From 94db4937565291d5370412a3b08c8df6ca304612 Mon Sep 17 00:00:00 2001 From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:56:22 -0500 Subject: [PATCH] Revise Source detail page to improve line-wrap issues. --- docs/ui/pages/sources.md | 4 +- src/transcription/ui/pages/sources_page.py | 141 ++++++++++++++++++--- tests/ui/test_sources_page.py | 34 +++++ 3 files changed, 160 insertions(+), 19 deletions(-) diff --git a/docs/ui/pages/sources.md b/docs/ui/pages/sources.md index 1399830..f0ba3f1 100644 --- a/docs/ui/pages/sources.md +++ b/docs/ui/pages/sources.md @@ -34,7 +34,9 @@ The list accepts optional `document_id` and `job_id` query parameters. Document - **Delete Source** opens the guarded delete route. - Previous and Next navigate only among Sources belonging to the same Document in page order; unavailable boundary actions are disabled. - The media viewer resolves the stored Source path through the configured upload root. -- The top layout is three columns: Source image, Editable Revision, and Source/SourceJob metadata. +- The top layout is adaptive: + - Standard pages use three columns with a wider Editable Revision column than the image column. + - Wide+narrow landscape images switch to a stacked left layout (image above Editable Revision) with metadata on the right. - Editable Revision is seeded from an existing revision or the preferred machine transcription. - Source Metadata shows upload name, stored filename, page number, Document Name, Document ID, and stored path. Source ID appears in the page-header subtitle. - SourceJob Metadata shows latest status, Job ID, execution time, provider, model, prompt, and failure detail. diff --git a/src/transcription/ui/pages/sources_page.py b/src/transcription/ui/pages/sources_page.py index 538ea6e..01d59c0 100644 --- a/src/transcription/ui/pages/sources_page.py +++ b/src/transcription/ui/pages/sources_page.py @@ -4,10 +4,14 @@ from __future__ import annotations import base64 import json +import logging +from pathlib import Path from uuid import UUID from fastapi import Request from nicegui import ui +from PIL import Image +from PIL import UnidentifiedImageError from transcription.config import Settings from transcription.db.models import ExecutionAttempt @@ -18,6 +22,7 @@ from transcription.services.errors import SourceDeleteBlockedError from transcription.services.errors import TranscriptionNotFoundError from transcription.services.evidence import EvidenceService from transcription.services.evidence import LatestExecutionAttempt +from transcription.services.source_media import lookup_source_mime_type from transcription.services.sources import SourceService from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.cards import archival_card @@ -43,6 +48,10 @@ from transcription.ui.theme import page_header from ...db.session import SessionFactoryDep +logger = logging.getLogger(__name__) + +_WIDE_NARROW_ASPECT_RATIO_THRESHOLD = 2.2 + def register_page() -> None: # noqa: PLR0915 """Register sources list, detail, and deletion routes.""" @@ -120,6 +129,7 @@ def register_page() -> None: # noqa: PLR0915 sources_service = SourceService(session_factory=session_factory) evidence_service = EvidenceService(session_factory=session_factory) render_navigation_header(current_path="/sources") + settings = resolve_runtime_settings(request) parsed_source_id = parsed_record_id(source_id, noun="Source") if parsed_source_id is None: @@ -143,6 +153,7 @@ def register_page() -> None: # noqa: PLR0915 return original_transcription = _resolve_original_transcription(source=source, latest_attempt=latest_attempt) + layout_mode = _resolve_source_detail_layout(source=source, settings=settings) with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): with section_header_row(): @@ -176,23 +187,27 @@ def register_page() -> None: # noqa: PLR0915 extra_classes="text-xs", ) with ui.grid().classes("w-full grid-cols-12 gap-4"): - with ui.column().classes("col-span-12 lg:col-span-8 gap-4"): - with ui.grid().classes("w-full grid-cols-1 lg:grid-cols-2 gap-4"): - with ui.column().classes("gap-2"): - _render_source_navigation(navigation.previous_id, navigation.next_id) - _render_source_viewer_zone( - source, - settings=resolve_runtime_settings(request), - request=request, - ) - _render_source_transcription_column( - source=source, - original_transcription=original_transcription, - latest_job_source=latest_job_source, - sources_service=sources_service, - ) - _render_machine_candidates( + if layout_mode == "wide_narrow": + _render_wide_narrow_source_content( source=source, + navigation=navigation, + request=request, + settings=settings, + original_transcription=original_transcription, + latest_job_source=latest_job_source, + sources_service=sources_service, + attempts=attempts, + evidence_service=evidence_service, + ) + else: + _render_standard_source_content( + source=source, + navigation=navigation, + request=request, + settings=settings, + original_transcription=original_transcription, + latest_job_source=latest_job_source, + sources_service=sources_service, attempts=attempts, evidence_service=evidence_service, ) @@ -200,7 +215,10 @@ def register_page() -> None: # noqa: PLR0915 source=source, latest_job_source=latest_job_source, latest_attempt=latest_attempt, - settings=resolve_runtime_settings(request), + settings=settings, + column_classes="col-span-12 lg:col-span-4 gap-4" + if layout_mode == "wide_narrow" + else "col-span-12 lg:col-span-3 gap-4", ) @ui.page("/sources/{source_id}/delete") @@ -312,14 +330,72 @@ def _render_source_transcription_column( ) +def _render_standard_source_content( + *, + source: Source, + navigation: SourceNavigation, + request: Request, + settings: Settings, + original_transcription: str | None, + latest_job_source: JobSource | None, + sources_service: SourceService, + attempts: list[ExecutionAttempt], + evidence_service: EvidenceService, +) -> None: + with ui.column().classes("col-span-12 lg:col-span-4 gap-2"): + _render_source_navigation(navigation.previous_id, navigation.next_id) + _render_source_viewer_zone(source, settings=settings, request=request) + with ui.column().classes("col-span-12 lg:col-span-5 gap-4"): + _render_source_transcription_zone( + source=source, + original_transcription=original_transcription, + latest_job_source=latest_job_source, + sources_service=sources_service, + ) + _render_machine_candidates( + source=source, + attempts=attempts, + evidence_service=evidence_service, + ) + + +def _render_wide_narrow_source_content( + *, + source: Source, + navigation: SourceNavigation, + request: Request, + settings: Settings, + original_transcription: str | None, + latest_job_source: JobSource | None, + sources_service: SourceService, + attempts: list[ExecutionAttempt], + evidence_service: EvidenceService, +) -> None: + with ui.column().classes("col-span-12 lg:col-span-8 gap-4"): + _render_source_navigation(navigation.previous_id, navigation.next_id) + _render_source_viewer_zone(source, settings=settings, request=request) + _render_source_transcription_zone( + source=source, + original_transcription=original_transcription, + latest_job_source=latest_job_source, + sources_service=sources_service, + ) + _render_machine_candidates( + source=source, + attempts=attempts, + evidence_service=evidence_service, + ) + + def _render_source_metadata_column( *, source: Source, latest_job_source: JobSource | None, latest_attempt: LatestExecutionAttempt | None, settings: Settings, + column_classes: str = "col-span-12 lg:col-span-4 gap-4", ) -> None: - with ui.column().classes("col-span-12 lg:col-span-4 gap-4"): + with ui.column().classes(column_classes): _render_source_metadata_zone(source, settings=settings) _render_source_job_metadata_zone( latest_job_source, @@ -328,6 +404,35 @@ def _render_source_metadata_column( _render_source_revision_logistics_zone(source) +def _resolve_source_detail_layout(*, source: Source, settings: Settings) -> str: + media_type = lookup_source_mime_type(source.file_path) + if not media_type.startswith("image/"): + return "standard" + absolute = (settings.upload_dir / Path(source.file_path)).resolve() + dimensions = _read_image_dimensions(absolute) + if dimensions is None: + return "standard" + width, height = dimensions + if _is_wide_narrow_image(width=width, height=height): + return "wide_narrow" + return "standard" + + +def _is_wide_narrow_image(*, width: int, height: int) -> bool: + if width <= 0 or height <= 0: + return False + return (width / height) >= _WIDE_NARROW_ASPECT_RATIO_THRESHOLD + + +def _read_image_dimensions(path: Path) -> tuple[int, int] | None: + try: + with Image.open(path) as image: + return image.size + except (FileNotFoundError, OSError, UnidentifiedImageError): + logger.debug("Unable to read image dimensions for source detail layout: %s", path) + return None + + def _render_source_metadata_zone(source: Source, *, settings: Settings) -> None: with archival_card(title="Source Metadata"): metadata_row("Upload Name:", source.upload_name) diff --git a/tests/ui/test_sources_page.py b/tests/ui/test_sources_page.py index 72c3317..266bed7 100644 --- a/tests/ui/test_sources_page.py +++ b/tests/ui/test_sources_page.py @@ -6,6 +6,7 @@ from pathlib import Path from uuid import uuid4 import pytest +from PIL import Image from sqlmodel import select from transcription.db import session_scope @@ -20,6 +21,8 @@ from transcription.db.models import JobStatus from transcription.db.models import Source from transcription.providers.evidence import TransportEvidence from transcription.services.sources import SourceService +from transcription.ui.pages.sources_page import _is_wide_narrow_image +from transcription.ui.pages.sources_page import _resolve_source_detail_layout # --- Unit Tests for Model @property Definitions --- @@ -44,6 +47,37 @@ class TestSourceModelProperties: assert source.latest_error_detail is None assert source.document_name is None + def test_wide_narrow_aspect_detection_threshold(self): + assert _is_wide_narrow_image(width=2200, height=1000) + assert not _is_wide_narrow_image(width=1800, height=1000) + assert not _is_wide_narrow_image(width=0, height=1000) + + @pytest.mark.asyncio + async def test_source_detail_layout_switches_for_wide_narrow_images(self, app_client): + app, _ = app_client + async with session_scope() as session: + document = Document(name="Layout test doc") + session.add(document) + await session.flush() + + wide_path = app.state.settings.upload_dir / "documents" / str(document.id) / "wide-strip.png" + wide_path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (2400, 800), color=(255, 255, 255)).save(wide_path, format="PNG") + + source = Source( + document_id=document.id, + page_number=1, + upload_name="wide-strip.png", + filename="wide-strip.png", + file_path=wide_path.relative_to(app.state.settings.upload_dir).as_posix(), + file_hash="c" * 64, + file_size_bytes=wide_path.stat().st_size, + ) + session.add(source) + await session.commit() + + assert _resolve_source_detail_layout(source=source, settings=app.state.settings) == "wide_narrow" + @pytest.mark.asyncio async def test_source_properties_with_document_and_job_sources(self, seed_job): job_id = await seed_job(