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