From 5b97c759fe0d2fba8f22f572f0a39eabab53f778 Mon Sep 17 00:00:00 2001
From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com>
Date: Sat, 15 Aug 2026 23:25:12 -0500
Subject: [PATCH] V4.4 revision to facsimile print format
---
docs/ui/pages/documents.md | 4 ++-
docs/ver4.4/implementation_plan_v4_4.md | 1 +
docs/ver4.4/scope_boundary_v4_4.md | 1 +
src/transcription/api/v4_print.py | 2 +-
src/transcription/services/documents.py | 2 ++
src/transcription/ui/pages/documents_page.py | 4 +++
.../ui/pages/print_preview_page.py | 13 ++++---
src/transcription/ui/static/theme.css | 36 +++++++++++++++++++
tests/services/test_v44_workflows.py | 6 +++-
tests/ui/test_documents_page.py | 2 ++
tests/ui/test_print_preview_page.py | 14 +++++++-
11 files changed, 76 insertions(+), 9 deletions(-)
diff --git a/docs/ui/pages/documents.md b/docs/ui/pages/documents.md
index 9adeff7..219117e 100644
--- a/docs/ui/pages/documents.md
+++ b/docs/ui/pages/documents.md
@@ -65,7 +65,7 @@ Rules:
- The heading shows name, type, and internal ID.
- The first Source, when present, appears in the dark-room viewer.
-- Archival Metadata shows authors, compact Document date, location, and archive identifier. Notes appear in a separate archival-notes block within the same card.
+- Archival Metadata shows authors, Document Type, compact Document date, location, and archive identifier. Notes appear in a separate archival-notes block within the same card.
- System Logistics shows created and updated timestamps.
- Related People are grouped by role and link to Person Detail.
- **Sources & Pipeline Jobs** shows counts and actions for filtered Sources, Document Jobs, and adding a Job.
@@ -79,6 +79,8 @@ Rules:
- **Text only** omits images, joins single line breaks inside paragraphs, and preserves blank-line paragraph boundaries.
- Non-null revised text takes precedence over raw transcription, including an intentionally empty revision.
- Archival metadata resolves Author through the hidden built-in semantic identity, not its mutable label.
+- Archival metadata includes the Document Type label.
+- Metadata tables use a narrow non-wrapping label column and wider wrapping data columns rather than stretching across the page.
- Job metadata uses one oldest-to-newest column per Job and ends with Status.
- Stored text is escaped and Source media uses record-validated application URLs rather than local file paths.
- Printing uses the browser print dialog; server-generated PDFs are not provided.
diff --git a/docs/ver4.4/implementation_plan_v4_4.md b/docs/ver4.4/implementation_plan_v4_4.md
index 2b0ce11..940501e 100644
--- a/docs/ver4.4/implementation_plan_v4_4.md
+++ b/docs/ver4.4/implementation_plan_v4_4.md
@@ -126,6 +126,7 @@ Deliver hidden semantic identity for built-in registries, a single atomic Linked
- Add a Print action to Document Detail.
- Open a dedicated persisted-Document print route with a Facsimile/Text-only format choice.
- Render the exact content order frozen in the scope.
+- Keep print metadata tables content-sized, with a non-wrapping label column and wider wrapping value columns.
- Render stored Notes and transcription as escaped text.
- For Text-only mode, normalize whitespace by joining single line breaks inside paragraphs while preserving blank-line paragraph boundaries.
- For Facsimile mode, preserve line breaks and use a two-column Source layout.
diff --git a/docs/ver4.4/scope_boundary_v4_4.md b/docs/ver4.4/scope_boundary_v4_4.md
index 3aac488..b9beffc 100644
--- a/docs/ver4.4/scope_boundary_v4_4.md
+++ b/docs/ver4.4/scope_boundary_v4_4.md
@@ -104,6 +104,7 @@ The print view contains, in this order:
1. Document title using the Document name.
2. Archival Metadata table:
- Author, containing People linked through the built-in `author` role.
+ - Document Type.
- Date.
- Location Created.
- Archival Identifier.
diff --git a/src/transcription/api/v4_print.py b/src/transcription/api/v4_print.py
index 4b4e3cd..c37bc21 100644
--- a/src/transcription/api/v4_print.py
+++ b/src/transcription/api/v4_print.py
@@ -51,4 +51,4 @@ async def read_document_source_media(
media_type = SOURCE_MIME_TYPES.get(path.suffix.lower())
if media_type is None:
raise HTTPException(status_code=415, detail="Unsupported Source media type")
- return FileResponse(path, media_type=media_type, filename=source.upload_name)
+ return FileResponse(path, media_type=media_type)
diff --git a/src/transcription/services/documents.py b/src/transcription/services/documents.py
index a52db38..c5a510b 100644
--- a/src/transcription/services/documents.py
+++ b/src/transcription/services/documents.py
@@ -95,6 +95,7 @@ class DocumentPrintJob:
class DocumentPrintProjection:
id: UUID
title: str
+ document_type: str | None
authors: tuple[str, ...]
document_date: date | None
document_date_raw: str | None
@@ -335,6 +336,7 @@ class DocumentService(ServiceBase):
return DocumentPrintProjection(
id=document.id,
title=document.name,
+ document_type=document.document_type_ref.label if document.document_type_ref is not None else None,
authors=tuple(authors),
document_date=document.document_date,
document_date_raw=document.document_date_raw,
diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py
index 6e60fd2..c9dbe54 100644
--- a/src/transcription/ui/pages/documents_page.py
+++ b/src/transcription/ui/pages/documents_page.py
@@ -528,6 +528,10 @@ def _render_bento_metadata_zone(document: Document) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"):
metadata_row("Author(s):", ", ".join(author_names) if author_names else "Not set")
+ metadata_row(
+ "Document Type:",
+ document.document_type_ref.label if document.document_type_ref is not None else "Not set",
+ )
metadata_row("Document Date:", compact_date(document.document_date, document.document_date_raw))
metadata_row("Location Created:", document.location_created or "Not set")
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
diff --git a/src/transcription/ui/pages/print_preview_page.py b/src/transcription/ui/pages/print_preview_page.py
index 17cffcf..3c9808e 100644
--- a/src/transcription/ui/pages/print_preview_page.py
+++ b/src/transcription/ui/pages/print_preview_page.py
@@ -101,17 +101,20 @@ def _render_facsimile_source(*, document_id: UUID, source: DocumentPrintSource,
with ui.row().classes("print-facsimile-row w-full items-start gap-4"):
media_url = f"/api/v4/documents/{document_id}/sources/{source.id}/media"
if source.media_type == "application/pdf":
- ui.html(
- f''
+ ui.element("iframe").props(f'src="{media_url}" title="Source page {source.page_number}"').classes(
+ "print-source-pdf"
)
else:
- ui.image(media_url).classes("print-source-image")
+ ui.element("img").props(f'src="{media_url}" alt="Source page {source.page_number}"').classes(
+ "print-source-image"
+ )
ui.label(text).classes("print-transcription print-preserve-lines")
def _render_metadata_table(projection: DocumentPrintProjection) -> None:
rows = [
{"field": "Author", "value": ", ".join(projection.authors) or "Not set"},
+ {"field": "Document Type", "value": projection.document_type or "Not set"},
{
"field": "Date",
"value": compact_date(projection.document_date, projection.document_date_raw) or "Not set",
@@ -127,7 +130,7 @@ def _render_metadata_table(projection: DocumentPrintProjection) -> None:
rows=rows,
row_key="field",
pagination={"rowsPerPage": 0},
- ).props("flat hide-header").classes("print-metadata-table w-full")
+ ).props("flat hide-header hide-bottom").classes("print-data-table print-metadata-table")
def _render_job_table(jobs: tuple[DocumentPrintJob, ...]) -> None:
@@ -155,7 +158,7 @@ def _render_job_table(jobs: tuple[DocumentPrintJob, ...]) -> None:
rows=rows,
row_key="field",
pagination={"rowsPerPage": 0},
- ).props("flat hide-bottom").classes("print-job-table w-full")
+ ).props("flat hide-bottom").classes("print-data-table print-job-table")
def reflow_transcription(text: str) -> list[str]:
diff --git a/src/transcription/ui/static/theme.css b/src/transcription/ui/static/theme.css
index ed32954..5e352a0 100644
--- a/src/transcription/ui/static/theme.css
+++ b/src/transcription/ui/static/theme.css
@@ -69,6 +69,42 @@ input:focus-visible,
border: 0;
}
+.print-data-table {
+ --print-label-column-width: 11rem;
+ --print-value-column-width: 20rem;
+ align-self: flex-start;
+ width: fit-content;
+ max-width: 100%;
+}
+
+.print-data-table .q-table__container,
+.print-data-table .q-table__middle,
+.print-data-table .q-table {
+ width: auto;
+ max-width: 100%;
+}
+
+.print-data-table .q-table {
+ table-layout: fixed;
+}
+
+.print-data-table .q-table th:first-child,
+.print-data-table .q-table td:first-child {
+ width: var(--print-label-column-width);
+ min-width: var(--print-label-column-width);
+ max-width: var(--print-label-column-width);
+ white-space: nowrap;
+}
+
+.print-data-table .q-table th:not(:first-child),
+.print-data-table .q-table td:not(:first-child) {
+ width: var(--print-value-column-width);
+ min-width: var(--print-value-column-width);
+ max-width: var(--print-value-column-width);
+ white-space: normal;
+ overflow-wrap: anywhere;
+}
+
.print-transcription {
flex: 1;
line-height: 1.5;
diff --git a/tests/services/test_v44_workflows.py b/tests/services/test_v44_workflows.py
index c739409..67381e2 100644
--- a/tests/services/test_v44_workflows.py
+++ b/tests/services/test_v44_workflows.py
@@ -107,7 +107,10 @@ async def test_document_print_projection_uses_semantic_author_and_current_text(d
people = PeopleService(session_factory=default_session_factory)
sources = SourceService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
- document = await documents.create_document(Document(name="Print Me", notes="Archive note"))
+ document_type = await documents.create_document_type(label="Print Type")
+ document = await documents.create_document(
+ Document(name="Print Me", notes="Archive note", document_type_id=document_type.id)
+ )
person = await people.create_person(Person(full_name="Historic Author"))
async with people._session_scope() as session:
@@ -160,6 +163,7 @@ async def test_document_print_projection_uses_semantic_author_and_current_text(d
projection = await documents.read_document_print_projection(document.id)
assert projection.authors == ("Historic Author",)
+ assert projection.document_type == "Print Type"
assert [source.page_number for source in projection.sources] == [1, 2]
assert [source.current_text for source in projection.sources] == ["raw first", "revised second"]
assert [source.media_type for source in projection.sources] == ["image/png", "image/png"]
diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py
index 58d52e5..2247d0a 100644
--- a/tests/ui/test_documents_page.py
+++ b/tests/ui/test_documents_page.py
@@ -125,6 +125,8 @@ class TestDocumentsPageRendering:
assert "Letter from Hig" in response.text
assert "ZC-1924-001" in response.text
assert "Zenna Cochran" in response.text
+ assert "Document Type:" in response.text
+ assert "Letter" in response.text
assert "PIPELINE JOBS" in response.text.upper()
assert "Edit Document" in response.text
diff --git a/tests/ui/test_print_preview_page.py b/tests/ui/test_print_preview_page.py
index 7255aa6..8f50175 100644
--- a/tests/ui/test_print_preview_page.py
+++ b/tests/ui/test_print_preview_page.py
@@ -4,6 +4,7 @@ import pytest
from transcription.db import session_scope
from transcription.db.models import Document
+from transcription.db.models import DocumentType
from transcription.db.models import Source
from transcription.ui.pages.print_preview_page import reflow_transcription
@@ -26,7 +27,14 @@ async def test_document_print_preview_and_safe_media_route(app_client):
pdf_path.write_bytes(b"%PDF-1.4\n%%EOF")
async with session_scope() as session:
- document = Document(name="", notes="")
+ document_type = DocumentType(label="Photograph", normalized_label="photograph")
+ session.add(document_type)
+ await session.flush()
+ document = Document(
+ name="",
+ notes="",
+ document_type_id=document_type.id,
+ )
session.add(document)
await session.flush()
source = Source(
@@ -60,16 +68,20 @@ async def test_document_print_preview_and_safe_media_route(app_client):
assert response.status_code == 200
assert "Print & Preserve" in response.text
assert "<script>unsafe()</script>" in response.text
+ assert "Document Type" in response.text
+ assert "Photograph" in response.text
assert "Facsimile" in response.text
assert "Text only" in response.text
assert "print-page-break" in response.text
assert "print-source-pdf" in response.text
+ assert "print-source-image" in response.text
assert str(media_path) not in response.text
assert str(pdf_path) not in response.text
media_response = client.get(f"/api/v4/documents/{document_id}/sources/{source_id}/media")
assert media_response.status_code == 200
assert media_response.headers["content-type"] == "image/png"
+ assert "content-disposition" not in media_response.headers
@pytest.mark.integration