V4.4 revision to facsimile print format

This commit is contained in:
Jim Lancaster
2026-08-15 23:25:12 -05:00
parent 7db4df1729
commit 5b97c759fe
11 changed files with 76 additions and 9 deletions
+3 -1
View File
@@ -65,7 +65,7 @@ Rules:
- The heading shows name, type, and internal ID. - The heading shows name, type, and internal ID.
- The first Source, when present, appears in the dark-room viewer. - 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. - System Logistics shows created and updated timestamps.
- Related People are grouped by role and link to Person Detail. - 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. - **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. - **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. - 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 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. - 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. - 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. - Printing uses the browser print dialog; server-generated PDFs are not provided.
+1
View File
@@ -126,6 +126,7 @@ Deliver hidden semantic identity for built-in registries, a single atomic Linked
- Add a Print action to Document Detail. - Add a Print action to Document Detail.
- Open a dedicated persisted-Document print route with a Facsimile/Text-only format choice. - Open a dedicated persisted-Document print route with a Facsimile/Text-only format choice.
- Render the exact content order frozen in the scope. - 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. - 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 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. - For Facsimile mode, preserve line breaks and use a two-column Source layout.
+1
View File
@@ -104,6 +104,7 @@ The print view contains, in this order:
1. Document title using the Document name. 1. Document title using the Document name.
2. Archival Metadata table: 2. Archival Metadata table:
- Author, containing People linked through the built-in `author` role. - Author, containing People linked through the built-in `author` role.
- Document Type.
- Date. - Date.
- Location Created. - Location Created.
- Archival Identifier. - Archival Identifier.
+1 -1
View File
@@ -51,4 +51,4 @@ async def read_document_source_media(
media_type = SOURCE_MIME_TYPES.get(path.suffix.lower()) media_type = SOURCE_MIME_TYPES.get(path.suffix.lower())
if media_type is None: if media_type is None:
raise HTTPException(status_code=415, detail="Unsupported Source media type") 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)
+2
View File
@@ -95,6 +95,7 @@ class DocumentPrintJob:
class DocumentPrintProjection: class DocumentPrintProjection:
id: UUID id: UUID
title: str title: str
document_type: str | None
authors: tuple[str, ...] authors: tuple[str, ...]
document_date: date | None document_date: date | None
document_date_raw: str | None document_date_raw: str | None
@@ -335,6 +336,7 @@ class DocumentService(ServiceBase):
return DocumentPrintProjection( return DocumentPrintProjection(
id=document.id, id=document.id,
title=document.name, title=document.name,
document_type=document.document_type_ref.label if document.document_type_ref is not None else None,
authors=tuple(authors), authors=tuple(authors),
document_date=document.document_date, document_date=document.document_date,
document_date_raw=document.document_date_raw, document_date_raw=document.document_date_raw,
@@ -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 ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"): with archival_card(title="Archival Metadata"):
metadata_row("Author(s):", ", ".join(author_names) if author_names else "Not set") 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("Document Date:", compact_date(document.document_date, document.document_date_raw))
metadata_row("Location Created:", document.location_created or "Not set") metadata_row("Location Created:", document.location_created or "Not set")
metadata_row("Archive Identifier:", document.archive_identifier or "Not set") metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
@@ -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"): 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" media_url = f"/api/v4/documents/{document_id}/sources/{source.id}/media"
if source.media_type == "application/pdf": if source.media_type == "application/pdf":
ui.html( ui.element("iframe").props(f'src="{media_url}" title="Source page {source.page_number}"').classes(
f'<iframe class="print-source-pdf" src="{media_url}" title="Source page {source.page_number}"></iframe>' "print-source-pdf"
) )
else: 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") ui.label(text).classes("print-transcription print-preserve-lines")
def _render_metadata_table(projection: DocumentPrintProjection) -> None: def _render_metadata_table(projection: DocumentPrintProjection) -> None:
rows = [ rows = [
{"field": "Author", "value": ", ".join(projection.authors) or "Not set"}, {"field": "Author", "value": ", ".join(projection.authors) or "Not set"},
{"field": "Document Type", "value": projection.document_type or "Not set"},
{ {
"field": "Date", "field": "Date",
"value": compact_date(projection.document_date, projection.document_date_raw) or "Not set", "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, rows=rows,
row_key="field", row_key="field",
pagination={"rowsPerPage": 0}, 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: def _render_job_table(jobs: tuple[DocumentPrintJob, ...]) -> None:
@@ -155,7 +158,7 @@ def _render_job_table(jobs: tuple[DocumentPrintJob, ...]) -> None:
rows=rows, rows=rows,
row_key="field", row_key="field",
pagination={"rowsPerPage": 0}, 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]: def reflow_transcription(text: str) -> list[str]:
+36
View File
@@ -69,6 +69,42 @@ input:focus-visible,
border: 0; 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 { .print-transcription {
flex: 1; flex: 1;
line-height: 1.5; line-height: 1.5;
+5 -1
View File
@@ -107,7 +107,10 @@ async def test_document_print_projection_uses_semantic_author_and_current_text(d
people = PeopleService(session_factory=default_session_factory) people = PeopleService(session_factory=default_session_factory)
sources = SourceService(session_factory=default_session_factory) sources = SourceService(session_factory=default_session_factory)
jobs = JobService(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")) person = await people.create_person(Person(full_name="Historic Author"))
async with people._session_scope() as session: 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) projection = await documents.read_document_print_projection(document.id)
assert projection.authors == ("Historic Author",) assert projection.authors == ("Historic Author",)
assert projection.document_type == "Print Type"
assert [source.page_number for source in projection.sources] == [1, 2] 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.current_text for source in projection.sources] == ["raw first", "revised second"]
assert [source.media_type for source in projection.sources] == ["image/png", "image/png"] assert [source.media_type for source in projection.sources] == ["image/png", "image/png"]
+2
View File
@@ -125,6 +125,8 @@ class TestDocumentsPageRendering:
assert "Letter from Hig" in response.text assert "Letter from Hig" in response.text
assert "ZC-1924-001" in response.text assert "ZC-1924-001" in response.text
assert "Zenna Cochran" 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 "PIPELINE JOBS" in response.text.upper()
assert "Edit Document" in response.text assert "Edit Document" in response.text
+13 -1
View File
@@ -4,6 +4,7 @@ import pytest
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentType
from transcription.db.models import Source from transcription.db.models import Source
from transcription.ui.pages.print_preview_page import reflow_transcription 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") pdf_path.write_bytes(b"%PDF-1.4\n%%EOF")
async with session_scope() as session: async with session_scope() as session:
document = Document(name="<Print & Preserve>", notes="<script>unsafe()</script>") document_type = DocumentType(label="Photograph", normalized_label="photograph")
session.add(document_type)
await session.flush()
document = Document(
name="<Print & Preserve>",
notes="<script>unsafe()</script>",
document_type_id=document_type.id,
)
session.add(document) session.add(document)
await session.flush() await session.flush()
source = Source( source = Source(
@@ -60,16 +68,20 @@ async def test_document_print_preview_and_safe_media_route(app_client):
assert response.status_code == 200 assert response.status_code == 200
assert "Print &amp; Preserve" in response.text assert "Print &amp; Preserve" in response.text
assert "&lt;script&gt;unsafe()&lt;/script&gt;" in response.text assert "&lt;script&gt;unsafe()&lt;/script&gt;" in response.text
assert "Document Type" in response.text
assert "Photograph" in response.text
assert "Facsimile" in response.text assert "Facsimile" in response.text
assert "Text only" in response.text assert "Text only" in response.text
assert "print-page-break" in response.text assert "print-page-break" in response.text
assert "print-source-pdf" 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(media_path) not in response.text
assert str(pdf_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") media_response = client.get(f"/api/v4/documents/{document_id}/sources/{source_id}/media")
assert media_response.status_code == 200 assert media_response.status_code == 200
assert media_response.headers["content-type"] == "image/png" assert media_response.headers["content-type"] == "image/png"
assert "content-disposition" not in media_response.headers
@pytest.mark.integration @pytest.mark.integration