diff --git a/.github/workflows/pr-visual-validation.yml b/.github/workflows/pr-visual-validation.yml index 2ae5802..4aa9a41 100644 --- a/.github/workflows/pr-visual-validation.yml +++ b/.github/workflows/pr-visual-validation.yml @@ -68,6 +68,9 @@ jobs: env: ARTIFACT_DIGEST: ${{ steps.upload.outputs.artifact-digest }} ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} + BASE_REPOSITORY: ${{ github.repository }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} shell: bash run: | set -euo pipefail @@ -79,6 +82,12 @@ jobs: echo "Artifact SHA-256: \`${ARTIFACT_DIGEST}\`" echo echo "After downloading and unzipping, open \`review.html\` for side-by-side inspection." + echo + if [[ "${HEAD_REPOSITORY}" == "${BASE_REPOSITORY}" ]]; then + echo "[Open this exact commit's rendered capability demo](https://rawcdn.githack.com/${HEAD_REPOSITORY}/${HEAD_SHA}/examples/review.html)" + else + echo "External HTML preview is intentionally omitted for fork pull requests; review the generated artifact instead." + fi } >> "${GITHUB_STEP_SUMMARY}" link: @@ -98,6 +107,7 @@ jobs: env: ARTIFACT_DIGEST: ${{ needs.build.outputs.artifact_digest }} ARTIFACT_URL: ${{ needs.build.outputs.artifact_url }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} REPOSITORY: ${{ github.repository }} @@ -129,6 +139,8 @@ jobs: echo echo "No external content, OCR, AI, or ML is used; provenance and licensing are included in \`manifest.json\`." echo + echo "[Open this exact commit's rendered capability demo](https://rawcdn.githack.com/${HEAD_REPOSITORY}/${HEAD_SHA}/examples/review.html)." + echo echo "[Browse this revision's committed capability demo](https://github.com/${REPOSITORY}/tree/${HEAD_SHA}/examples); it is separate from this PR artifact." echo "" } > updated-body.md diff --git a/README.md b/README.md index 8c52554..ca1e8f1 100644 --- a/README.md +++ b/README.md @@ -153,10 +153,18 @@ Every accepted semantic element can retain evidence linking it back to its sourc ## Capability demo -[Open the rendered, side-by-side capability demo](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/review.html) -or [browse its committed inputs and exact outputs](examples/README.md). The three -complex source PDFs, fixture prose, and assets are first-party project material -under the bundled MIT license; no downloaded content, OCR, AI, or ML is used. +[Browse this revision's committed inputs and exact outputs](examples/README.md). +On `main`, [open the rendered, side-by-side capability demo](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/review.html). +Same-repository pull requests receive a commit-pinned rendered-demo link in +their description, so reviewing a child branch never silently opens `main`. +For security, fork pull requests expose the generated review artifact but not +a one-click third-party rendering of contributor-controlled HTML. + +The three complex source PDFs, fixture prose, and assets are first-party project +material under the bundled MIT license; no downloaded content, OCR, AI, or ML +is used. They remain separate because Tagged-PDF structure trees, AcroForm +fields, and outlines are document-catalog semantics: concatenating their pages +would change the evidence being tested rather than merely combine examples. The committed `examples/` tree is distinct from temporary pull-request review artifacts. CI regenerates it from the same case definitions and fails if any diff --git a/examples/README.md b/examples/README.md index bbdc93b..b271baa 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,16 +3,18 @@ All inputs and fixture prose are first-party project material under the bundled MIT license. No network content, OCR, AI, or ML was used. +The three PDFs are intentionally isolated: Tagged-PDF structure trees, AcroForm fields, and outlines are document-catalog semantics. Concatenating their pages would alter the evidence being tested and make failures less diagnostic. + This directory is the committed, reproducible capability demo. Pull-request review artifacts are generated separately and are never written here. -[Open the rendered side-by-side demo](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/review.html). GitHub displays committed HTML files as source code; this third-party browser preview renders the same files from `main` without a project website. +[Open the rendered main-branch side-by-side demo](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/review.html). GitHub displays committed HTML files as source code; this third-party browser preview renders the same files from `main` without a project website. Same-repository pull requests receive a separate commit-pinned rendered link in their description. -Each row links the source PDF to the exact output committed from the same CocoaPDF revision. +Relative row links resolve against the revision being viewed. External rendered-HTML links are explicitly labeled as `main`; a same-repository PR description supplies the exact commit-pinned rendered demo. Full semantic JSON is committed. Report summaries omit only duplicate semantic graphs and glyph-heavy internals; the temporary PR artifact retains every full report. | Case | Coverage | Input | Outputs | | --- | --- | --- | --- | -| `strategic_corner_cases` | Broad V1-V4 formatting, Unicode, lists, tables, figures, forms, columns, security, and fallback coverage. | [PDF](cases/strategic_corner_cases/input.pdf) | [full Markdown](cases/strategic_corner_cases/full/output.md), [full rendered HTML](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/cases/strategic_corner_cases/full/output.html), [full semantic JSON](cases/strategic_corner_cases/full/output.json), [full report](cases/strategic_corner_cases/full/output.report.summary.json) | -| `tagged_semantics` | Tagged heading, sibling ordered-list isolation, MCID provenance, and tagged table structure. | [PDF](cases/tagged_semantics/input.pdf) | [full Markdown](cases/tagged_semantics/full/output.md), [full rendered HTML](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/cases/tagged_semantics/full/output.html), [full semantic JSON](cases/tagged_semantics/full/output.json), [full report](cases/tagged_semantics/full/output.report.summary.json) | -| `scope_and_adversarial` | Page-range outline/AcroForm scope, dot-leader finance recovery, and diagram-versus-form false-positive resistance. | [PDF](cases/scope_and_adversarial/input.pdf) | [full Markdown](cases/scope_and_adversarial/full/output.md), [full rendered HTML](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/cases/scope_and_adversarial/full/output.html), [full semantic JSON](cases/scope_and_adversarial/full/output.json), [full report](cases/scope_and_adversarial/full/output.report.summary.json)
[page-2 Markdown](cases/scope_and_adversarial/page-2/output.md), [page-2 rendered HTML](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/cases/scope_and_adversarial/page-2/output.html), [page-2 semantic JSON](cases/scope_and_adversarial/page-2/output.json), [page-2 report](cases/scope_and_adversarial/page-2/output.report.summary.json) | +| `strategic_corner_cases` | Broad V1-V4 formatting, Unicode, lists, tables, figures, forms, columns, security, and fallback coverage. | [PDF](cases/strategic_corner_cases/input.pdf) | [full Markdown](cases/strategic_corner_cases/full/output.md), [full rendered HTML on main](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/cases/strategic_corner_cases/full/output.html), [full semantic JSON](cases/strategic_corner_cases/full/output.json), [full report](cases/strategic_corner_cases/full/output.report.summary.json) | +| `tagged_semantics` | Tagged heading, sibling ordered-list isolation, MCID provenance, and tagged table structure. | [PDF](cases/tagged_semantics/input.pdf) | [full Markdown](cases/tagged_semantics/full/output.md), [full rendered HTML on main](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/cases/tagged_semantics/full/output.html), [full semantic JSON](cases/tagged_semantics/full/output.json), [full report](cases/tagged_semantics/full/output.report.summary.json) | +| `scope_and_adversarial` | Page-range outline/AcroForm scope, valid heading anchors, dot-leader finance recovery, and two-sided diagram-versus-form/table fidelity. | [PDF](cases/scope_and_adversarial/input.pdf) | [full Markdown](cases/scope_and_adversarial/full/output.md), [full rendered HTML on main](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/cases/scope_and_adversarial/full/output.html), [full semantic JSON](cases/scope_and_adversarial/full/output.json), [full report](cases/scope_and_adversarial/full/output.report.summary.json)
[page-2 Markdown](cases/scope_and_adversarial/page-2/output.md), [page-2 rendered HTML on main](https://raw.githack.com/sayantandey/CocoaPDF/main/examples/cases/scope_and_adversarial/page-2/output.html), [page-2 semantic JSON](cases/scope_and_adversarial/page-2/output.json), [page-2 report](cases/scope_and_adversarial/page-2/output.report.summary.json) | diff --git a/examples/cases/scope_and_adversarial/full/assertions.json b/examples/cases/scope_and_adversarial/full/assertions.json index 6813a86..8b470fb 100644 --- a/examples/cases/scope_and_adversarial/full/assertions.json +++ b/examples/cases/scope_and_adversarial/full/assertions.json @@ -20,6 +20,102 @@ { "passed": true, "detail": "OCR remains disabled" + }, + { + "passed": true, + "detail": "page-only outline destinations resolve to unique page headings" + }, + { + "passed": true, + "detail": "unresolved navigation never emits a literal None anchor" + }, + { + "passed": true, + "detail": "both full-document AcroForm fields are retained" + }, + { + "passed": true, + "detail": "the finance grid remains a structural table" + }, + { + "passed": true, + "detail": "five outlined diagram panels are retained as one vector figure" + }, + { + "passed": true, + "detail": "all native diagram labels remain inside the vector figure" + }, + { + "passed": true, + "detail": "the SVG retains the outlined-panel stroke geometry" + }, + { + "passed": true, + "detail": "diagram labels are not flattened into false prose lines" + }, + { + "passed": true, + "detail": "Markdown and HTML both reference the generated diagram asset" + }, + { + "passed": true, + "detail": "semantic JSON records one vector image node" + }, + { + "passed": true, + "detail": "the vector figure retains page-2 provenance" + }, + { + "passed": true, + "detail": "the vector figure remains traceable to its source glyphs" + }, + { + "passed": true, + "detail": "diagram labels provide deterministic alternative text" + }, + { + "passed": true, + "detail": "the vector figure records PDF-native artwork evidence" + }, + { + "passed": true, + "detail": "report geometry shows one page-spanning, multi-row diagram" + }, + { + "passed": true, + "detail": "diagram preservation does not invoke image-text extraction" + }, + { + "passed": true, + "detail": "SecondField has one page-scoped semantic field" + }, + { + "passed": true, + "detail": "SecondField retains explicit PDF widget appearance evidence" + }, + { + "passed": true, + "detail": "SecondField preserves its explicitly large text size" + }, + { + "passed": true, + "detail": "SecondField preserves its dark blue-gray text color" + }, + { + "passed": true, + "detail": "SecondField preserves its pale blue-gray background" + }, + { + "passed": true, + "detail": "SecondField appearance remains traceable to independent PDF-native evidence" + }, + { + "passed": true, + "detail": "HTML renders only the evidenced field with its size and colors" + }, + { + "passed": true, + "detail": "documentary form output never creates an active browser control" } ] } diff --git a/examples/cases/scope_and_adversarial/full/assets/vector-a60ea913b6a0a8ba.svg b/examples/cases/scope_and_adversarial/full/assets/vector-a60ea913b6a0a8ba.svg new file mode 100644 index 0000000..6412f5a --- /dev/null +++ b/examples/cases/scope_and_adversarial/full/assets/vector-a60ea913b6a0a8ba.svg @@ -0,0 +1,28 @@ + +Vector artwork preserved from PDF graphics and text + + + + + + + + + + + + + + + + + + + + +Parse +Layout +Render +Decode +Reconcile + \ No newline at end of file diff --git a/examples/cases/scope_and_adversarial/full/output.html b/examples/cases/scope_and_adversarial/full/output.html index 8400a9c..4a4a10d 100644 --- a/examples/cases/scope_and_adversarial/full/output.html +++ b/examples/cases/scope_and_adversarial/full/output.html @@ -25,10 +25,13 @@ .cocoapdf-table-note { font-size: 0.9em; font-style: italic; } .cocoapdf-cross-reference { text-decoration: none; } .cocoapdf-form { display: grid; gap: 0.65rem; margin: 1rem 0; } +.cocoapdf-form-field { display: grid; grid-template-columns: minmax(8rem, max-content) 1fr; column-gap: 0.5rem; } +.cocoapdf-form-field-name { font-weight: 600; } +.cocoapdf-form-field-value-evidenced { align-items: center; box-sizing: border-box; display: inline-flex; padding: 0 0.25rem; } .cocoapdf-equation { overflow-x: auto; margin: 1rem 0; } - +

Page Scope Review

First page body and first field.

FirstField: Alpha

@@ -45,8 +48,9 @@

Selected Evidence Page

SecondField: Beta

Diagram boxes below are not form controls.

-

Parse Layout Render

-

Decode Reconcile

-
FirstFieldAlpha
SecondFieldBeta
+
+Parse · Layout · Render · Decode · Reconcile +
+
FirstField: Alpha
SecondField: Beta
diff --git a/examples/cases/scope_and_adversarial/full/output.json b/examples/cases/scope_and_adversarial/full/output.json index c7400b0..09d4c23 100644 --- a/examples/cases/scope_and_adversarial/full/output.json +++ b/examples/cases/scope_and_adversarial/full/output.json @@ -14,7 +14,7 @@ 2 ], "outline": { - "id": "node-56", + "id": "node-48", "kind": "outline", "text": "", "attrs": { @@ -22,7 +22,7 @@ }, "children": [ { - "id": "node-54", + "id": "node-46", "kind": "outline_item", "text": "First Page", "attrs": { @@ -34,8 +34,8 @@ null, null ], - "target_id": null, - "target_anchor": null, + "target_id": "node-3", + "target_anchor": "page-scope-review", "open": true }, "children": [], @@ -68,7 +68,7 @@ "warnings": [] }, { - "id": "node-55", + "id": "node-47", "kind": "outline_item", "text": "Selected Evidence", "attrs": { @@ -80,8 +80,8 @@ null, null ], - "target_id": null, - "target_anchor": null, + "target_id": "node-10", + "target_anchor": "selected-evidence-page", "open": true }, "children": [], @@ -155,7 +155,7 @@ }, "children": [ { - "id": "node-59", + "id": "node-51", "kind": "toc", "text": "", "attrs": { @@ -163,13 +163,13 @@ }, "children": [ { - "id": "node-57", + "id": "node-49", "kind": "toc_item", "text": "First Page", "attrs": { "level": 1, - "target_id": null, - "target_anchor": null, + "target_id": "node-3", + "target_anchor": "page-scope-review", "page": 1 }, "children": [], @@ -202,13 +202,13 @@ "warnings": [] }, { - "id": "node-58", + "id": "node-50", "kind": "toc_item", "text": "Selected Evidence", "attrs": { "level": 1, - "target_id": null, - "target_anchor": null, + "target_id": "node-10", + "target_anchor": "selected-evidence-page", "page": 2 }, "children": [], @@ -399,7 +399,7 @@ "warnings": [] } ], - "confidence": 0.92, + "confidence": 0.9, "evidence": [ { "kind": "geometric_semantic_detector", @@ -411,6 +411,13 @@ "p1-body-1" ] } + }, + { + "kind": "pdf_outline_unique_page_heading", + "confidence": 0.9, + "detail": "First Page", + "page": 1, + "data": {} } ], "sources": [ @@ -747,7 +754,7 @@ 76.5 ], "region_ids": [ - "p2-body-3" + "p2-body-4" ], "region_kinds": [ "body" @@ -863,7 +870,7 @@ "warnings": [] } ], - "confidence": 0.92, + "confidence": 0.9, "evidence": [ { "kind": "geometric_semantic_detector", @@ -872,9 +879,16 @@ "page": 2, "data": { "region_ids": [ - "p2-body-3" + "p2-body-4" ] } + }, + { + "kind": "pdf_outline_unique_page_heading", + "confidence": 0.9, + "detail": "Selected Evidence", + "page": 2, + "data": {} } ], "sources": [ @@ -905,7 +919,7 @@ 88 ], "region_ids": [ - "p2-body-3" + "p2-body-4" ], "mcids": [], "object_refs": [], @@ -2817,7 +2831,7 @@ 249.75 ], "region_ids": [ - "p2-body-3" + "p2-body-4" ], "region_kinds": [ "body" @@ -2884,7 +2898,7 @@ "page": 2, "data": { "region_ids": [ - "p2-body-3" + "p2-body-4" ] } } @@ -2912,7 +2926,7 @@ 304 ], "region_ids": [ - "p2-body-3" + "p2-body-4" ], "mcids": [], "object_refs": [], @@ -2942,7 +2956,7 @@ 284.75 ], "region_ids": [ - "p2-body-3" + "p2-body-4" ], "region_kinds": [ "body" @@ -3106,7 +3120,7 @@ "page": 2, "data": { "region_ids": [ - "p2-body-3" + "p2-body-4" ] } } @@ -3159,7 +3173,7 @@ 346 ], "region_ids": [ - "p2-body-3" + "p2-body-4" ], "mcids": [], "object_refs": [], @@ -3177,37 +3191,68 @@ "warnings": [] }, { - "id": "node-49", - "kind": "paragraph", + "id": "node-45", + "kind": "figure", "text": "", "attrs": { + "image": "ImageItem(x0=44.0, y0=312.0, x1=568.0, y1=372.0, page=2, seq=347, name='vector-a60ea913b6a0a8ba.svg', data=b'\\nVector artwork preserved from PDF graphics and text\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\nParse\\nLayout\\nRender\\nDecode\\nReconcile\\n', alt='Parse \u00b7 Layout \u00b7 Render \u00b7 Decode \u00b7 Reconcile', intrinsic_width=524, intrinsic_height=60, placed_width=524.0, placed_height=60.0, quad=((44.0, 312.0), (568.0, 312.0), (568.0, 372.0), (44.0, 372.0)), link=None, kind='vector', mcids=(), tags=(), object_ref=None, link_object_ref=None)", + "caption": "", "page": 2, - "bbox": [ - 53.0, - 322.2, - 517.0, - 331.0 - ], - "region_ids": [ - "p2-body-3" - ], - "region_kinds": [ - "body" - ], - "writing_mode": "horizontal", + "bbox": null, + "region_ids": [], + "region_kinds": [], "bottom_zone": false }, "children": [ { "id": "node-44", - "kind": "text", - "text": "Parse Layout Render", + "kind": "image", + "text": "", "attrs": { - "hard_break": false + "src": "vector-a60ea913b6a0a8ba.svg", + "alt": "Parse \u00b7 Layout \u00b7 Render \u00b7 Decode \u00b7 Reconcile", + "kind": "vector", + "intrinsic_width": 524, + "intrinsic_height": 60, + "display_width_pt": 524.0, + "display_height_pt": 60.0, + "alignment": "center", + "quad": [ + [ + 44.0, + 312.0 + ], + [ + 568.0, + 312.0 + ], + [ + 568.0, + 372.0 + ], + [ + 44.0, + 372.0 + ] + ], + "link": null, + "text_extraction_attempted": false, + "marked_content_tags": [] }, "children": [], - "confidence": 0.99, - "evidence": [], + "confidence": 0.96, + "evidence": [ + { + "kind": "pdf_vector_artwork", + "confidence": 0.96, + "detail": "", + "page": 2, + "data": { + "asset": "vector-a60ea913b6a0a8ba.svg", + "kind": "vector" + } + } + ], "sources": [ { "page": 2, @@ -3217,12 +3262,27 @@ 353, 354, 355, + 360, + 361, + 362, + 363, + 364, + 365, 370, 371, 372, 373, 374, 375, + 380, + 381, + 382, + 383, + 384, + 385, + 386, + 387, + 388, 393, 394, 395, @@ -3232,12 +3292,14 @@ ], "region_ids": [], "mcids": [], - "object_refs": [], + "object_refs": [ + "vector-a60ea913b6a0a8ba.svg" + ], "bbox": [ - 53.0, - 322.2, - 517.0, - 331.0 + 44.0, + 312.0, + 568.0, + 372.0 ] } ], @@ -3247,17 +3309,15 @@ "warnings": [] } ], - "confidence": 0.96, + "confidence": 0.95, "evidence": [ { "kind": "geometric_semantic_detector", - "confidence": 0.96, - "detail": "paragraph", + "confidence": 0.95, + "detail": "figure", "page": 2, "data": { - "region_ids": [ - "p2-body-3" - ] + "region_ids": [] } } ], @@ -3270,130 +3330,18 @@ 353, 354, 355, - 370, - 371, - 372, - 373, - 374, - 375, - 393, - 394, - 395, - 396, - 397, - 398 - ], - "region_ids": [ - "p2-body-3" - ], - "mcids": [], - "object_refs": [], - "bbox": [ - 53.0, - 322.2, - 517.0, - 331.0 - ] - } - ], - "source_pages": [ - 2 - ], - "warnings": [] - }, - { - "id": "node-53", - "kind": "paragraph", - "text": "", - "attrs": { - "page": 2, - "bbox": [ - 163.0, - 356.2, - 415.88800000000003, - 365.0 - ], - "region_ids": [ - "p2-body-3" - ], - "region_kinds": [ - "body" - ], - "writing_mode": "horizontal", - "bottom_zone": false - }, - "children": [ - { - "id": "node-50", - "kind": "text", - "text": "Decode Reconcile", - "attrs": { - "hard_break": false - }, - "children": [], - "confidence": 0.99, - "evidence": [], - "sources": [ - { - "page": 2, - "glyph_ids": [ - 360, - 361, - 362, - 363, - 364, - 365, - 380, - 381, - 382, - 383, - 384, - 385, - 386, - 387, - 388 - ], - "region_ids": [], - "mcids": [], - "object_refs": [], - "bbox": [ - 163.0, - 356.2, - 415.888, - 365.0 - ] - } - ], - "source_pages": [ - 2 - ], - "warnings": [] - } - ], - "confidence": 0.96, - "evidence": [ - { - "kind": "geometric_semantic_detector", - "confidence": 0.96, - "detail": "paragraph", - "page": 2, - "data": { - "region_ids": [ - "p2-body-3" - ] - } - } - ], - "sources": [ - { - "page": 2, - "glyph_ids": [ 360, 361, 362, 363, 364, 365, + 370, + 371, + 372, + 373, + 374, + 375, 380, 381, 382, @@ -3402,18 +3350,24 @@ 385, 386, 387, - 388 - ], - "region_ids": [ - "p2-body-3" + 388, + 393, + 394, + 395, + 396, + 397, + 398 ], + "region_ids": [], "mcids": [], - "object_refs": [], + "object_refs": [ + "vector-a60ea913b6a0a8ba.svg" + ], "bbox": [ - 163.0, - 356.2, - 415.888, - 365.0 + 44.0, + 312.0, + 568.0, + 372.0 ] } ], @@ -3423,7 +3377,7 @@ "warnings": [] }, { - "id": "node-62", + "id": "node-54", "kind": "form", "text": "", "attrs": { @@ -3433,7 +3387,7 @@ }, "children": [ { - "id": "node-60", + "id": "node-52", "kind": "form_field", "text": "Alpha", "attrs": { @@ -3471,9 +3425,11 @@ "appearance_state": null, "export_states": [], "annotation_flags": 0, - "highlight_mode": null + "highlight_mode": null, + "appearance": null } ], + "appearance": null, "actions_ignored": false }, "children": [], @@ -3512,7 +3468,7 @@ "warnings": [] }, { - "id": "node-61", + "id": "node-53", "kind": "form_field", "text": "Beta", "attrs": { @@ -3550,9 +3506,111 @@ "appearance_state": null, "export_states": [], "annotation_flags": 0, - "highlight_mode": null + "highlight_mode": null, + "appearance": { + "declared_default_appearance": { + "font_resource": "F1", + "font_size_pt": 18.0, + "text_color_rgb": [ + 0.18, + 0.25, + 0.42 + ] + }, + "declared_appearance_characteristics": { + "background_color_rgb": [ + 0.91, + 0.93, + 0.98 + ], + "border_color_rgb": [ + 0.65, + 0.7, + 0.78 + ] + }, + "background_color_rgb": [ + 0.91, + 0.93, + 0.98 + ], + "border_color_rgb": [ + 0.65, + 0.7, + 0.78 + ], + "border_width_pt": 1.0, + "text_color_rgb": [ + 0.18, + 0.25, + 0.42 + ], + "font_resource": "F1", + "font_size_pt": 18.0, + "text_alignment": "left", + "width_pt": 160.0, + "height_pt": 25.0, + "sources": [ + "appearance_characteristics", + "default_appearance", + "normal_appearance_stream", + "quadding", + "widget_rect" + ] + } } ], + "appearance": { + "declared_default_appearance": { + "font_resource": "F1", + "font_size_pt": 18.0, + "text_color_rgb": [ + 0.18, + 0.25, + 0.42 + ] + }, + "declared_appearance_characteristics": { + "background_color_rgb": [ + 0.91, + 0.93, + 0.98 + ], + "border_color_rgb": [ + 0.65, + 0.7, + 0.78 + ] + }, + "background_color_rgb": [ + 0.91, + 0.93, + 0.98 + ], + "border_color_rgb": [ + 0.65, + 0.7, + 0.78 + ], + "border_width_pt": 1.0, + "text_color_rgb": [ + 0.18, + 0.25, + 0.42 + ], + "font_resource": "F1", + "font_size_pt": 18.0, + "text_alignment": "left", + "width_pt": 160.0, + "height_pt": 25.0, + "sources": [ + "appearance_characteristics", + "default_appearance", + "normal_appearance_stream", + "quadding", + "widget_rect" + ] + }, "actions_ignored": false }, "children": [], diff --git a/examples/cases/scope_and_adversarial/full/output.md b/examples/cases/scope_and_adversarial/full/output.md index ad1b258..b5665d5 100644 --- a/examples/cases/scope_and_adversarial/full/output.md +++ b/examples/cases/scope_and_adversarial/full/output.md @@ -1,5 +1,5 @@ -- [First Page](#None) — 1 -- [Selected Evidence](#None) — 2 +- [First Page](#page-scope-review) — 1 +- [Selected Evidence](#selected-evidence-page) — 2 ## Page Scope Review @@ -24,9 +24,9 @@ SecondField: Beta **Diagram boxes below are not form controls.** -Parse Layout Render - -Decode Reconcile +
+Parse · Layout · Render · Decode · Reconcile +
**FirstField:** Alpha diff --git a/examples/cases/scope_and_adversarial/full/output.report.summary.json b/examples/cases/scope_and_adversarial/full/output.report.summary.json index 2d3ce2b..a961e15 100644 --- a/examples/cases/scope_and_adversarial/full/output.report.summary.json +++ b/examples/cases/scope_and_adversarial/full/output.report.summary.json @@ -6,14 +6,55 @@ } ], "anchors": [], - "assets": [], + "assets": [ + "vector-a60ea913b6a0a8ba.svg" + ], "chars": 378, "description": "Deterministic PDF-to-Markdown/HTML conversion for structured text-layer PDFs. No OCR. No AI.", "encrypted": false, "fills": 0, "image_text_extraction_attempted": false, - "images": 0, - "images_detail": [], + "images": 1, + "images_detail": [ + { + "alignment": "center", + "alt": "Parse \u00b7 Layout \u00b7 Render \u00b7 Decode \u00b7 Reconcile", + "asset": "vector-a60ea913b6a0a8ba.svg", + "bbox": { + "x0": 44.0, + "x1": 568.0, + "y0": 312.0, + "y1": 372.0 + }, + "display_height": 60.0, + "display_width": 524.0, + "intrinsic_height": 60, + "intrinsic_width": 524, + "kind": "vector", + "link": null, + "page": 2, + "placed_height": 60.0, + "placed_width": 524.0, + "quad": [ + { + "x": 44.0, + "y": 312.0 + }, + { + "x": 568.0, + "y": 312.0 + }, + { + "x": 568.0, + "y": 372.0 + }, + { + "x": 44.0, + "y": 372.0 + } + ] + } + ], "mode_per_page": [ "geometric", "geometric" @@ -34,9 +75,11 @@ "segments": 20, "semantic_errors": [], "semantic_kinds": [ + "figure", "form", "form_field", "heading", + "image", "paragraph", "strong", "table", @@ -46,7 +89,7 @@ "toc", "toc_item" ], - "semantic_node_count": 53, + "semantic_node_count": 51, "semantic_output_used": true, "semantic_schema": "cocoapdf.semantic-document/v2", "semantic_valid": true, @@ -67,6 +110,11 @@ "code": "FORMS_PRESENT_UNPROCESSED", "detail": "Catalog/AcroForm", "page": null + }, + { + "code": "VECTOR_FIGURE_APPROXIMATE", + "detail": "supported PDF fills, line segments, filled paths, and text replayed as one SVG; original authoring vector unavailable", + "page": 2 } ] } diff --git a/examples/cases/scope_and_adversarial/input.pdf b/examples/cases/scope_and_adversarial/input.pdf index 70cc0b9..8693d52 100644 Binary files a/examples/cases/scope_and_adversarial/input.pdf and b/examples/cases/scope_and_adversarial/input.pdf differ diff --git a/examples/cases/scope_and_adversarial/page-2/assertions.json b/examples/cases/scope_and_adversarial/page-2/assertions.json index 6346586..6e5a3d9 100644 --- a/examples/cases/scope_and_adversarial/page-2/assertions.json +++ b/examples/cases/scope_and_adversarial/page-2/assertions.json @@ -57,6 +57,86 @@ "passed": true, "detail": "dot leaders are removed from table output" }, + { + "passed": true, + "detail": "five outlined diagram panels are retained as one vector figure" + }, + { + "passed": true, + "detail": "all native diagram labels remain inside the vector figure" + }, + { + "passed": true, + "detail": "the SVG retains the outlined-panel stroke geometry" + }, + { + "passed": true, + "detail": "diagram labels are not flattened into false prose lines" + }, + { + "passed": true, + "detail": "Markdown and HTML both reference the generated diagram asset" + }, + { + "passed": true, + "detail": "semantic JSON records one vector image node" + }, + { + "passed": true, + "detail": "the vector figure retains page-2 provenance" + }, + { + "passed": true, + "detail": "the vector figure remains traceable to its source glyphs" + }, + { + "passed": true, + "detail": "diagram labels provide deterministic alternative text" + }, + { + "passed": true, + "detail": "the vector figure records PDF-native artwork evidence" + }, + { + "passed": true, + "detail": "report geometry shows one page-spanning, multi-row diagram" + }, + { + "passed": true, + "detail": "diagram preservation does not invoke image-text extraction" + }, + { + "passed": true, + "detail": "SecondField has one page-scoped semantic field" + }, + { + "passed": true, + "detail": "SecondField retains explicit PDF widget appearance evidence" + }, + { + "passed": true, + "detail": "SecondField preserves its explicitly large text size" + }, + { + "passed": true, + "detail": "SecondField preserves its dark blue-gray text color" + }, + { + "passed": true, + "detail": "SecondField preserves its pale blue-gray background" + }, + { + "passed": true, + "detail": "SecondField appearance remains traceable to independent PDF-native evidence" + }, + { + "passed": true, + "detail": "HTML renders only the evidenced field with its size and colors" + }, + { + "passed": true, + "detail": "documentary form output never creates an active browser control" + }, { "passed": true, "detail": "semantic metadata records page 2 only" diff --git a/examples/cases/scope_and_adversarial/page-2/assets/vector-a60ea913b6a0a8ba.svg b/examples/cases/scope_and_adversarial/page-2/assets/vector-a60ea913b6a0a8ba.svg new file mode 100644 index 0000000..6412f5a --- /dev/null +++ b/examples/cases/scope_and_adversarial/page-2/assets/vector-a60ea913b6a0a8ba.svg @@ -0,0 +1,28 @@ + +Vector artwork preserved from PDF graphics and text + + + + + + + + + + + + + + + + + + + + +Parse +Layout +Render +Decode +Reconcile + \ No newline at end of file diff --git a/examples/cases/scope_and_adversarial/page-2/output.html b/examples/cases/scope_and_adversarial/page-2/output.html index abc312b..d7ca284 100644 --- a/examples/cases/scope_and_adversarial/page-2/output.html +++ b/examples/cases/scope_and_adversarial/page-2/output.html @@ -25,6 +25,9 @@ .cocoapdf-table-note { font-size: 0.9em; font-style: italic; } .cocoapdf-cross-reference { text-decoration: none; } .cocoapdf-form { display: grid; gap: 0.65rem; margin: 1rem 0; } +.cocoapdf-form-field { display: grid; grid-template-columns: minmax(8rem, max-content) 1fr; column-gap: 0.5rem; } +.cocoapdf-form-field-name { font-weight: 600; } +.cocoapdf-form-field-value-evidenced { align-items: center; box-sizing: border-box; display: inline-flex; padding: 0 0.25rem; } .cocoapdf-equation { overflow-x: auto; margin: 1rem 0; } @@ -41,8 +44,9 @@

Selected Evidence Page

SecondField: Beta

Diagram boxes below are not form controls.

-

Parse Layout Render

-

Decode Reconcile

-
SecondFieldBeta
+
+Parse · Layout · Render · Decode · Reconcile +
+
SecondField: Beta
diff --git a/examples/cases/scope_and_adversarial/page-2/output.json b/examples/cases/scope_and_adversarial/page-2/output.json index 864911f..b939de7 100644 --- a/examples/cases/scope_and_adversarial/page-2/output.json +++ b/examples/cases/scope_and_adversarial/page-2/output.json @@ -13,7 +13,7 @@ 2 ], "outline": { - "id": "node-49", + "id": "node-41", "kind": "outline", "text": "", "attrs": { @@ -21,7 +21,7 @@ }, "children": [ { - "id": "node-47", + "id": "node-39", "kind": "outline_item", "text": "First Page", "attrs": { @@ -67,7 +67,7 @@ "warnings": [] }, { - "id": "node-48", + "id": "node-40", "kind": "outline_item", "text": "Selected Evidence", "attrs": { @@ -79,8 +79,8 @@ null, null ], - "target_id": null, - "target_anchor": null, + "target_id": "node-3", + "target_anchor": "selected-evidence-page", "open": true }, "children": [], @@ -167,7 +167,7 @@ 76.5 ], "region_ids": [ - "p2-body-2" + "p2-body-3" ], "region_kinds": [ "body" @@ -283,7 +283,7 @@ "warnings": [] } ], - "confidence": 0.92, + "confidence": 0.9, "evidence": [ { "kind": "geometric_semantic_detector", @@ -292,9 +292,16 @@ "page": 2, "data": { "region_ids": [ - "p2-body-2" + "p2-body-3" ] } + }, + { + "kind": "pdf_outline_unique_page_heading", + "confidence": 0.9, + "detail": "Selected Evidence", + "page": 2, + "data": {} } ], "sources": [ @@ -325,7 +332,7 @@ 22 ], "region_ids": [ - "p2-body-2" + "p2-body-3" ], "mcids": [], "object_refs": [], @@ -2237,7 +2244,7 @@ 249.75 ], "region_ids": [ - "p2-body-2" + "p2-body-3" ], "region_kinds": [ "body" @@ -2304,7 +2311,7 @@ "page": 2, "data": { "region_ids": [ - "p2-body-2" + "p2-body-3" ] } } @@ -2332,7 +2339,7 @@ 238 ], "region_ids": [ - "p2-body-2" + "p2-body-3" ], "mcids": [], "object_refs": [], @@ -2362,7 +2369,7 @@ 284.75 ], "region_ids": [ - "p2-body-2" + "p2-body-3" ], "region_kinds": [ "body" @@ -2526,7 +2533,7 @@ "page": 2, "data": { "region_ids": [ - "p2-body-2" + "p2-body-3" ] } } @@ -2579,7 +2586,7 @@ 280 ], "region_ids": [ - "p2-body-2" + "p2-body-3" ], "mcids": [], "object_refs": [], @@ -2597,37 +2604,68 @@ "warnings": [] }, { - "id": "node-42", - "kind": "paragraph", + "id": "node-38", + "kind": "figure", "text": "", "attrs": { + "image": "ImageItem(x0=44.0, y0=312.0, x1=568.0, y1=372.0, page=2, seq=281, name='vector-a60ea913b6a0a8ba.svg', data=b'\\nVector artwork preserved from PDF graphics and text\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\nParse\\nLayout\\nRender\\nDecode\\nReconcile\\n', alt='Parse \u00b7 Layout \u00b7 Render \u00b7 Decode \u00b7 Reconcile', intrinsic_width=524, intrinsic_height=60, placed_width=524.0, placed_height=60.0, quad=((44.0, 312.0), (568.0, 312.0), (568.0, 372.0), (44.0, 372.0)), link=None, kind='vector', mcids=(), tags=(), object_ref=None, link_object_ref=None)", + "caption": "", "page": 2, - "bbox": [ - 53.0, - 322.2, - 517.0, - 331.0 - ], - "region_ids": [ - "p2-body-2" - ], - "region_kinds": [ - "body" - ], - "writing_mode": "horizontal", + "bbox": null, + "region_ids": [], + "region_kinds": [], "bottom_zone": false }, "children": [ { "id": "node-37", - "kind": "text", - "text": "Parse Layout Render", + "kind": "image", + "text": "", "attrs": { - "hard_break": false + "src": "vector-a60ea913b6a0a8ba.svg", + "alt": "Parse \u00b7 Layout \u00b7 Render \u00b7 Decode \u00b7 Reconcile", + "kind": "vector", + "intrinsic_width": 524, + "intrinsic_height": 60, + "display_width_pt": 524.0, + "display_height_pt": 60.0, + "alignment": "center", + "quad": [ + [ + 44.0, + 312.0 + ], + [ + 568.0, + 312.0 + ], + [ + 568.0, + 372.0 + ], + [ + 44.0, + 372.0 + ] + ], + "link": null, + "text_extraction_attempted": false, + "marked_content_tags": [] }, "children": [], - "confidence": 0.99, - "evidence": [], + "confidence": 0.96, + "evidence": [ + { + "kind": "pdf_vector_artwork", + "confidence": 0.96, + "detail": "", + "page": 2, + "data": { + "asset": "vector-a60ea913b6a0a8ba.svg", + "kind": "vector" + } + } + ], "sources": [ { "page": 2, @@ -2637,12 +2675,27 @@ 287, 288, 289, + 294, + 295, + 296, + 297, + 298, + 299, 304, 305, 306, 307, 308, 309, + 314, + 315, + 316, + 317, + 318, + 319, + 320, + 321, + 322, 327, 328, 329, @@ -2652,12 +2705,14 @@ ], "region_ids": [], "mcids": [], - "object_refs": [], + "object_refs": [ + "vector-a60ea913b6a0a8ba.svg" + ], "bbox": [ - 53.0, - 322.2, - 517.0, - 331.0 + 44.0, + 312.0, + 568.0, + 372.0 ] } ], @@ -2667,17 +2722,15 @@ "warnings": [] } ], - "confidence": 0.96, + "confidence": 0.95, "evidence": [ { "kind": "geometric_semantic_detector", - "confidence": 0.96, - "detail": "paragraph", + "confidence": 0.95, + "detail": "figure", "page": 2, "data": { - "region_ids": [ - "p2-body-2" - ] + "region_ids": [] } } ], @@ -2690,130 +2743,18 @@ 287, 288, 289, - 304, - 305, - 306, - 307, - 308, - 309, - 327, - 328, - 329, - 330, - 331, - 332 - ], - "region_ids": [ - "p2-body-2" - ], - "mcids": [], - "object_refs": [], - "bbox": [ - 53.0, - 322.2, - 517.0, - 331.0 - ] - } - ], - "source_pages": [ - 2 - ], - "warnings": [] - }, - { - "id": "node-46", - "kind": "paragraph", - "text": "", - "attrs": { - "page": 2, - "bbox": [ - 163.0, - 356.2, - 415.88800000000003, - 365.0 - ], - "region_ids": [ - "p2-body-2" - ], - "region_kinds": [ - "body" - ], - "writing_mode": "horizontal", - "bottom_zone": false - }, - "children": [ - { - "id": "node-43", - "kind": "text", - "text": "Decode Reconcile", - "attrs": { - "hard_break": false - }, - "children": [], - "confidence": 0.99, - "evidence": [], - "sources": [ - { - "page": 2, - "glyph_ids": [ - 294, - 295, - 296, - 297, - 298, - 299, - 314, - 315, - 316, - 317, - 318, - 319, - 320, - 321, - 322 - ], - "region_ids": [], - "mcids": [], - "object_refs": [], - "bbox": [ - 163.0, - 356.2, - 415.888, - 365.0 - ] - } - ], - "source_pages": [ - 2 - ], - "warnings": [] - } - ], - "confidence": 0.96, - "evidence": [ - { - "kind": "geometric_semantic_detector", - "confidence": 0.96, - "detail": "paragraph", - "page": 2, - "data": { - "region_ids": [ - "p2-body-2" - ] - } - } - ], - "sources": [ - { - "page": 2, - "glyph_ids": [ 294, 295, 296, 297, 298, 299, + 304, + 305, + 306, + 307, + 308, + 309, 314, 315, 316, @@ -2822,18 +2763,24 @@ 319, 320, 321, - 322 - ], - "region_ids": [ - "p2-body-2" + 322, + 327, + 328, + 329, + 330, + 331, + 332 ], + "region_ids": [], "mcids": [], - "object_refs": [], + "object_refs": [ + "vector-a60ea913b6a0a8ba.svg" + ], "bbox": [ - 163.0, - 356.2, - 415.888, - 365.0 + 44.0, + 312.0, + 568.0, + 372.0 ] } ], @@ -2843,7 +2790,7 @@ "warnings": [] }, { - "id": "node-52", + "id": "node-44", "kind": "form", "text": "", "attrs": { @@ -2853,7 +2800,7 @@ }, "children": [ { - "id": "node-51", + "id": "node-43", "kind": "form_field", "text": "Beta", "attrs": { @@ -2891,9 +2838,111 @@ "appearance_state": null, "export_states": [], "annotation_flags": 0, - "highlight_mode": null + "highlight_mode": null, + "appearance": { + "declared_default_appearance": { + "font_resource": "F1", + "font_size_pt": 18.0, + "text_color_rgb": [ + 0.18, + 0.25, + 0.42 + ] + }, + "declared_appearance_characteristics": { + "background_color_rgb": [ + 0.91, + 0.93, + 0.98 + ], + "border_color_rgb": [ + 0.65, + 0.7, + 0.78 + ] + }, + "background_color_rgb": [ + 0.91, + 0.93, + 0.98 + ], + "border_color_rgb": [ + 0.65, + 0.7, + 0.78 + ], + "border_width_pt": 1.0, + "text_color_rgb": [ + 0.18, + 0.25, + 0.42 + ], + "font_resource": "F1", + "font_size_pt": 18.0, + "text_alignment": "left", + "width_pt": 160.0, + "height_pt": 25.0, + "sources": [ + "appearance_characteristics", + "default_appearance", + "normal_appearance_stream", + "quadding", + "widget_rect" + ] + } } ], + "appearance": { + "declared_default_appearance": { + "font_resource": "F1", + "font_size_pt": 18.0, + "text_color_rgb": [ + 0.18, + 0.25, + 0.42 + ] + }, + "declared_appearance_characteristics": { + "background_color_rgb": [ + 0.91, + 0.93, + 0.98 + ], + "border_color_rgb": [ + 0.65, + 0.7, + 0.78 + ] + }, + "background_color_rgb": [ + 0.91, + 0.93, + 0.98 + ], + "border_color_rgb": [ + 0.65, + 0.7, + 0.78 + ], + "border_width_pt": 1.0, + "text_color_rgb": [ + 0.18, + 0.25, + 0.42 + ], + "font_resource": "F1", + "font_size_pt": 18.0, + "text_alignment": "left", + "width_pt": 160.0, + "height_pt": 25.0, + "sources": [ + "appearance_characteristics", + "default_appearance", + "normal_appearance_stream", + "quadding", + "widget_rect" + ] + }, "actions_ignored": false }, "children": [], diff --git a/examples/cases/scope_and_adversarial/page-2/output.md b/examples/cases/scope_and_adversarial/page-2/output.md index 30bfb57..dc9732d 100644 --- a/examples/cases/scope_and_adversarial/page-2/output.md +++ b/examples/cases/scope_and_adversarial/page-2/output.md @@ -15,8 +15,8 @@ SecondField: Beta **Diagram boxes below are not form controls.** -Parse Layout Render - -Decode Reconcile +
+Parse · Layout · Render · Decode · Reconcile +
**SecondField:** Beta diff --git a/examples/cases/scope_and_adversarial/page-2/output.report.summary.json b/examples/cases/scope_and_adversarial/page-2/output.report.summary.json index cc04aa3..d702ba5 100644 --- a/examples/cases/scope_and_adversarial/page-2/output.report.summary.json +++ b/examples/cases/scope_and_adversarial/page-2/output.report.summary.json @@ -6,14 +6,55 @@ } ], "anchors": [], - "assets": [], + "assets": [ + "vector-a60ea913b6a0a8ba.svg" + ], "chars": 312, "description": "Deterministic PDF-to-Markdown/HTML conversion for structured text-layer PDFs. No OCR. No AI.", "encrypted": false, "fills": 0, "image_text_extraction_attempted": false, - "images": 0, - "images_detail": [], + "images": 1, + "images_detail": [ + { + "alignment": "center", + "alt": "Parse \u00b7 Layout \u00b7 Render \u00b7 Decode \u00b7 Reconcile", + "asset": "vector-a60ea913b6a0a8ba.svg", + "bbox": { + "x0": 44.0, + "x1": 568.0, + "y0": 312.0, + "y1": 372.0 + }, + "display_height": 60.0, + "display_width": 524.0, + "intrinsic_height": 60, + "intrinsic_width": 524, + "kind": "vector", + "link": null, + "page": 2, + "placed_height": 60.0, + "placed_width": 524.0, + "quad": [ + { + "x": 44.0, + "y": 312.0 + }, + { + "x": 568.0, + "y": 312.0 + }, + { + "x": 568.0, + "y": 372.0 + }, + { + "x": 44.0, + "y": 372.0 + } + ] + } + ], "mode_per_page": [ "not_selected", "geometric" @@ -33,9 +74,11 @@ "segments": 20, "semantic_errors": [], "semantic_kinds": [ + "figure", "form", "form_field", "heading", + "image", "paragraph", "strong", "table", @@ -43,7 +86,7 @@ "table_row", "text" ], - "semantic_node_count": 42, + "semantic_node_count": 40, "semantic_output_used": true, "semantic_schema": "cocoapdf.semantic-document/v2", "semantic_valid": true, @@ -64,6 +107,11 @@ "code": "FORMS_PRESENT_UNPROCESSED", "detail": "Catalog/AcroForm", "page": null + }, + { + "code": "VECTOR_FIGURE_APPROXIMATE", + "detail": "supported PDF fills, line segments, filled paths, and text replayed as one SVG; original authoring vector unavailable", + "page": 2 } ] } diff --git a/examples/cases/strategic_corner_cases/full/output.html b/examples/cases/strategic_corner_cases/full/output.html index 1d56216..461fd16 100644 --- a/examples/cases/strategic_corner_cases/full/output.html +++ b/examples/cases/strategic_corner_cases/full/output.html @@ -25,6 +25,9 @@ .cocoapdf-table-note { font-size: 0.9em; font-style: italic; } .cocoapdf-cross-reference { text-decoration: none; } .cocoapdf-form { display: grid; gap: 0.65rem; margin: 1rem 0; } +.cocoapdf-form-field { display: grid; grid-template-columns: minmax(8rem, max-content) 1fr; column-gap: 0.5rem; } +.cocoapdf-form-field-name { font-weight: 600; } +.cocoapdf-form-field-value-evidenced { align-items: center; box-sizing: border-box; display: inline-flex; padding: 0 0.25rem; } .cocoapdf-equation { overflow-x: auto; margin: 1rem 0; } @@ -293,13 +296,13 @@

13. Figures, Images, Captions

SENTINEL-FIGURE-002 inline icon-like image should not destroy surrounding text:

- +SVG FIGURE · vector sentinel: VEC-001 αβγ → Markdow

before-icon

after-icon.

SENTINEL-FIGURE-003 image wrapped in link:

- +SVG FIGURE · vector sentinel: VEC-001 αβγ → Markdow

14. OCR / Raster Hybrid Future Fixture

diff --git a/examples/cases/strategic_corner_cases/full/output.json b/examples/cases/strategic_corner_cases/full/output.json index 660a299..eb38b6e 100644 --- a/examples/cases/strategic_corner_cases/full/output.json +++ b/examples/cases/strategic_corner_cases/full/output.json @@ -56557,7 +56557,56 @@ "sources": [ { "page": 5, - "glyph_ids": [], + "glyph_ids": [ + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099 + ], "region_ids": [], "mcids": [], "object_refs": [ @@ -56966,6 +57015,54 @@ { "page": 5, "glyph_ids": [ + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, 10100, 10101, 10102, @@ -57155,7 +57252,56 @@ "sources": [ { "page": 5, - "glyph_ids": [], + "glyph_ids": [ + 9753, + 9754, + 9755, + 9756, + 9757, + 9758, + 9759, + 9760, + 9761, + 9762, + 9763, + 9764, + 9765, + 9766, + 9767, + 9768, + 9769, + 9770, + 9771, + 9772, + 9773, + 9774, + 9775, + 9776, + 9777, + 9778, + 9779, + 9780, + 9781, + 9782, + 9783, + 9784, + 9785, + 9786, + 9787, + 9788, + 9789, + 9790, + 9791, + 9792, + 9793, + 9794, + 9795, + 9796, + 9797, + 9798, + 9799, + 9800 + ], "region_ids": [], "mcids": [], "object_refs": [ @@ -57394,6 +57540,54 @@ { "page": 5, "glyph_ids": [ + 9753, + 9754, + 9755, + 9756, + 9757, + 9758, + 9759, + 9760, + 9761, + 9762, + 9763, + 9764, + 9765, + 9766, + 9767, + 9768, + 9769, + 9770, + 9771, + 9772, + 9773, + 9774, + 9775, + 9776, + 9777, + 9778, + 9779, + 9780, + 9781, + 9782, + 9783, + 9784, + 9785, + 9786, + 9787, + 9788, + 9789, + 9790, + 9791, + 9792, + 9793, + 9794, + 9795, + 9796, + 9797, + 9798, + 9799, + 9800, 9801, 9802, 9803, @@ -57734,7 +57928,7 @@ "kind": "figure", "text": "", "attrs": { - "image": "ImageItem(x0=151.0000137083325, y0=730.74980955209, x1=691.0000392083305, y1=910.74980205209, page=5, seq=10281, name='vector-36d70b475f1d2d1e.svg', data=b'\\nVector artwork preserved from PDF graphics and text\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\nSVG FIGURE\\nvector sentinel: VEC-001 \\xce\\xb1\\xce\\xb2\\xce\\xb3 \\xe2\\x86\\x92 Markdow\\n', alt='', intrinsic_width=540, intrinsic_height=180, placed_width=540.000025499998, placed_height=179.99999249999996, quad=((151.0000137083325, 730.74980955209), (691.0000392083305, 730.74980955209), (691.0000392083305, 910.74980205209), (151.0000137083325, 910.74980205209)), link=None, kind='vector', mcids=(), tags=(), object_ref=None, link_object_ref=None)", + "image": "ImageItem(x0=151.0000137083325, y0=730.74980955209, x1=691.0000392083305, y1=910.74980205209, page=5, seq=10281, name='vector-36d70b475f1d2d1e.svg', data=b'\\nVector artwork preserved from PDF graphics and text\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\nSVG FIGURE\\nvector sentinel: VEC-001 \\xce\\xb1\\xce\\xb2\\xce\\xb3 \\xe2\\x86\\x92 Markdow\\n', alt='SVG FIGURE \u00b7 vector sentinel: VEC-001 \u03b1\u03b2\u03b3 \u2192 Markdow', intrinsic_width=540, intrinsic_height=180, placed_width=540.000025499998, placed_height=179.99999249999996, quad=((151.0000137083325, 730.74980955209), (691.0000392083305, 730.74980955209), (691.0000392083305, 910.74980205209), (151.0000137083325, 910.74980205209)), link=None, kind='vector', mcids=(), tags=(), object_ref=None, link_object_ref=None)", "caption": "", "page": 5, "bbox": null, @@ -57749,7 +57943,7 @@ "text": "", "attrs": { "src": "vector-36d70b475f1d2d1e.svg", - "alt": "", + "alt": "SVG FIGURE \u00b7 vector sentinel: VEC-001 \u03b1\u03b2\u03b3 \u2192 Markdow", "kind": "vector", "intrinsic_width": 540, "intrinsic_height": 180, @@ -57795,7 +57989,56 @@ "sources": [ { "page": 5, - "glyph_ids": [], + "glyph_ids": [ + 10290, + 10291, + 10292, + 10293, + 10294, + 10295, + 10296, + 10297, + 10298, + 10299, + 10300, + 10301, + 10302, + 10303, + 10304, + 10305, + 10306, + 10307, + 10308, + 10309, + 10310, + 10311, + 10312, + 10313, + 10314, + 10315, + 10316, + 10317, + 10318, + 10319, + 10320, + 10321, + 10322, + 10323, + 10324, + 10325, + 10326, + 10327, + 10328, + 10329, + 10330, + 10331, + 10332, + 10333, + 10334, + 10335, + 10336, + 10337 + ], "region_ids": [], "mcids": [], "object_refs": [ @@ -57830,7 +58073,56 @@ "sources": [ { "page": 5, - "glyph_ids": [], + "glyph_ids": [ + 10290, + 10291, + 10292, + 10293, + 10294, + 10295, + 10296, + 10297, + 10298, + 10299, + 10300, + 10301, + 10302, + 10303, + 10304, + 10305, + 10306, + 10307, + 10308, + 10309, + 10310, + 10311, + 10312, + 10313, + 10314, + 10315, + 10316, + 10317, + 10318, + 10319, + 10320, + 10321, + 10322, + 10323, + 10324, + 10325, + 10326, + 10327, + 10328, + 10329, + 10330, + 10331, + 10332, + 10333, + 10334, + 10335, + 10336, + 10337 + ], "region_ids": [], "mcids": [], "object_refs": [ @@ -58258,7 +58550,7 @@ "kind": "figure", "text": "", "attrs": { - "image": "ImageItem(x0=96.2500015895831, y0=949.74980042709, x1=636.2500270895811, y1=1129.74979292709, page=5, seq=10392, name='vector-36d70b475f1d2d1e.svg', data=b'\\nVector artwork preserved from PDF graphics and text\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\nSVG FIGURE\\nvector sentinel: VEC-001 \\xce\\xb1\\xce\\xb2\\xce\\xb3 \\xe2\\x86\\x92 Markdow\\n', alt='', intrinsic_width=540, intrinsic_height=180, placed_width=540.000025499998, placed_height=179.99999250000008, quad=((96.2500015895831, 949.74980042709), (636.2500270895811, 949.74980042709), (636.2500270895811, 1129.74979292709), (96.2500015895831, 1129.74979292709)), link='https://example.com/linked-image', kind='vector', mcids=(), tags=(), object_ref=None, link_object_ref='80 0 R')", + "image": "ImageItem(x0=96.2500015895831, y0=949.74980042709, x1=636.2500270895811, y1=1129.74979292709, page=5, seq=10392, name='vector-36d70b475f1d2d1e.svg', data=b'\\nVector artwork preserved from PDF graphics and text\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\nSVG FIGURE\\nvector sentinel: VEC-001 \\xce\\xb1\\xce\\xb2\\xce\\xb3 \\xe2\\x86\\x92 Markdow\\n', alt='SVG FIGURE \u00b7 vector sentinel: VEC-001 \u03b1\u03b2\u03b3 \u2192 Markdow', intrinsic_width=540, intrinsic_height=180, placed_width=540.000025499998, placed_height=179.99999250000008, quad=((96.2500015895831, 949.74980042709), (636.2500270895811, 949.74980042709), (636.2500270895811, 1129.74979292709), (96.2500015895831, 1129.74979292709)), link='https://example.com/linked-image', kind='vector', mcids=(), tags=(), object_ref=None, link_object_ref='80 0 R')", "caption": "", "page": 5, "bbox": null, @@ -58273,7 +58565,7 @@ "text": "", "attrs": { "src": "vector-36d70b475f1d2d1e.svg", - "alt": "", + "alt": "SVG FIGURE \u00b7 vector sentinel: VEC-001 \u03b1\u03b2\u03b3 \u2192 Markdow", "kind": "vector", "intrinsic_width": 540, "intrinsic_height": 180, @@ -58319,7 +58611,56 @@ "sources": [ { "page": 5, - "glyph_ids": [], + "glyph_ids": [ + 10401, + 10402, + 10403, + 10404, + 10405, + 10406, + 10407, + 10408, + 10409, + 10410, + 10411, + 10412, + 10413, + 10414, + 10415, + 10416, + 10417, + 10418, + 10419, + 10420, + 10421, + 10422, + 10423, + 10424, + 10425, + 10426, + 10427, + 10428, + 10429, + 10430, + 10431, + 10432, + 10433, + 10434, + 10435, + 10436, + 10437, + 10438, + 10439, + 10440, + 10441, + 10442, + 10443, + 10444, + 10445, + 10446, + 10447, + 10448 + ], "region_ids": [], "mcids": [], "object_refs": [ @@ -58355,7 +58696,56 @@ "sources": [ { "page": 5, - "glyph_ids": [], + "glyph_ids": [ + 10401, + 10402, + 10403, + 10404, + 10405, + 10406, + 10407, + 10408, + 10409, + 10410, + 10411, + 10412, + 10413, + 10414, + 10415, + 10416, + 10417, + 10418, + 10419, + 10420, + 10421, + 10422, + 10423, + 10424, + 10425, + 10426, + 10427, + 10428, + 10429, + 10430, + 10431, + 10432, + 10433, + 10434, + 10435, + 10436, + 10437, + 10438, + 10439, + 10440, + 10441, + 10442, + 10443, + 10444, + 10445, + 10446, + 10447, + 10448 + ], "region_ids": [], "mcids": [], "object_refs": [ diff --git a/examples/cases/strategic_corner_cases/full/output.md b/examples/cases/strategic_corner_cases/full/output.md index 36467d8..4e4ddd8 100644 --- a/examples/cases/strategic_corner_cases/full/output.md +++ b/examples/cases/strategic_corner_cases/full/output.md @@ -358,7 +358,7 @@ SENTINEL-FIGURE-001 Markdown image with SVG data URI: SENTINEL-FIGURE-002 inline icon-like image should not destroy surrounding text:
- +SVG FIGURE · vector sentinel: VEC-001 αβγ → Markdow
before-icon @@ -368,7 +368,7 @@ after-icon. SENTINEL-FIGURE-003 image wrapped in link:
- +SVG FIGURE · vector sentinel: VEC-001 αβγ → Markdow
--- diff --git a/examples/cases/strategic_corner_cases/full/output.report.summary.json b/examples/cases/strategic_corner_cases/full/output.report.summary.json index cdab6b3..fb61d09 100644 --- a/examples/cases/strategic_corner_cases/full/output.report.summary.json +++ b/examples/cases/strategic_corner_cases/full/output.report.summary.json @@ -175,7 +175,7 @@ }, { "alignment": "left", - "alt": "", + "alt": "SVG FIGURE \u00b7 vector sentinel: VEC-001 \u03b1\u03b2\u03b3 \u2192 Markdow", "asset": "vector-36d70b475f1d2d1e.svg", "bbox": { "x0": 151.0, @@ -213,7 +213,7 @@ }, { "alignment": "left", - "alt": "", + "alt": "SVG FIGURE \u00b7 vector sentinel: VEC-001 \u03b1\u03b2\u03b3 \u2192 Markdow", "asset": "vector-36d70b475f1d2d1e.svg", "bbox": { "x0": 96.25, diff --git a/examples/cases/tagged_semantics/full/output.html b/examples/cases/tagged_semantics/full/output.html index bedf4ac..f55d152 100644 --- a/examples/cases/tagged_semantics/full/output.html +++ b/examples/cases/tagged_semantics/full/output.html @@ -25,6 +25,9 @@ .cocoapdf-table-note { font-size: 0.9em; font-style: italic; } .cocoapdf-cross-reference { text-decoration: none; } .cocoapdf-form { display: grid; gap: 0.65rem; margin: 1rem 0; } +.cocoapdf-form-field { display: grid; grid-template-columns: minmax(8rem, max-content) 1fr; column-gap: 0.5rem; } +.cocoapdf-form-field-name { font-weight: 600; } +.cocoapdf-form-field-value-evidenced { align-items: center; box-sizing: border-box; display: inline-flex; padding: 0 0.25rem; } .cocoapdf-equation { overflow-x: auto; margin: 1rem 0; } diff --git a/examples/manifest.json b/examples/manifest.json index 7f6011c..1662a88 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -25,20 +25,20 @@ "sha256": "d82c113d8e7b4c998d5750f5ff0bb0bb5b4b9bd0e3782e17ae451fa94a9d255b" }, "output.html": { - "bytes": 27279, - "sha256": "7b2f03fd9088213140b4745fa0d88795490947a9524faf99d5a6f46bd2cb1b23" + "bytes": 27683, + "sha256": "ce8417263be75d789faa8f0224566bdbe5d20bb4b6a20a7111f5a9176920adc1" }, "output.json": { - "bytes": 2080924, - "sha256": "8d3baef8dedb69e6e89b8fbb976f344108ec21f72a3d952d055e8281266fc70a" + "bytes": 2089272, + "sha256": "3ba72c06c9e0323808cf1efa80ec02e00dbda837c8a03fc95bb929853e81272d" }, "output.md": { - "bytes": 20064, - "sha256": "c4f68a52afa3539d08ec9730b7f7c1e4f15c36c1ac6f19899da3849600260955" + "bytes": 20178, + "sha256": "b8821b37921002f6621840d1c97d6968659b066cfa05bb1a73d1fa84debb2609" }, "output.report.summary.json": { - "bytes": 9143, - "sha256": "71a6da94229dfe2df9cfb0ba03c9a1322fa387eb4c678b6468ea3a933e36b9ae" + "bytes": 9295, + "sha256": "af184bf00ac82648677dca642484bd98e0f814b623177f304d8a48ebe35fe7a9" } }, "name": "full", @@ -73,8 +73,8 @@ "sha256": "d82c113d8e7b4c998d5750f5ff0bb0bb5b4b9bd0e3782e17ae451fa94a9d255b" }, "output.html": { - "bytes": 2339, - "sha256": "b85d29364a2f898d2490fa71c8b9d5a2d01b15cfebd138319d1af901e3e384f2" + "bytes": 2629, + "sha256": "dff1ae716320832e25290e9061dad0d2aafe73e6a033af63d970160281713767" }, "output.json": { "bytes": 30291, @@ -117,28 +117,32 @@ { "files": { "assertions.json": { - "bytes": 447, - "sha256": "fb13e84706128e132c38c5dd73cb42d8274383adeb0639783b20cc9b0b2c304c" + "bytes": 3057, + "sha256": "8372819d8f9bbeb5a42d14dacbccbdba1a131709999793a71ab4411ff0c56c84" + }, + "assets/vector-a60ea913b6a0a8ba.svg": { + "bytes": 3326, + "sha256": "a60ea913b6a0a8bad9fed7d74a6877169569170294465edd74215f13c5b968f7" }, "command.json": { "bytes": 309, "sha256": "d82c113d8e7b4c998d5750f5ff0bb0bb5b4b9bd0e3782e17ae451fa94a9d255b" }, "output.html": { - "bytes": 3208, - "sha256": "fc40bb68321d90b39ba80fe53f79b7362625121c9c37706e4cb477dd7349d20b" + "bytes": 4176, + "sha256": "f77a021271bb348ea844334527d809251ab1bd722f94413fc388586d9910ac30" }, "output.json": { - "bytes": 84822, - "sha256": "2eba1268ff0d82011553b0fb2acfd24cbeb961941d0578749a7e42c6c074f322" + "bytes": 91712, + "sha256": "9b73f5991d2b0dfbf42207e9cee324a99935787bcb9906dd9dc28beaf14dcb5e" }, "output.md": { - "bytes": 716, - "sha256": "8b251163e19990610425fa05d8bb493467fe3439c510ca39582a82df799e7390" + "bytes": 1015, + "sha256": "f1d230d02b1024e249e19fd10534c50d14c7a6ce14335afaebc4d8980d847ff5" }, "output.report.summary.json": { - "bytes": 1580, - "sha256": "6699ef9ad69cb1f078608b48f30c3ae4dde9893af4a3121423efbf46a7befcec" + "bytes": 2647, + "sha256": "441f3f9f20480a06785b930f6e32c599dd1f239765e9e718843523dcb471c817" } }, "name": "full", @@ -148,28 +152,32 @@ { "files": { "assertions.json": { - "bytes": 1433, - "sha256": "91140935b66d91c0b439ef7301403d81ea83eae69d1246a362d6e24eb46998c8" + "bytes": 3620, + "sha256": "8e95e32e16749f7283f8d57dd4290e4a2df2fb4ca81677027d0a15a9535b8adc" + }, + "assets/vector-a60ea913b6a0a8ba.svg": { + "bytes": 3326, + "sha256": "a60ea913b6a0a8bad9fed7d74a6877169569170294465edd74215f13c5b968f7" }, "command.json": { "bytes": 332, "sha256": "179d6e7aeced8baa4f7edc6730aa0e6136f18b93679700e9d4f9791f111996d3" }, "output.html": { - "bytes": 2526, - "sha256": "1d511d6dfa8465a99b4a77c5c7b1f8befdbc178ff63c4dfbb27eff1e34685842" + "bytes": 3461, + "sha256": "d13e1d2604778b7c4e464f13aa707a528af55c127caa39eedc4716327f9a5701" }, "output.json": { - "bytes": 70261, - "sha256": "30aee40b2d36be8c95c317661d3bc831c7f197786c4a7894bfb008be85637818" + "bytes": 76841, + "sha256": "6e37d854cc4b1ddfd13efae52882cb7d4b484ad83558d8b3e331aeea2624e545" }, "output.md": { - "bytes": 554, - "sha256": "38058450917cf47dc178349d20299d69a89f4ca99eb52583fe0e97ccd2a374b8" + "bytes": 822, + "sha256": "01143b441ec530bff0e90903f11414327140b104ca29e78be33ae24067ddd33b" }, "output.report.summary.json": { - "bytes": 1549, - "sha256": "4ad05489c37095f4282b79b20c0cc0b2c91bdf558a3dd11937f0388a0d261a7d" + "bytes": 2616, + "sha256": "5485e5279ac36dec7759bc563e8d226eb65313947391529ceda3c6b7dfc941c1" } }, "name": "page-2", @@ -177,12 +185,12 @@ "passed": true } ], - "description": "Page-range outline/AcroForm scope, dot-leader finance recovery, and diagram-versus-form false-positive resistance.", + "description": "Page-range outline/AcroForm scope, valid heading anchors, dot-leader finance recovery, and two-sided diagram-versus-form/table fidelity.", "id": "scope_and_adversarial", "input": { - "bytes": 3715, + "bytes": 4189, "deterministic": true, - "sha256": "0a697b6632d7ebe49f862c3b9e89273ca97b5a50771913332ce3c9ba53406e3e" + "sha256": "ac3c2b4cb08751c636aae3e8ca561328e09996e3e0c1470b55830039c9b54872" }, "provenance": { "font_programs_embedded": false, @@ -196,8 +204,18 @@ } } ], + "fixture_isolation": { + "catalog_scoped_features": [ + "StructTreeRoot", + "ParentTree", + "AcroForm", + "Outlines" + ], + "combined_pdf": false, + "reason": "Page concatenation would alter document-global evidence and weaken failure attribution." + }, "generator": "validation/pr_visual/build.py", - "generator_sha256": "3e7c8fd949c347c47942e8b023747ca08257c63ef724bda2e89c085cf796b7d2", + "generator_sha256": "8a814c58de678c9556ae0d5e7919dc56933b61d260b57c1deccc787531e88f50", "head_commit": null, "license": { "file": "LICENSE.txt", diff --git a/examples/review.html b/examples/review.html index 46c3aa5..5683356 100644 --- a/examples/review.html +++ b/examples/review.html @@ -19,6 +19,8 @@

CocoaPDF permanent capability demo

Left: source PDF. Right: CocoaPDF HTML. See README.md and manifest.json for every output and hash.

+

The three source PDFs remain separate because Tagged-PDF trees, AcroForm fields, and outlines + are document-catalog semantics; concatenating pages would change the evidence under review.

strategic_corner_cases

@@ -56,7 +58,7 @@

tagged_semantics

scope_and_adversarial

-

Page-range outline/AcroForm scope, dot-leader finance recovery, and diagram-versus-form false-positive resistance.

+

Page-range outline/AcroForm scope, valid heading anchors, dot-leader finance recovery, and two-sided diagram-versus-form/table fidelity.

Open input PDF · Open Markdown · diff --git a/src/cocoapdf/assets/vector.py b/src/cocoapdf/assets/vector.py index 5ae23bc..b09ebae 100644 --- a/src/cocoapdf/assets/vector.py +++ b/src/cocoapdf/assets/vector.py @@ -16,6 +16,15 @@ class VectorFigure: data: bytes line_ids: Tuple[int, ...] seq: int + alt: str = "" + glyph_ids: Tuple[int, ...] = () + + +@dataclass(frozen=True) +class _OutlinedPanel: + bbox: Tuple[float, float, float, float] + segments: Tuple[Any, ...] + seq: int def detect_vector_figures( @@ -25,7 +34,19 @@ def detect_vector_figures( ) -> List[VectorFigure]: out: List[VectorFigure] = [] claimed: Dict[int, List[Tuple[float, float, float, float]]] = {} - for page, box, seq in _composite_diagram_boxes(converter): + diagram_boxes = list(_composite_diagram_boxes(converter)) + diagram_boxes.extend( + _outlined_panel_diagram_boxes(converter, lines_by_page, table_boxes) + ) + for page, box, seq in sorted( + diagram_boxes, + key=lambda item: (item[0], item[1][1], item[1][0], item[2]), + ): + if any( + _overlap_ratio(box, previous) >= 0.80 + for previous in claimed.get(page, []) + ): + continue if any( image.page == page and box[0] <= (image.x0 + image.x1) / 2 <= box[2] @@ -50,6 +71,8 @@ def detect_vector_figures( data=svg, line_ids=tuple(line_ids), seq=seq, + alt=_vector_alt(lines), + glyph_ids=_vector_glyph_ids(lines), ) ) claimed.setdefault(page, []).append(box) @@ -134,6 +157,8 @@ def detect_vector_figures( data=svg, line_ids=tuple(line_ids), seq=fill.seq, + alt=_vector_alt(lines), + glyph_ids=_vector_glyph_ids(lines), ) ) claimed.setdefault(fill.page, []).append(box) @@ -201,6 +226,310 @@ def _capture_lines( return lines, line_ids +def _outlined_panel_diagram_boxes( + converter: Any, + lines_by_page: Dict[int, List[Any]], + table_boxes: Dict[int, List[Tuple[float, float, float, float]]], +) -> List[Tuple[int, Tuple[float, float, float, float], int]]: + """Find diagram-like constellations made only from labeled outline panels. + + An absence of fills, connectors, or arrowheads is not proof that independent + closed rectangles are prose. Some PDF producers paint a flow or architecture + sequence as staggered labeled boxes only. Preserve those strokes and labels + together when the geometry supplies several independent signals: + + * at least four closed, similarly sized, labeled panels; + * panels distributed over three or more horizontal axes and multiple rows; + * no dominant one/two-column form pattern; + * no table lattice overlap; and + * almost all text in the captured region belongs to a panel. + + The last conditions deliberately prefer a plain-text false negative over + swallowing a form, table, or nearby paragraph into an SVG. + """ + segments_by_page: Dict[int, List[Any]] = {} + for segment in converter.segments: + if segment.horizontal or segment.vertical: + segments_by_page.setdefault(segment.page, []).append(segment) + out: List[Tuple[int, Tuple[float, float, float, float], int]] = [] + for page, page_segments in segments_by_page.items(): + panels = [ + panel + for panel in _closed_outline_panels(page_segments) + if not any( + _overlap_ratio(panel.bbox, table_box) >= 0.45 + or _contains(panel.bbox, table_box, 2.0) + for table_box in table_boxes.get(page, []) + ) + ] + if len(panels) < 4: + continue + lines = lines_by_page.get(page, []) + for group in _nearby_panel_groups(panels): + if len(group) < 4 or _form_column_pattern(group): + continue + widths = sorted(panel.bbox[2] - panel.bbox[0] for panel in group) + heights = sorted(panel.bbox[3] - panel.bbox[1] for panel in group) + median_width = widths[len(widths) // 2] + median_height = heights[len(heights) // 2] + if widths[-1] > widths[0] * 2.25 or heights[-1] > heights[0] * 2.0: + continue + x_clusters = _position_clusters( + [(panel.bbox[0] + panel.bbox[2]) / 2.0 for panel in group], + max(12.0, median_width * 0.22), + ) + y_clusters = _position_clusters( + [(panel.bbox[1] + panel.bbox[3]) / 2.0 for panel in group], + max(6.0, median_height * 0.40), + ) + if len(x_clusters) < 3 or len(y_clusters) < 2: + continue + panel_texts = [_panel_text(panel.bbox, lines) for panel in group] + if sum(bool(text) for text in panel_texts) < math.ceil(len(group) * 0.80): + continue + if any(text and (len(text) > 48 or not any(char.isalpha() for char in text)) for text in panel_texts): + continue + box = _union_boxes([panel.bbox for panel in group]) + page_width, page_height = converter.page_sizes.get(page, (612.0, 792.0)) + width = box[2] - box[0] + height = box[3] - box[1] + if width < median_width * 2.75 or height < median_height * 1.40: + continue + if width * height > page_width * page_height * 0.28: + continue + if _region_panel_text_ratio(box, group, lines) < 0.82: + continue + if any( + image.page == page + and box[0] <= (image.x0 + image.x1) / 2.0 <= box[2] + and box[1] <= (image.y0 + image.y1) / 2.0 <= box[3] + for image in converter.images + ): + continue + padding = max( + 1.0, + max(segment.width for panel in group for segment in panel.segments) / 2.0 + 0.5, + ) + padded = ( + max(0.0, box[0] - padding), + max(0.0, box[1] - padding), + min(page_width, box[2] + padding), + min(page_height, box[3] + padding), + ) + out.append((page, padded, min(panel.seq for panel in group))) + return out + + +def _closed_outline_panels(segments: Sequence[Any]) -> List[_OutlinedPanel]: + tolerance = 2.5 + horizontal = sorted( + ( + ( + min(segment.x0, segment.x1), + max(segment.x0, segment.x1), + (segment.y0 + segment.y1) / 2.0, + segment, + ) + for segment in segments + if segment.horizontal and 36.0 <= segment.length <= 320.0 + ), + key=lambda item: (item[2], item[0], item[1], item[3].seq), + ) + vertical = [ + segment + for segment in segments + if segment.vertical and 8.0 <= segment.length <= 96.0 + ] + vertical_index: Dict[int, List[Any]] = {} + for segment in vertical: + x = (segment.x0 + segment.x1) / 2.0 + vertical_index.setdefault(int(math.floor(x / tolerance)), []).append(segment) + found: Dict[Tuple[int, int, int, int], _OutlinedPanel] = {} + for index, first_info in enumerate(horizontal): + first_x0, first_x1, first_y, first = first_info + for second_x0, second_x1, second_y, second in horizontal[index + 1 :]: + height = second_y - first_y + if height > 96.0: + break + if height < 8.0: + continue + if ( + abs(first_x0 - second_x0) > tolerance + or abs(first_x1 - second_x1) > tolerance + ): + continue + x0 = (first_x0 + second_x0) / 2.0 + x1 = (first_x1 + second_x1) / 2.0 + left = _matching_vertical_edge( + vertical_index, + x0, + first_y, + second_y, + tolerance, + ) + right = _matching_vertical_edge( + vertical_index, + x1, + first_y, + second_y, + tolerance, + ) + if left is None or right is None or left is right: + continue + bbox = (x0, first_y, x1, second_y) + key = tuple(int(round(value * 2.0)) for value in bbox) + panel_segments = (first, second, left, right) + candidate = _OutlinedPanel( + bbox=bbox, + segments=panel_segments, + seq=min(segment.seq for segment in panel_segments), + ) + previous = found.get(key) + if previous is None or candidate.seq < previous.seq: + found[key] = candidate + return sorted(found.values(), key=lambda panel: (panel.bbox[1], panel.bbox[0], panel.seq)) + + +def _matching_vertical_edge( + segments_by_x: Dict[int, List[Any]], + x: float, + y0: float, + y1: float, + tolerance: float, +) -> Any: + candidates = [] + key = int(math.floor(x / tolerance)) + segments = [ + segment + for bucket in range(key - 1, key + 2) + for segment in segments_by_x.get(bucket, []) + ] + for segment in segments: + segment_x = (segment.x0 + segment.x1) / 2.0 + segment_y0, segment_y1 = sorted((segment.y0, segment.y1)) + if abs(segment_x - x) > tolerance: + continue + if segment_y0 > y0 + tolerance or segment_y1 < y1 - tolerance: + continue + candidates.append( + ( + abs(segment_x - x) + + abs(segment_y0 - y0) + + abs(segment_y1 - y1), + segment.seq, + segment, + ) + ) + return min(candidates, default=(0.0, 0, None))[2] + + +def _nearby_panel_groups(panels: Sequence[_OutlinedPanel]) -> List[List[_OutlinedPanel]]: + if not panels: + return [] + widths = sorted(panel.bbox[2] - panel.bbox[0] for panel in panels) + heights = sorted(panel.bbox[3] - panel.bbox[1] for panel in panels) + median_width = widths[len(widths) // 2] + median_height = heights[len(heights) // 2] + max_gap = max(24.0, min(72.0, median_width * 0.58 + median_height * 0.18)) + remaining = set(range(len(panels))) + groups: List[List[_OutlinedPanel]] = [] + while remaining: + seed = min(remaining) + remaining.remove(seed) + member_indexes = {seed} + frontier = [seed] + while frontier: + current = frontier.pop() + neighbors = [ + index + for index in sorted(remaining) + if _boxes_distance(panels[current].bbox, panels[index].bbox) <= max_gap + ] + for index in neighbors: + remaining.remove(index) + member_indexes.add(index) + frontier.append(index) + groups.append( + sorted( + (panels[index] for index in member_indexes), + key=lambda panel: (panel.bbox[1], panel.bbox[0], panel.seq), + ) + ) + return groups + + +def _form_column_pattern(panels: Sequence[_OutlinedPanel]) -> bool: + widths = sorted(max(1.0, panel.bbox[2] - panel.bbox[0]) for panel in panels) + median_width = widths[len(widths) // 2] + clusters = _position_clusters( + [panel.bbox[0] for panel in panels], + max(12.0, min(30.0, median_width * 0.18)), + ) + sizes = sorted((len(cluster) for cluster in clusters), reverse=True) + count = max(1, len(panels)) + if sizes and sizes[0] / count >= 0.60: + return True + return ( + len(sizes) >= 2 + and sizes[1] >= max(2, math.ceil(count * 0.25)) + and sum(sizes[:2]) / count >= 0.85 + ) + + +def _position_clusters(values: Sequence[float], tolerance: float) -> List[List[float]]: + clusters: List[List[float]] = [] + for value in sorted(values): + for cluster in clusters: + if abs(value - sum(cluster) / len(cluster)) <= tolerance: + cluster.append(value) + break + else: + clusters.append([value]) + return clusters + + +def _panel_text( + box: Tuple[float, float, float, float], + lines: Sequence[Any], +) -> str: + chars = [ + char + for line in lines + for char in line.chars + if char.text.strip() + and box[0] - 2.0 <= (char.x0 + char.x1) / 2.0 <= box[2] + 2.0 + and box[1] - 2.0 <= (char.y0 + char.y1) / 2.0 <= box[3] + 2.0 + ] + return "".join(char.text for char in sorted(chars, key=lambda char: (char.y0, char.x0, char.seq))).strip() + + +def _region_panel_text_ratio( + box: Tuple[float, float, float, float], + panels: Sequence[_OutlinedPanel], + lines: Sequence[Any], +) -> float: + region_chars = [ + char + for line in lines + for char in line.chars + if char.text.strip() + and box[0] <= (char.x0 + char.x1) / 2.0 <= box[2] + and box[1] <= (char.y0 + char.y1) / 2.0 <= box[3] + ] + if not region_chars: + return 0.0 + inside = sum( + 1 + for char in region_chars + if any( + panel.bbox[0] - 2.0 <= (char.x0 + char.x1) / 2.0 <= panel.bbox[2] + 2.0 + and panel.bbox[1] - 2.0 <= (char.y0 + char.y1) / 2.0 <= panel.bbox[3] + 2.0 + for panel in panels + ) + ) + return inside / len(region_chars) + + def _composite_diagram_boxes( converter: Any, ) -> List[Tuple[int, Tuple[float, float, float, float], int]]: @@ -535,6 +864,30 @@ def _line_char_runs(line: Any) -> List[List[Any]]: return runs +def _vector_alt(lines: Sequence[Any]) -> str: + labels: List[str] = [] + for line in sorted(lines, key=lambda item: (item.y0, item.x0, item.seq)): + for run in _line_char_runs(line): + text = "".join( + char.text + for char in sorted(run, key=lambda item: (item.x0, item.seq)) + ).strip() + if text and text not in labels: + labels.append(text) + alt = " · ".join(labels) + if len(alt) <= 240: + return alt + return alt[:237].rstrip() + "..." + + +def _vector_glyph_ids(lines: Sequence[Any]) -> Tuple[int, ...]: + return tuple(sorted({ + int(char.seq) + for line in lines + for char in line.chars + })) + + def _source_viewport_box(fill: Any) -> Tuple[float, float, float, float]: """Recover a tightly clipped vector viewport around an inset background.""" paint = (fill.x0, fill.y0, fill.x1, fill.y1) @@ -607,6 +960,8 @@ def _group_adjacent_panels( data=svg, line_ids=tuple(sorted(set(left.line_ids + right.line_ids))), seq=min(left.seq, right.seq), + alt=_vector_alt(lines), + glyph_ids=_vector_glyph_ids(lines), ) ) i += 2 diff --git a/src/cocoapdf/core.py b/src/cocoapdf/core.py index b29927a..24a367f 100644 --- a/src/cocoapdf/core.py +++ b/src/cocoapdf/core.py @@ -235,6 +235,7 @@ class ImageItem: kind: str = "raster" mcids: Tuple[int, ...] = field(default_factory=tuple) tags: Tuple[str, ...] = field(default_factory=tuple) + glyph_ids: Tuple[int, ...] = field(default_factory=tuple, repr=False) object_ref: Optional[str] = None link_object_ref: Optional[str] = None @@ -2789,12 +2790,14 @@ def _materialize_vector_figures(self) -> None: seq=figure.seq, name=figure.name, data=figure.data, + alt=figure.alt, intrinsic_width=max(1, int(round(x1 - x0))), intrinsic_height=max(1, int(round(y1 - y0))), placed_width=max(1.0, x1 - x0), placed_height=max(1.0, y1 - y0), quad=((x0, y0), (x1, y0), (x1, y1), (x0, y1)), kind="vector", + glyph_ids=figure.glyph_ids, ) cx = (x0 + x1) / 2 cy = (y0 + y1) / 2 @@ -3623,7 +3626,7 @@ def _render_page(self, page: int) -> List[str]: ) ) consumed.update(id(line) for line in caption_lines) - if not img.alt: + if not img.alt or img.kind == "vector": img.alt = caption blocks.append( self._event( diff --git a/src/cocoapdf/html/css.py b/src/cocoapdf/html/css.py index 697819a..dd7d28a 100644 --- a/src/cocoapdf/html/css.py +++ b/src/cocoapdf/html/css.py @@ -21,5 +21,8 @@ .cocoapdf-table-note { font-size: 0.9em; font-style: italic; } .cocoapdf-cross-reference { text-decoration: none; } .cocoapdf-form { display: grid; gap: 0.65rem; margin: 1rem 0; } +.cocoapdf-form-field { display: grid; grid-template-columns: minmax(8rem, max-content) 1fr; column-gap: 0.5rem; } +.cocoapdf-form-field-name { font-weight: 600; } +.cocoapdf-form-field-value-evidenced { align-items: center; box-sizing: border-box; display: inline-flex; padding: 0 0.25rem; } .cocoapdf-equation { overflow-x: auto; margin: 1rem 0; } """.strip() diff --git a/src/cocoapdf/html/semantic.py b/src/cocoapdf/html/semantic.py index dd3eb9a..6833b25 100644 --- a/src/cocoapdf/html/semantic.py +++ b/src/cocoapdf/html/semantic.py @@ -1,7 +1,8 @@ from __future__ import annotations import html -from typing import List +import math +from typing import Any, List, Optional, Tuple from .css import DEFAULT_CSS from .sanitize import safe_asset_href, safe_href @@ -84,7 +85,10 @@ def _render_node(node: SemanticNode) -> str: if kind == "toc": return '

' % (data_attrs, "".join(_render_node(child) for child in node.children)) if kind == "toc_item": - target = html.escape(str(node.attrs.get("target_anchor") or node.attrs.get("target_id", "")), quote=True) + target = html.escape( + str(node.attrs.get("target_anchor") or node.attrs.get("target_id") or ""), + quote=True, + ) body = html.escape(node.text) if target: body = '%s' % (target, body) @@ -131,7 +135,10 @@ def _render_inline(node: SemanticNode) -> str: href = safe_href(str(node.attrs.get("href", ""))) return '%s' % (html.escape(href, quote=True), data_attrs, body) if href else body if node.kind == "cross_reference": - target = html.escape(str(node.attrs.get("target_anchor") or node.attrs.get("target_id", "")), quote=True) + target = html.escape( + str(node.attrs.get("target_anchor") or node.attrs.get("target_id") or ""), + quote=True, + ) return '%s' % (target, data_attrs, body) if target else body if node.kind in {"footnote_ref", "image"}: return _render_node(node) @@ -215,12 +222,144 @@ def _render_form_field(node: SemanticNode, attrs: str) -> str: display = value or state if state and value and state != value: display = "%s (%s)" % (value, state) + display_name = ( + name + if not name or name.rstrip().endswith(":") + else name + ":" + ) + value_class, value_attrs = _form_value_appearance_attributes(node) # Form extraction is documentary only: never emit active controls or copy # PDF actions into HTML. The typed semantic JSON retains options, flags, - # widget rectangles, export states, and signature metadata. + # widget rectangles, appearance evidence, export states, and signatures. return ( '
' - '%s' - '%s
' - % (html.escape(kind, quote=True), html.escape(str(node.attrs.get("name", "")), quote=True), attrs, name, display) + '%s ' + '%s' + % ( + html.escape(kind, quote=True), + html.escape(str(node.attrs.get("name", "")), quote=True), + attrs, + display_name, + value_class, + value_attrs, + display, + ) + ) + + +def _form_value_appearance_attributes( + node: SemanticNode, +) -> Tuple[str, str]: + appearance = node.attrs.get("appearance") + if not isinstance(appearance, dict): + return "cocoapdf-form-field-value", "" + + styles: List[str] = [] + text_color = _css_rgb(appearance.get("text_color_rgb")) + background = _css_rgb(appearance.get("background_color_rgb")) + border = _css_rgb(appearance.get("border_color_rgb")) + font_size = _safe_style_number( + appearance.get("font_size_pt"), + minimum=0.5, + maximum=144.0, + ) + width = _safe_style_number( + appearance.get("width_pt"), + minimum=1.0, + maximum=1_000.0, + ) + height = _safe_style_number( + appearance.get("height_pt"), + minimum=1.0, + maximum=1_000.0, + ) + border_width = _safe_style_number( + appearance.get("border_width_pt"), + minimum=0.0, + maximum=12.0, ) + if text_color: + styles.append("color: %s" % text_color) + if background: + styles.append("background-color: %s" % background) + if font_size is not None: + styles.append("font-size: %.3fpt" % font_size) + if width is not None: + styles.extend( + [ + "inline-size: %.3fpt" % width, + "max-inline-size: 100%", + ] + ) + if height is not None: + styles.append("min-block-size: %.3fpt" % height) + if border: + width_css = ( + "%.3fpt" % border_width + if border_width is not None and border_width > 0 + else "1px" + ) + styles.append("border: %s solid %s" % (width_css, border)) + alignment = str(appearance.get("text_alignment", "")) + if alignment in {"left", "center", "right"}: + styles.append( + "justify-content: %s" + % {"left": "flex-start", "center": "center", "right": "flex-end"}[ + alignment + ] + ) + + # A parsed font resource name is provenance, not a portable CSS family; + # silently mapping /F1 to a browser font would invent semantics. + sources = appearance.get("sources") + source_attr = "" + if isinstance(sources, list): + safe_sources = [ + str(source) + for source in sources + if isinstance(source, str) and source + ] + if safe_sources: + source_attr = ' data-appearance-source="%s"' % html.escape( + ",".join(safe_sources), + quote=True, + ) + if not styles: + return "cocoapdf-form-field-value", source_attr + style_attr = ' style="%s"' % html.escape("; ".join(styles), quote=True) + return ( + "cocoapdf-form-field-value cocoapdf-form-field-value-evidenced", + source_attr + style_attr, + ) + + +def _css_rgb(raw: Any) -> Optional[str]: + if ( + not isinstance(raw, (list, tuple)) + or len(raw) != 3 + or any(isinstance(value, bool) for value in raw) + ): + return None + values: List[int] = [] + for value in raw: + if not isinstance(value, (int, float)): + return None + number = float(value) + if not math.isfinite(number): + return None + values.append(int(round(min(1.0, max(0.0, number)) * 255.0))) + return "rgb(%d, %d, %d)" % tuple(values) + + +def _safe_style_number( + raw: Any, + *, + minimum: float, + maximum: float, +) -> Optional[float]: + if isinstance(raw, bool) or not isinstance(raw, (int, float)): + return None + value = float(raw) + if not math.isfinite(value) or value < minimum: + return None + return min(value, maximum) diff --git a/src/cocoapdf/markdown/semantic.py b/src/cocoapdf/markdown/semantic.py index d919e12..214dc7f 100644 --- a/src/cocoapdf/markdown/semantic.py +++ b/src/cocoapdf/markdown/semantic.py @@ -138,7 +138,11 @@ def _render_inline(node: SemanticNode) -> str: return "<%s>" % href return "[%s](%s)" % (body, _escape_destination(href)) if href else body if node.kind == "cross_reference": - target = re.sub(r"[^A-Za-z0-9_.:-]", "-", str(node.attrs.get("target_anchor") or node.attrs.get("target_id", ""))) + target = re.sub( + r"[^A-Za-z0-9_.:-]", + "-", + str(node.attrs.get("target_anchor") or node.attrs.get("target_id") or ""), + ) return "[%s](#%s)" % (body, target) if target else body if node.kind == "footnote_ref": return "[^%s]" % _safe_label(str(node.attrs.get("label", node.id))) @@ -284,7 +288,7 @@ def _render_toc(node: SemanticNode) -> str: def _render_toc_item(node: SemanticNode) -> str: level = max(1, int(node.attrs.get("level", 1))) - target = str(node.attrs.get("target_anchor") or node.attrs.get("target_id", "")) + target = str(node.attrs.get("target_anchor") or node.attrs.get("target_id") or "") body = _escape(node.text) if target: body = "[%s](#%s)" % (body, re.sub(r"[^A-Za-z0-9_.:-]", "-", target)) diff --git a/src/cocoapdf/semantics/forms.py b/src/cocoapdf/semantics/forms.py index 95dc2f6..ec0156f 100644 --- a/src/cocoapdf/semantics/forms.py +++ b/src/cocoapdf/semantics/forms.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math from typing import Any, Dict, List, Optional, Sequence, Set, Tuple from ..ir.evidence import Evidence @@ -7,6 +8,8 @@ _INHERITED_KEYS = ("FT", "Ff", "V", "DV", "DA", "Q", "Opt", "MaxLen", "I", "TI") +_ACROFORM_DEFAULT_KEYS = ("DA", "Q") +_MAX_APPEARANCE_STREAM_BYTES = 262_144 def extract_acroform( @@ -23,11 +26,27 @@ def extract_acroform( fields = _resolve(document, form.get("Fields")) if not isinstance(fields, list): fields = [] + form_defaults = { + key: form[key] + for key in _ACROFORM_DEFAULT_KEYS + if key in form + } widget_pages = _widget_page_index(document, page_ref_to_num) children: List[SemanticNode] = [] active: set[Tuple[str, int]] = set() for raw in fields: - children.extend(_walk_field(document, factory, raw, {}, [], page_ref_to_num, widget_pages, active)) + children.extend( + _walk_field( + document, + factory, + raw, + form_defaults, + [], + page_ref_to_num, + widget_pages, + active, + ) + ) if selected_pages is not None: # AcroForm field trees are document-global. Interpret the complete tree # first so inheritance and widget identity remain intact, then retain @@ -45,10 +64,16 @@ def extract_acroform( child.sources = selected_sources widgets = child.attrs.get("widgets") if isinstance(widgets, list): - child.attrs["widgets"] = [ + selected_widgets = [ widget for widget in widgets if isinstance(widget, dict) and widget.get("page") in selected_pages ] + child.attrs["widgets"] = selected_widgets + child.attrs["appearance"] = ( + _uniform_widget_appearance(selected_widgets) + if child.attrs.get("field_type") in {"text", "combo", "listbox"} + else None + ) selected_children.append(child) children = selected_children if not children: @@ -148,6 +173,7 @@ def _field_node( rect = tuple(float(item) for item in rect_value[:4]) if isinstance(rect_value, list) and len(rect_value) >= 4 else None object_ref = _ref_text(widget_raw) states = _appearance_states(document, widget) + appearance = _widget_appearance(document, widget, state, rect) export_states.extend(state for state in states if state != "Off") actions_ignored = actions_ignored or any(key in widget for key in ("A", "AA")) sources.append(SourceRef(page=page, object_refs=(object_ref,), bbox=rect)) @@ -159,6 +185,7 @@ def _field_node( "export_states": states, "annotation_flags": int(_number(document, widget.get("F"), 0)), "highlight_mode": _name(document, widget.get("H")) or None, + "appearance": appearance or None, }) password = kind == "text" and bool(flags & (1 << 13)) value_redacted = password and bool(display_value) @@ -166,6 +193,11 @@ def _field_node( display_value = "[redacted]" alternate_name = _text(document, value.get("TU")) mapping_name = _text(document, value.get("TM")) + uniform_appearance = ( + _uniform_widget_appearance(widget_meta) + if kind in {"text", "combo", "listbox"} + else None + ) attrs = { "name": ".".join(parts), "partial_name": parts[-1] if parts else "", @@ -189,6 +221,10 @@ def _field_node( "top_index": int(_number(document, state.get("TI"), 0)) if kind == "listbox" else None, "export_states": sorted(set(export_states)), "widgets": widget_meta, + # A field may have several widgets with different appearances. HTML is + # styled only when every retained widget agrees; per-widget evidence + # always remains available in ``widgets``. + "appearance": uniform_appearance, "actions_ignored": actions_ignored, } if kind in {"checkbox", "radio"}: @@ -281,6 +317,393 @@ def _appearance_states(document: Any, widget: Dict[str, Any]) -> List[str]: return sorted({_name(document, key) for key in normal if _name(document, key)}) +def _uniform_widget_appearance( + widgets: Sequence[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + appearances = [ + widget.get("appearance") + for widget in widgets + if isinstance(widget.get("appearance"), dict) + ] + if ( + len(appearances) != len(widgets) + or not appearances + or any(candidate != appearances[0] for candidate in appearances[1:]) + ): + return None + return appearances[0] + + +def _widget_appearance( + document: Any, + widget: Dict[str, Any], + state: Dict[str, Any], + rect: Optional[Tuple[float, ...]], +) -> Dict[str, Any]: + """Recover only explicit, safely renderable widget appearance evidence. + + PDF viewers may synthesize blue focus rectangles, font sizes, and other UI + chrome when a widget lacks an appearance. Those viewer choices are not PDF + content and must not leak into deterministic output. The values below come + only from /DA, /MK, /Q, an /AP normal appearance stream, or /Rect. + """ + appearance: Dict[str, Any] = {} + sources: List[str] = [] + + default_appearance = _default_appearance_style( + document, + widget.get("DA", state.get("DA")), + ) + characteristics = _appearance_characteristics_style(document, widget) + normal_stream = _normal_appearance_stream(document, widget) + normal_appearance = ( + _normal_appearance_style(document, normal_stream) + if normal_stream is not None + else {} + ) + if normal_stream is not None: + # /AP is the painted normal appearance. Keep /DA and /MK declarations + # as provenance, but never use an unpainted declaration to fill a + # visual property missing from an authoritative appearance stream. + if default_appearance: + appearance["declared_default_appearance"] = default_appearance + sources.append("default_appearance") + if characteristics: + appearance["declared_appearance_characteristics"] = characteristics + sources.append("appearance_characteristics") + appearance.update(normal_appearance) + sources.append("normal_appearance_stream") + else: + if default_appearance: + appearance.update(default_appearance) + sources.append("default_appearance") + if characteristics: + appearance.update(characteristics) + sources.append("appearance_characteristics") + + alignment_number = _resolved_number( + document, + widget.get("Q", state.get("Q")), + ) + if alignment_number in (0.0, 1.0, 2.0): + appearance["text_alignment"] = { + 0.0: "left", + 1.0: "center", + 2.0: "right", + }[alignment_number] + sources.append("quadding") + + if not sources: + return {} + if rect and len(rect) >= 4: + width = abs(float(rect[2]) - float(rect[0])) + height = abs(float(rect[3]) - float(rect[1])) + if _positive_finite(width) and _positive_finite(height): + appearance["width_pt"] = round(width, 6) + appearance["height_pt"] = round(height, 6) + sources.append("widget_rect") + appearance["sources"] = sorted(set(sources)) + return appearance + + +def _default_appearance_style(document: Any, raw: Any) -> Dict[str, Any]: + value = _resolve(document, raw) + if isinstance(value, str): + data = value.encode("latin-1", "replace") + elif isinstance(value, bytes): + data = value + else: + return {} + if len(data) > _MAX_APPEARANCE_STREAM_BYTES: + return {} + style: Dict[str, Any] = {} + for operator, operands in _content_operations(data): + if operator == "Tf" and len(operands) >= 2: + font_name = operands[-2] + font_size = _finite_float(operands[-1]) + if isinstance(font_name, str) and font_name: + style["font_resource"] = font_name.lstrip("/") + if font_size is not None: + if font_size > 0: + style["font_size_pt"] = round(font_size, 6) + elif font_size == 0: + style["font_size_auto"] = True + elif operator in {"g", "rg", "k"}: + color = _operator_color(operator, operands) + if color is not None: + style["text_color_rgb"] = color + return style + + +def _appearance_characteristics_style( + document: Any, + widget: Dict[str, Any], +) -> Dict[str, Any]: + characteristics = _resolve(document, widget.get("MK")) + if not isinstance(characteristics, dict): + return {} + style: Dict[str, Any] = {} + background = _pdf_color(document, characteristics.get("BG")) + border = _pdf_color(document, characteristics.get("BC")) + if background is not None: + style["background_color_rgb"] = background + if border is not None: + style["border_color_rgb"] = border + rotation = _resolved_number(document, characteristics.get("R")) + if rotation is not None and math.isfinite(rotation): + normalized = int(round(rotation)) % 360 + if normalized in {0, 90, 180, 270}: + style["rotation"] = normalized + return style + + +def _normal_appearance_style( + document: Any, + stream: Any, +) -> Dict[str, Any]: + attrs = getattr(stream, "attrs", None) + if not isinstance(attrs, dict): + return {} + try: + data = document.decoded_stream(stream) + except Exception: + return {} + if not isinstance(data, bytes) or len(data) > _MAX_APPEARANCE_STREAM_BYTES: + return {} + + bbox = _numeric_rect(document, attrs.get("BBox")) + fill_color: Optional[List[float]] = None + stroke_color: Optional[List[float]] = None + font_resource: Optional[str] = None + font_size: Optional[float] = None + line_width: Optional[float] = None + pending_rect: Optional[Tuple[float, float, float, float]] = None + graphics_stack: List[ + Tuple[ + Optional[List[float]], + Optional[List[float]], + Optional[str], + Optional[float], + Optional[float], + ] + ] = [] + style: Dict[str, Any] = {} + for operator, operands in _content_operations(data): + if operator == "q": + if len(graphics_stack) >= 64: + break + graphics_stack.append( + ( + fill_color, + stroke_color, + font_resource, + font_size, + line_width, + ) + ) + elif operator == "Q" and graphics_stack: + fill_color, stroke_color, font_resource, font_size, line_width = ( + graphics_stack.pop() + ) + elif operator in {"g", "rg", "k"}: + fill_color = _operator_color(operator, operands) + elif operator in {"G", "RG", "K"}: + stroke_color = _operator_color(operator.lower(), operands) + elif operator == "Tf" and len(operands) >= 2: + candidate_name = operands[-2] + candidate_size = _finite_float(operands[-1]) + if isinstance(candidate_name, str) and candidate_name: + font_resource = candidate_name.lstrip("/") + if candidate_size is not None: + font_size = candidate_size + elif operator == "w" and operands: + candidate_width = _finite_float(operands[-1]) + if candidate_width is not None and candidate_width >= 0: + line_width = candidate_width + elif operator == "re" and len(operands) >= 4: + values = [_finite_float(item) for item in operands[-4:]] + if all(item is not None for item in values): + pending_rect = tuple(float(item) for item in values) # type: ignore[arg-type] + elif operator in {"f", "F", "f*", "B", "B*", "b", "b*"}: + if ( + fill_color is not None + and pending_rect is not None + and _rect_covers_bbox(pending_rect, bbox) + ): + style["background_color_rgb"] = fill_color + if ( + operator in {"B", "B*", "b", "b*"} + and stroke_color is not None + and pending_rect is not None + and _rect_covers_bbox(pending_rect, bbox) + ): + style["border_color_rgb"] = stroke_color + if line_width is not None: + style["border_width_pt"] = round(line_width, 6) + pending_rect = None + elif operator in {"S", "s"}: + if ( + stroke_color is not None + and pending_rect is not None + and _rect_covers_bbox(pending_rect, bbox) + ): + style["border_color_rgb"] = stroke_color + if line_width is not None: + style["border_width_pt"] = round(line_width, 6) + pending_rect = None + elif operator in {"Tj", "TJ", "'", '"'}: + if fill_color is not None: + style["text_color_rgb"] = fill_color + if font_resource: + style["font_resource"] = font_resource + if font_size is not None: + if font_size > 0: + style["font_size_pt"] = round(font_size, 6) + elif font_size == 0: + style["font_size_auto"] = True + return style + + +def _normal_appearance_stream(document: Any, widget: Dict[str, Any]) -> Any: + appearance = _resolve(document, widget.get("AP")) + if not isinstance(appearance, dict): + return None + normal = _resolve(document, appearance.get("N")) + if isinstance(getattr(normal, "attrs", None), dict): + return normal + if not isinstance(normal, dict): + return None + state = _name(document, widget.get("AS")) + if state: + for raw_name, raw_stream in normal.items(): + if _name(document, raw_name) == state: + candidate = _resolve(document, raw_stream) + if isinstance(getattr(candidate, "attrs", None), dict): + return candidate + return None + candidates = [] + for raw_name, raw_stream in normal.items(): + if _name(document, raw_name) == "Off": + continue + candidate = _resolve(document, raw_stream) + if isinstance(getattr(candidate, "attrs", None), dict): + candidates.append(candidate) + return candidates[0] if len(candidates) == 1 else None + + +def _content_operations(data: bytes) -> Any: + from ..content.tokens import tokenize_content + + operands: List[Any] = [] + try: + for token in tokenize_content(data): + operator = getattr(token, "name", None) + if operator is None: + if len(operands) < 32: + operands.append(token) + continue + yield str(operator), operands + operands = [] + except Exception: + return + + +def _operator_color( + operator: str, + operands: Sequence[Any], +) -> Optional[List[float]]: + component_count = {"g": 1, "rg": 3, "k": 4}.get(operator) + if component_count is None or len(operands) < component_count: + return None + values = [ + _finite_float(item) + for item in operands[-component_count:] + ] + if any(value is None for value in values): + return None + return _components_to_rgb([float(value) for value in values]) + + +def _pdf_color(document: Any, raw: Any) -> Optional[List[float]]: + value = _resolve(document, raw) + if not isinstance(value, list) or len(value) not in {1, 3, 4}: + return None + components = [_resolved_number(document, item) for item in value] + if any(component is None for component in components): + return None + return _components_to_rgb([float(component) for component in components]) + + +def _components_to_rgb(components: Sequence[float]) -> Optional[List[float]]: + if not components or any(not math.isfinite(value) for value in components): + return None + values = [min(1.0, max(0.0, value)) for value in components] + if len(values) == 1: + red = green = blue = values[0] + elif len(values) == 3: + red, green, blue = values + elif len(values) == 4: + cyan, magenta, yellow, black = values + red = 1.0 - min(1.0, cyan + black) + green = 1.0 - min(1.0, magenta + black) + blue = 1.0 - min(1.0, yellow + black) + else: + return None + return [round(red, 6), round(green, 6), round(blue, 6)] + + +def _numeric_rect( + document: Any, + raw: Any, +) -> Optional[Tuple[float, float, float, float]]: + value = _resolve(document, raw) + if not isinstance(value, list) or len(value) < 4: + return None + numbers = [_resolved_number(document, item) for item in value[:4]] + if any(number is None for number in numbers): + return None + return tuple(float(number) for number in numbers) # type: ignore[arg-type] + + +def _rect_covers_bbox( + rect: Tuple[float, float, float, float], + bbox: Optional[Tuple[float, float, float, float]], +) -> bool: + if bbox is None: + return False + rect_x0, rect_x1 = sorted((rect[0], rect[0] + rect[2])) + rect_y0, rect_y1 = sorted((rect[1], rect[1] + rect[3])) + bbox_x0, bbox_x1 = sorted((bbox[0], bbox[2])) + bbox_y0, bbox_y1 = sorted((bbox[1], bbox[3])) + rect_area = (rect_x1 - rect_x0) * (rect_y1 - rect_y0) + bbox_area = (bbox_x1 - bbox_x0) * (bbox_y1 - bbox_y0) + overlap_width = max(0.0, min(rect_x1, bbox_x1) - max(rect_x0, bbox_x0)) + overlap_height = max(0.0, min(rect_y1, bbox_y1) - max(rect_y0, bbox_y0)) + overlap_area = overlap_width * overlap_height + return ( + rect_area > 0 + and bbox_area > 0 + and rect_area >= bbox_area * 0.75 + and overlap_area >= bbox_area * 0.75 + ) + + +def _resolved_number(document: Any, raw: Any) -> Optional[float]: + value = _resolve(document, raw) + return _finite_float(value) + + +def _finite_float(value: Any) -> Optional[float]: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + result = float(value) + return result if math.isfinite(result) else None + + +def _positive_finite(value: float) -> bool: + return math.isfinite(value) and value > 0 + + def _signature_metadata(document: Any, raw: Any) -> Optional[Dict[str, Any]]: value = _resolve(document, raw) if not isinstance(value, dict): diff --git a/src/cocoapdf/semantics/graph.py b/src/cocoapdf/semantics/graph.py index 42290e8..168ff73 100644 --- a/src/cocoapdf/semantics/graph.py +++ b/src/cocoapdf/semantics/graph.py @@ -313,6 +313,7 @@ def _figure_node(factory: NodeFactory, converter: Any, event: Any, attrs: Dict[s object_refs = tuple(value for value in (getattr(image, "object_ref", None), getattr(image, "link_object_ref", None), image.name) if value) image_source = SourceRef( page=image.page, + glyph_ids=tuple(getattr(image, "glyph_ids", ()) or ()), mcids=tuple(getattr(image, "mcids", ()) or ()), object_refs=object_refs, bbox=(image.x0, image.y0, image.x1, image.y1), diff --git a/src/cocoapdf/semantics/navigation.py b/src/cocoapdf/semantics/navigation.py index 1e8073c..e120a6c 100644 --- a/src/cocoapdf/semantics/navigation.py +++ b/src/cocoapdf/semantics/navigation.py @@ -147,7 +147,17 @@ def _walk_siblings( target = _destination(document, destination, page_ref_to_num, named_destinations) children = _walk_siblings(document, factory, value.get("First"), level + 1, page_ref_to_num, named_destinations, semantic_document, active) if value.get("First") is not None else [] sources = [SourceRef(page=target[0], object_refs=(key,))] if target[0] else [] - target_node = _best_heading_target(semantic_document.children, title, target[0], level) if semantic_document is not None else None + target_node = ( + _best_heading_target( + semantic_document.children, + title, + target[0], + level, + destination=target[1], + ) + if semantic_document is not None + else None + ) out.append(factory.make( "outline_item", text=title, @@ -200,6 +210,7 @@ def _best_heading_target( title: str, page: Optional[int] = None, outline_level: int = 1, + destination: Any = None, ) -> Optional[Tuple[str, str]]: normalized = _normalize(title) title_words = set(normalized.split()) @@ -224,11 +235,55 @@ def _best_heading_target( if score > best[0]: best = (score, node) if best[0] < 0.72 or best[1] is None: - return None + # A page-only outline destination cannot identify a precise vertical + # coordinate. When that page has exactly one recovered heading, the + # PDF destination plus unique page structure is stronger evidence than + # title wording alone (for example "First Page" targeting "Page Scope + # Review"). Do not guess when multiple headings compete. + if not _page_only_destination(destination): + return None + page_headings = [ + node + for node in candidates + if page + and node.kind == "heading" + and node.source_pages() + and page in node.source_pages() + ] + if len(page_headings) != 1: + return None + node = page_headings[0] + node.evidence.append( + Evidence( + "pdf_outline_unique_page_heading", + 0.90, + page=page, + detail=title, + ) + ) + node.confidence = min(node.confidence, 0.90) + return node.id, _ensure_anchor(candidates, node, _node_text(node)) node = best[1] return node.id, _ensure_anchor(candidates, node, _node_text(node)) +def _page_only_destination(destination: Any) -> bool: + """Return true only when a PDF destination carries no vertical target.""" + if not isinstance(destination, list) or not destination: + return False + mode = str(destination[0] or "").lstrip("/") + operands = destination[1:] + if mode in {"Fit", "FitB", "FitV", "FitBV"}: + return True + if mode == "XYZ": + top = operands[1] if len(operands) > 1 else None + return top is None + if mode in {"FitH", "FitBH"}: + top = operands[0] if operands else None + return top is None + return False + + def _ensure_anchor(nodes: Sequence[SemanticNode], node: SemanticNode, title: str) -> str: existing = str(node.attrs.get("anchor") or "") if existing: diff --git a/tests/test_conversion_contract.py b/tests/test_conversion_contract.py index 3ffc5f7..d96874b 100644 --- a/tests/test_conversion_contract.py +++ b/tests/test_conversion_contract.py @@ -477,13 +477,15 @@ def test_connected_flowchart_is_one_complete_vector_figure(self): self.assertEqual(len(vectors), 1) self.assertEqual(result.markdown.count("", result.markdown) + for row in range(1, 4): + for column in range(1, 4): + self.assertIn("R%dC%d" % (row, column), result.markdown) + def test_outline_only_display_formula_is_preserved_as_external_svg(self): curve = ( b"%d 500 m %d 520 %d 520 %d 510 c " diff --git a/tests/test_release_automation.py b/tests/test_release_automation.py index c314657..9b4ff28 100644 --- a/tests/test_release_automation.py +++ b/tests/test_release_automation.py @@ -99,6 +99,22 @@ def test_pr_visual_workflow_is_ephemeral_and_release_queue_is_lossless(self): self.assertIn("no stale download link is retained", visual) self.assertIn("tree/main/examples", visual) self.assertIn("tree/${HEAD_SHA}/examples", visual) + self.assertIn( + "rawcdn.githack.com/${HEAD_REPOSITORY}/${HEAD_SHA}/examples/review.html", + visual, + ) + self.assertIn( + "HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}", + visual, + ) + self.assertIn( + 'if [[ "${HEAD_REPOSITORY}" == "${BASE_REPOSITORY}" ]]', + visual, + ) + self.assertIn( + "External HTML preview is intentionally omitted for fork pull requests", + visual, + ) self.assertNotIn("pull_request_target:", visual) self.assertIn("queue: max", release) self.assertIn("if: github.event_name == 'push'", release) @@ -122,6 +138,11 @@ def test_committed_capability_demo_has_locked_provenance_and_hashes(self): self.assertEqual(manifest["license"]["spdx"], "MIT") self.assertFalse(manifest["license"]["third_party_content_added"]) self.assertEqual(manifest["license"]["network_fetches"], 0) + self.assertFalse(manifest["fixture_isolation"]["combined_pdf"]) + self.assertEqual( + manifest["fixture_isolation"]["catalog_scoped_features"], + ["StructTreeRoot", "ParentTree", "AcroForm", "Outlines"], + ) self.assertTrue(manifest["lifecycle"]["committed_outputs"]) self.assertEqual(manifest["lifecycle"]["location"], "examples") self.assertEqual(len(manifest["cases"]), 3) @@ -133,6 +154,14 @@ def test_committed_capability_demo_has_locked_provenance_and_hashes(self): "https://raw.githack.com/sayantandey/CocoaPDF/main/examples/review.html", (examples / "README.md").read_text(encoding="utf-8"), ) + self.assertIn( + "rendered main-branch side-by-side demo", + (examples / "README.md").read_text(encoding="utf-8"), + ) + self.assertIn( + "rendered HTML on main", + (examples / "README.md").read_text(encoding="utf-8"), + ) for index_name in ("README.md", "review.html"): text = (examples / index_name).read_text(encoding="utf-8") links = re.findall(r"\]\(([^)]+)\)", text) diff --git a/tests/test_semantic_pipeline.py b/tests/test_semantic_pipeline.py index ee43174..7ab02c9 100644 --- a/tests/test_semantic_pipeline.py +++ b/tests/test_semantic_pipeline.py @@ -14,6 +14,7 @@ from cocoapdf.ir.semantic import NodeFactory, SemanticDocument, SemanticNode, SourceRef from cocoapdf.markdown.semantic import render_semantic_markdown from cocoapdf.reporting.report import attach_semantic_document +from cocoapdf.semantics.navigation import _best_heading_target from cocoapdf.semantics.reconcile import _tagged_list_node, reconcile_tagged_content from cocoapdf.semantics.tagged import parse_tagged_structure from cocoapdf.synthetic import line_op, make_pdf, rect_fill_op, text_op @@ -156,6 +157,102 @@ def test_acroform_field_tree_is_semantic_and_actions_are_not_executed(self) -> N self.assertIn("PDF_ACTIONS_NOT_EXECUTED", fields[0].warnings) self.assertIn("**Name:** Alice", result.markdown) self.assertIn('name="Name"', result.html) + self.assertIn( + 'Name: ' + 'Alice', + result.html, + ) + self.assertNotIn( + 'class="cocoapdf-form-field-value cocoapdf-form-field-value-evidenced"', + result.html, + ) + + def test_acroform_widget_appearance_uses_only_pdf_native_evidence(self) -> None: + content = b"BT /F1 12 Tf 1 0 0 1 72 720 Tm (Styled field) Tj ET" + appearance = ( + b"q 0.91 0.93 0.98 rg 0 0 160 25 re f " + b"0.65 0.70 0.78 RG 1 w 0.5 0.5 159 24 re S " + b"0.18 0.25 0.42 rg " + b"BT /F1 18 Tf 1 0 0 1 4 5 Tm (Beta) Tj ET Q" + ) + data = one_page_pdf( + content, + page_extra=b"/Annots [7 0 R]", + catalog_extra=b"/AcroForm 6 0 R", + extra_objects=[ + b"<< /Fields [7 0 R] /NeedAppearances false >>", + ( + b"<< /Type /Annot /Subtype /Widget /FT /Tx " + b"/T (SecondField) /V (Beta) /Rect [200 535 360 560] " + b"/P 3 0 R /DA (/F1 9 Tf 1 0 0 rg) /Q 0 " + b"/MK << /BG [1 0 0] /BC [0 1 0] >> " + b"/BS << /W 1 /S /S >> /AP << /N 8 0 R >> >>" + ), + ( + b"<< /Type /XObject /Subtype /Form /FormType 1 " + b"/BBox [0 0 160 25] " + b"/Resources << /Font << /F1 1 0 R >> >> " + b"/Length %d >>\nstream\n" % len(appearance) + + appearance + + b"\nendstream" + ), + ], + ) + result = convert(data) + field = semantic_nodes(result, "form_field")[0] + evidence = field.attrs["appearance"] + self.assertEqual(evidence["font_size_pt"], 18.0) + self.assertEqual(evidence["text_color_rgb"], [0.18, 0.25, 0.42]) + self.assertEqual(evidence["background_color_rgb"], [0.91, 0.93, 0.98]) + self.assertEqual(evidence["width_pt"], 160.0) + self.assertEqual(evidence["height_pt"], 25.0) + self.assertEqual( + evidence["declared_default_appearance"]["font_size_pt"], + 9.0, + ) + self.assertEqual( + evidence["declared_appearance_characteristics"][ + "background_color_rgb" + ], + [1.0, 0.0, 0.0], + ) + self.assertIn("normal_appearance_stream", evidence["sources"]) + self.assertIn("default_appearance", evidence["sources"]) + self.assertIn("appearance_characteristics", evidence["sources"]) + self.assertIn("cocoapdf-form-field-value-evidenced", result.html) + self.assertIn("background-color: rgb(232, 237, 250)", result.html) + self.assertIn("color: rgb(46, 64, 107)", result.html) + self.assertIn("font-size: 18.000pt", result.html) + self.assertNotIn(" None: + content = b"BT /F1 12 Tf 1 0 0 1 72 720 Tm (Inherited style) Tj ET" + data = one_page_pdf( + content, + page_extra=b"/Annots [7 0 R]", + catalog_extra=b"/AcroForm 6 0 R", + extra_objects=[ + ( + b"<< /Fields [7 0 R] /NeedAppearances true " + b"/DA (/F1 13 Tf 0.1 g) " + b"/DR << /Font << /F1 1 0 R >> >> >>" + ), + ( + b"<< /Type /Annot /Subtype /Widget /FT /Tx " + b"/T (InheritedField) /V (Value) " + b"/Rect [72 650 240 675] /P 3 0 R >>" + ), + ], + ) + result = convert(data) + appearance = semantic_nodes(result, "form_field")[0].attrs["appearance"] + self.assertEqual(appearance["font_size_pt"], 13.0) + self.assertEqual(appearance["text_color_rgb"], [0.1, 0.1, 0.1]) + self.assertEqual( + appearance["sources"], + ["default_appearance", "widget_rect"], + ) + self.assertIn("font-size: 13.000pt", result.html) def test_outline_is_parsed_and_used_as_toc_when_visible_toc_is_absent(self) -> None: content = b"BT /F1 18 Tf 1 0 0 1 72 720 Tm (Introduction) Tj ET" @@ -173,6 +270,84 @@ def test_outline_is_parsed_and_used_as_toc_when_visible_toc_is_absent(self) -> N self.assertIn("Introduction", result.markdown) self.assertIn('class="cocoapdf-toc"', result.html) + def test_page_only_outline_targets_the_unique_heading_on_that_page(self) -> None: + factory = NodeFactory() + source = [SourceRef(page=1)] + heading = factory.make( + "heading", + attrs={"level": 2}, + sources=source, + ).add( + factory.make("text", text="Page Scope Review", sources=source) + ) + body = factory.make("paragraph", sources=source).add( + factory.make("text", text="First page body", sources=source) + ) + + target = _best_heading_target( + [heading, body], + "First Page", + page=1, + outline_level=1, + destination=["XYZ", None, None, None], + ) + + self.assertEqual(target, (heading.id, "page-scope-review")) + self.assertEqual(heading.attrs["anchor"], "page-scope-review") + self.assertIn( + "pdf_outline_unique_page_heading", + {evidence.kind for evidence in heading.evidence}, + ) + + competing = factory.make( + "heading", + attrs={"level": 2}, + sources=source, + ).add( + factory.make("text", text="Another Heading", sources=source) + ) + self.assertIsNone( + _best_heading_target( + [heading, competing, body], + "Unrelated Destination", + page=1, + outline_level=1, + destination=["XYZ", None, None, None], + ) + ) + + self.assertIsNone( + _best_heading_target( + [heading, body], + "Unrelated Destination", + page=1, + outline_level=1, + destination=["XYZ", None, 400.0, None], + ) + ) + + def test_unresolved_outline_item_never_emits_a_none_anchor(self) -> None: + content = b"\n".join( + [ + b"BT /F1 18 Tf 1 0 0 1 72 720 Tm (Alpha Heading) Tj ET", + b"BT /F1 18 Tf 1 0 0 1 72 650 Tm (Bravo Heading) Tj ET", + ] + ) + data = one_page_pdf( + content, + catalog_extra=b"/Outlines 6 0 R", + extra_objects=[ + b"<< /Type /Outlines /First 7 0 R /Last 7 0 R /Count 1 >>", + b"<< /Title (Unrelated Destination) /Parent 6 0 R /Dest [3 0 R /XYZ null null null] >>", + ], + ) + result = convert(data) + item = semantic_nodes(result, "toc_item")[0] + self.assertIsNone(item.attrs["target_anchor"]) + self.assertIn("Unrelated Destination", result.markdown) + self.assertNotIn("#None", result.markdown) + self.assertNotIn('href="#None"', result.html) + def test_page_selection_keeps_outline_metadata_without_importing_full_toc(self) -> None: first = b"BT /F1 12 Tf 1 0 0 1 72 720 Tm (First body) Tj ET" second = b"BT /F1 12 Tf 1 0 0 1 72 720 Tm (Selected body) Tj ET" @@ -230,16 +405,25 @@ def test_page_selection_prunes_other_widgets_of_a_shared_field(self) -> None: b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 1 0 R >> >> /Contents 3 0 R /Annots [9 0 R] >>", b"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 >>", b"<< /Fields [10 0 R] >>", - b"<< /Type /Annot /Subtype /Widget /Parent 10 0 R /Rect [72 650 240 675] /P 4 0 R >>", - b"<< /Type /Annot /Subtype /Widget /Parent 10 0 R /Rect [72 650 240 675] /P 5 0 R >>", + b"<< /Type /Annot /Subtype /Widget /Parent 10 0 R /Rect [72 650 240 675] /P 4 0 R /DA (/F1 9 Tf 0 g) >>", + b"<< /Type /Annot /Subtype /Widget /Parent 10 0 R /Rect [72 650 240 675] /P 5 0 R /DA (/F1 18 Tf 0 0 1 rg) >>", b"<< /FT /Tx /T (SharedField) /V (Gamma) /Kids [8 0 R 9 0 R] >>", b"<< /Type /Catalog /Pages 6 0 R /AcroForm 7 0 R >>", ] - result = convert(render_pdf(objects, 11), ConvertOptions(pages="2")) + data = render_pdf(objects, 11) + full_field = semantic_nodes(convert(data), "form_field")[0] + self.assertIsNone(full_field.attrs["appearance"]) + result = convert(data, ConvertOptions(pages="2")) field = semantic_nodes(result, "form_field")[0] self.assertEqual(field.source_pages(), [2]) self.assertEqual([widget["page"] for widget in field.attrs["widgets"]], [2]) self.assertEqual(field.sources[0].object_refs, ("9 0 R",)) + self.assertEqual(field.attrs["appearance"]["font_size_pt"], 18.0) + self.assertEqual(field.attrs["appearance"]["text_color_rgb"], [0.0, 0.0, 1.0]) + self.assertIn( + 'class="cocoapdf-form-field-value cocoapdf-form-field-value-evidenced"', + result.html, + ) self.assertIn("**SharedField:** Gamma", result.markdown) diff --git a/validation/pr_visual/README.md b/validation/pr_visual/README.md index 5364e61..b94509e 100644 --- a/validation/pr_visual/README.md +++ b/validation/pr_visual/README.md @@ -16,6 +16,13 @@ represented by the committed demo without writing generated files into it. outlines, page-bound AcroForm widgets, a dot-leader financial statement, and diagram boxes that must not be hallucinated as form controls. +These are deliberately not concatenated into one PDF. `StructTreeRoot` and its +parent tree, AcroForm, and outlines are catalog-scoped object graphs, not +page-local decorations. A page append would either discard those semantics or +create a partially tagged hybrid whose evidence no longer matches the isolated +test. The permanent demo presents one corpus, while retaining three inputs is +what keeps each failure attributable and the source/output comparison honest. + For each input, the runner emits CocoaPDF Markdown, HTML, semantic JSON, report JSON, extracted assets, hashes, and explicit contract results. The artifact also contains `review.html` for side-by-side input/output inspection and @@ -42,8 +49,10 @@ permanent `examples/` demo. Artifacts are retained for at most seven days and are deleted when the pull request closes or merges. For same-repository pull requests, the workflow maintains one delimited block -in the PR description containing the current artifact link and digest. When -the PR closes, the workflow replaces that block with an explicit deletion +in the PR description containing the current artifact link and digest. While +the PR is open, the same block includes an immutable rendered-demo URL pinned +to the exact head commit; the stable public demo remains explicitly on `main`. +When the PR closes, the workflow replaces that block with an explicit deletion notice, the reviewed head SHA, the cleanup run, and a link to the permanent demo. A merged PR therefore never retains a live-looking link to a deleted artifact. Fork and automation-token restrictions may prevent description diff --git a/validation/pr_visual/build.py b/validation/pr_visual/build.py index 1fb6683..9df0e33 100644 --- a/validation/pr_visual/build.py +++ b/validation/pr_visual/build.py @@ -21,7 +21,7 @@ PULL_REQUEST_PROFILE = "pull-request" PERMANENT_PROFILE = "permanent" VALID_PROFILES = (PULL_REQUEST_PROFILE, PERMANENT_PROFILE) -RENDER_BASE_URL = "https://raw.githack.com/sayantandey/CocoaPDF/main/examples" +MAIN_RENDER_BASE_URL = "https://raw.githack.com/sayantandey/CocoaPDF/main/examples" def _source_imports() -> None: @@ -150,6 +150,16 @@ def _outline(x0: float, y0: float, x1: float, y1: float) -> List[bytes]: def build_scope_and_adversarial_pdf() -> bytes: """Build page-scope semantics plus a diagram/form near-miss.""" + second_field_appearance = b"\n".join( + [ + b"q", + b"0.91 0.93 0.98 rg 0 0 160 25 re f", + b"0.65 0.70 0.78 RG 1 w 0.5 0.5 159 24 re S", + b"0.18 0.25 0.42 rg", + b"BT /F1 18 Tf 1 0 0 1 4 5 Tm (Beta) Tj ET", + b"Q", + ] + ) page_one = b"\n".join( [ text_op(72, 720, "Page Scope Review", "F2", 18), @@ -206,7 +216,10 @@ def build_scope_and_adversarial_pdf() -> bytes: b"/Contents 4 0 R /Annots [11 0 R] >>" ), b"<< /Type /Pages /Kids [5 0 R 6 0 R] /Count 2 >>", - b"<< /Fields [10 0 R 11 0 R] /NeedAppearances false >>", + ( + b"<< /Fields [10 0 R 11 0 R] /NeedAppearances false " + b"/DR << /Font << /F1 1 0 R >> >> >>" + ), b"<< /Type /Outlines /First 12 0 R /Last 13 0 R /Count 2 >>", ( b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (FirstField) " @@ -214,7 +227,10 @@ def build_scope_and_adversarial_pdf() -> bytes: ), ( b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (SecondField) " - b"/V (Beta) /Rect [200 535 360 560] /P 6 0 R >>" + b"/V (Beta) /Rect [200 535 360 560] /P 6 0 R " + b"/DA (/F1 18 Tf 0.18 0.25 0.42 rg) /Q 0 " + b"/MK << /BG [0.91 0.93 0.98] /BC [0.65 0.70 0.78] >> " + b"/BS << /W 1 /S /S >> /AP << /N 15 0 R >> >>" ), ( b"<< /Title (First Page) /Parent 9 0 R /Next 13 0 R " @@ -228,6 +244,14 @@ def build_scope_and_adversarial_pdf() -> bytes: b"<< /Type /Catalog /Pages 7 0 R /AcroForm 8 0 R " b"/Outlines 9 0 R >>" ), + ( + b"<< /Type /XObject /Subtype /Form /FormType 1 " + b"/BBox [0 0 160 25] " + b"/Resources << /Font << /F1 1 0 R >> >> " + b"/Length %d >>\nstream\n" % len(second_field_appearance) + + second_field_appearance + + b"\nendstream" + ), ] return render_pdf(objects, 14) @@ -368,6 +392,206 @@ def _common_contract(output_dir: Path, results: List[Dict[str, Any]]) -> Tuple[s return markdown, semantic, report +def _semantic_nodes( + semantic: Dict[str, Any], + kind: str, +) -> List[Dict[str, Any]]: + found: List[Dict[str, Any]] = [] + stack: List[Any] = list(reversed(semantic.get("children", []))) + while stack: + node = stack.pop() + if not isinstance(node, dict): + continue + if node.get("kind") == kind: + found.append(node) + children = node.get("children") + if isinstance(children, list): + stack.extend(reversed(children)) + return found + + +def _verify_scope_diagram( + output_dir: Path, + markdown: str, + semantic: Dict[str, Any], + report: Dict[str, Any], + results: List[Dict[str, Any]], +) -> None: + """Verify meaning and visible geometry without locking an asset hash.""" + html_output = (output_dir / "output.html").read_text(encoding="utf-8") + vector_assets = sorted((output_dir / "assets").glob("vector-*.svg")) + _require( + len(vector_assets) == 1, + "five outlined diagram panels are retained as one vector figure", + results, + ) + if len(vector_assets) != 1: + return + asset = vector_assets[0] + svg = asset.read_text(encoding="utf-8") + labels = ("Parse", "Decode", "Layout", "Reconcile", "Render") + _require( + all(label in svg for label in labels), + "all native diagram labels remain inside the vector figure", + results, + ) + _require( + svg.count("= 16, + "the SVG retains the outlined-panel stroke geometry", + results, + ) + _require( + not re.search( + r"(?m)^(?:Parse\s+Layout\s+Render|Decode\s+Reconcile)\s*$", + markdown, + ), + "diagram labels are not flattened into false prose lines", + results, + ) + _require( + asset.name in markdown and asset.name in html_output, + "Markdown and HTML both reference the generated diagram asset", + results, + ) + vector_nodes = [ + node + for node in _semantic_nodes(semantic, "image") + if isinstance(node.get("attrs"), dict) + and node["attrs"].get("kind") == "vector" + ] + _require( + len(vector_nodes) == 1, + "semantic JSON records one vector image node", + results, + ) + if vector_nodes: + node = vector_nodes[0] + attrs = node.get("attrs") if isinstance(node.get("attrs"), dict) else {} + evidence = node.get("evidence") if isinstance(node.get("evidence"), list) else [] + _require( + node.get("source_pages") == [2], + "the vector figure retains page-2 provenance", + results, + ) + sources = node.get("sources") if isinstance(node.get("sources"), list) else [] + _require( + bool(sources) + and isinstance(sources[0], dict) + and bool(sources[0].get("glyph_ids")), + "the vector figure remains traceable to its source glyphs", + results, + ) + _require( + all(label in str(attrs.get("alt", "")) for label in labels), + "diagram labels provide deterministic alternative text", + results, + ) + _require( + any( + isinstance(item, dict) + and item.get("kind") == "pdf_vector_artwork" + for item in evidence + ), + "the vector figure records PDF-native artwork evidence", + results, + ) + image_details = [ + item + for item in report.get("images_detail", []) + if isinstance(item, dict) and item.get("kind") == "vector" + ] + _require( + len(image_details) == 1 + and float(image_details[0].get("placed_width", 0.0)) > 450.0 + and float(image_details[0].get("placed_height", 0.0)) > 40.0, + "report geometry shows one page-spanning, multi-row diagram", + results, + ) + _require( + report.get("image_text_extraction_attempted") is False, + "diagram preservation does not invoke image-text extraction", + results, + ) + + +def _verify_second_field_appearance( + output_dir: Path, + semantic: Dict[str, Any], + results: List[Dict[str, Any]], +) -> None: + """Check evidence classes and broad visual intent, not serializer trivia.""" + fields = [ + node + for node in _semantic_nodes(semantic, "form_field") + if isinstance(node.get("attrs"), dict) + and node["attrs"].get("name") == "SecondField" + ] + _require( + len(fields) == 1, + "SecondField has one page-scoped semantic field", + results, + ) + if len(fields) != 1: + return + appearance = fields[0]["attrs"].get("appearance") + _require( + isinstance(appearance, dict), + "SecondField retains explicit PDF widget appearance evidence", + results, + ) + if not isinstance(appearance, dict): + return + font_size = float(appearance.get("font_size_pt", 0.0) or 0.0) + text_color = appearance.get("text_color_rgb") + background = appearance.get("background_color_rgb") + sources = set(appearance.get("sources") or []) + _require( + 16.0 <= font_size <= 22.0, + "SecondField preserves its explicitly large text size", + results, + ) + _require( + isinstance(text_color, list) + and len(text_color) == 3 + and float(text_color[2]) > float(text_color[1]) > float(text_color[0]), + "SecondField preserves its dark blue-gray text color", + results, + ) + _require( + isinstance(background, list) + and len(background) == 3 + and all(0.85 <= float(component) <= 1.0 for component in background), + "SecondField preserves its pale blue-gray background", + results, + ) + _require( + { + "default_appearance", + "appearance_characteristics", + "normal_appearance_stream", + "widget_rect", + }.issubset(sources), + "SecondField appearance remains traceable to independent PDF-native evidence", + results, + ) + html_output = (output_dir / "output.html").read_text(encoding="utf-8") + _require( + html_output.count( + 'class="cocoapdf-form-field-value cocoapdf-form-field-value-evidenced"' + ) == 1 + and 'data-name="SecondField"' in html_output + and "background-color: rgb(" in html_output + and "font-size: " in html_output, + "HTML renders only the evidenced field with its size and colors", + results, + ) + _require( + " List[Dict[str, Any]]: results: List[Dict[str, Any]] = [] markdown, _semantic, _report = _common_contract(output_dir, results) @@ -416,6 +640,8 @@ def verify_scope_selected(output_dir: Path) -> List[Dict[str, Any]]: _require('2024' in markdown, "financial year columns are structural", results) _require('Net income' in markdown, "financial row labels are structural", results) _require("................................" not in markdown, "dot leaders are removed from table output", results) + _verify_scope_diagram(output_dir, markdown, semantic, report, results) + _verify_second_field_appearance(output_dir, semantic, results) metadata = semantic.get("metadata") if isinstance(semantic.get("metadata"), dict) else {} _require(metadata.get("processed_pages") == [2], "semantic metadata records page 2 only", results) warning_codes = { @@ -427,6 +653,39 @@ def verify_scope_selected(output_dir: Path) -> List[Dict[str, Any]]: return results +def verify_scope_full(output_dir: Path) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + markdown, semantic, report = _common_contract(output_dir, results) + _require( + "[First Page](#page-scope-review)" in markdown + and "[Selected Evidence](#selected-evidence-page)" in markdown, + "page-only outline destinations resolve to unique page headings", + results, + ) + _require( + "#None" not in markdown and 'href="#None"' not in ( + output_dir / "output.html" + ).read_text(encoding="utf-8"), + "unresolved navigation never emits a literal None anchor", + results, + ) + _require( + "**FirstField:** Alpha" in markdown + and "**SecondField:** Beta" in markdown, + "both full-document AcroForm fields are retained", + results, + ) + _require( + '2024' in markdown + and 'Net income' in markdown, + "the finance grid remains a structural table", + results, + ) + _verify_scope_diagram(output_dir, markdown, semantic, report, results) + _verify_second_field_appearance(output_dir, semantic, results) + return results + + def _write_assertions(path: Path, assertions: Sequence[Dict[str, Any]]) -> None: write_utf8_lf( path, @@ -481,6 +740,10 @@ def _write_review_files( "All inputs and fixture prose are first-party project material under the bundled MIT license.", "No network content, OCR, AI, or ML was used.", "", + "The three PDFs are intentionally isolated: Tagged-PDF structure trees, AcroForm fields, " + "and outlines are document-catalog semantics. Concatenating their pages would alter the " + "evidence being tested and make failures less diagnostic.", + "", ] if permanent: lines.extend( @@ -488,12 +751,15 @@ def _write_review_files( "This directory is the committed, reproducible capability demo. " "Pull-request review artifacts are generated separately and are never written here.", "", - "[Open the rendered side-by-side demo](%s/review.html). GitHub displays " + "[Open the rendered main-branch side-by-side demo](%s/review.html). GitHub displays " "committed HTML files as source code; this third-party browser preview " - "renders the same files from `main` without a project website." - % RENDER_BASE_URL, + "renders the same files from `main` without a project website. Same-repository " + "pull requests receive a separate commit-pinned rendered link in their description." + % MAIN_RENDER_BASE_URL, "", - "Each row links the source PDF to the exact output committed from the same CocoaPDF revision.", + "Relative row links resolve against the revision being viewed. External rendered-HTML " + "links are explicitly labeled as `main`; a same-repository PR description supplies " + "the exact commit-pinned rendered demo.", "", "Full semantic JSON is committed. Report summaries omit only duplicate semantic graphs " "and glyph-heavy internals; the temporary PR artifact retains every full report.", @@ -521,17 +787,22 @@ def _write_review_files( else "output.report.json" ) html_link = ( - "%s/%s/output.html" % (RENDER_BASE_URL, base) + "%s/%s/output.html" % (MAIN_RENDER_BASE_URL, base) if permanent else "%s/output.html" % base ) + html_label = ( + "%s rendered HTML on main" % name + if permanent + else "%s HTML" % name + ) output_links.append( - "[%s Markdown](%s/output.md), [%s rendered HTML](%s), " + "[%s Markdown](%s/output.md), [%s](%s), " "[%s semantic JSON](%s/output.json), [%s report](%s/%s)" % ( name, base, - name, + html_label, html_link, name, base, @@ -603,6 +874,8 @@ def _write_review_files(

__COCOAPDF_REVIEW_TITLE__

Left: source PDF. Right: CocoaPDF HTML. See __COCOAPDF_INDEX_NAME__ and manifest.json for every output and hash.

+

The three source PDFs remain separate because Tagged-PDF trees, AcroForm fields, and outlines + are document-catalog semantics; concatenating pages would change the evidence under review.

__COCOAPDF_REVIEW_SECTIONS__ @@ -639,6 +912,10 @@ def build_bundle( (output_root / "LICENSE.txt").write_bytes(LICENSE_PATH.read_bytes()) inputs = generate_inputs() + # Keep these PDF-native microfixtures separate. StructTreeRoot/ParentTree, + # AcroForm, and Outlines are catalog-scoped semantics, not page-local + # decorations; appending their pages to one PDF would create a partially + # tagged hybrid and weaken the isolation that makes each failure diagnostic. case_specs = [ { "id": "strategic_corner_cases", @@ -669,7 +946,7 @@ def build_bundle( }, { "id": "scope_and_adversarial", - "description": "Page-range outline/AcroForm scope, dot-leader finance recovery, and diagram-versus-form false-positive resistance.", + "description": "Page-range outline/AcroForm scope, valid heading anchors, dot-leader finance recovery, and two-sided diagram-versus-form/table fidelity.", "input": inputs["scope_and_adversarial"], "source": None, "provenance": { @@ -680,7 +957,7 @@ def build_bundle( "pdf_standard_fonts": ["Helvetica", "Helvetica-Bold"], }, "conversions": [ - ("full", None, None), + ("full", None, verify_scope_full), ("page-2", "2", verify_scope_selected), ], }, @@ -749,6 +1026,19 @@ def build_bundle( "network_fetches": 0, "third_party_content_added": False, }, + "fixture_isolation": { + "combined_pdf": False, + "catalog_scoped_features": [ + "StructTreeRoot", + "ParentTree", + "AcroForm", + "Outlines", + ], + "reason": ( + "Page concatenation would alter document-global evidence and " + "weaken failure attribution." + ), + }, "lifecycle": ( { "committed_outputs": True,