generated from john/python-template
V4.4 revision to facsimile print format
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 & Preserve" in response.text
|
assert "Print & Preserve" in response.text
|
||||||
assert "<script>unsafe()</script>" 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 "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
|
||||||
|
|||||||
Reference in New Issue
Block a user