From 8e1b3ba373bff0f6239ab483e8b88495ff9dc785 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Sat, 10 Oct 2026 19:19:23 -0400 Subject: [PATCH 1/4] Preserve semantic PDF text and bounded recovery evidence --- README.md | 12 +- benchmark/eval/README.md | 11 +- build.zig | 22 + docs/host-adapters.md | 60 +- docs/output-schema.md | 115 +- docs/parser-host-implementation-plan.md | 115 +- integrations/javascript/extract.d.mts | 28 + integrations/javascript/extract.mjs | 91 +- integrations/javascript/extract.test.mjs | 144 ++ integrations/javascript/fidelity.test.mjs | 169 +++ integrations/javascript/integration.test.mjs | 136 +- integrations/javascript/ocr-options.mjs | 15 + integrations/javascript/stream.d.mts | 4 +- integrations/javascript/stream.mjs | 8 +- integrations/javascript/stream.test.mjs | 9 + src/adaptive.zig | 497 ++++-- src/complexity.zig | 17 +- src/coverage.zig | 73 +- src/encoding.zig | 64 +- src/form_scope.zig | 74 + src/integration_test.zig | 62 +- src/interpreter.zig | 354 ++++- src/layout.zig | 82 + src/main.zig | 21 +- src/native_layout.zig | 455 +++++- src/ocr.zig | 2 + src/ocr/crop.zig | 128 ++ src/ocr/types.zig | 13 + src/paint.zig | 140 ++ src/reconcile.zig | 77 +- src/root.zig | 1427 +++++++++++++++--- src/schema.zig | 27 +- src/specialist_protocol.zig | 216 ++- src/stream.zig | 2 + src/structtree.zig | 85 +- src/visual_assets.zig | 2 + 36 files changed, 4243 insertions(+), 514 deletions(-) create mode 100644 integrations/javascript/fidelity.test.mjs create mode 100644 integrations/javascript/ocr-options.mjs create mode 100644 src/form_scope.zig create mode 100644 src/ocr/crop.zig create mode 100644 src/paint.zig diff --git a/README.md b/README.md index 5ce7b1a..30b5cb3 100644 --- a/README.md +++ b/README.md @@ -669,7 +669,7 @@ and ruling lines, invokes OCR only for scanned routes, then reconciles native, OCR, table, formula, and form spans with typed provenance. The versioned JSON, artifact JSONL, and streaming JSONL schema is currently -`0.15.0`, with parser build identity `0.4.0-dev` after the `v0.3.0` release. +`0.17.0`, with parser build identity `0.4.0-dev` after the `v0.3.0` release. Every emitted record carries a `provenance` envelope with document and source identity, input hash context, artifact id, page/bbox, source kind, confidence, related span/block/chunk ids, route trace ids, and route reasons. @@ -683,7 +683,15 @@ UTF-16 locators, overlap-aware chunk origins and separate prompt projections. Schema `0.15.0` adds reconciled span/table/cell ownership and page/region recovery assessments. Streaming supports output and adaptive page allocation budgets; the JavaScript page adapter validates lifecycle completion and applies -backpressure. Visual completeness remains unverified. +backpressure. Schema `0.16.0` retains rendering-mode and rectangular-clipping +evidence on native spans and adds bounded, page/region-scoped OCR crops with +accepted PNG hashes. Crops preserve native text on refusal and expose unresolved +quota skips. Visual completeness remains unverified; paint evidence describes +operators and span bounds rather than final pixel visibility. +Schema `0.17.0` adds content-stream and Form invocation scope to spans. +ActualText and classified text boundaries survive adaptive reconstruction; Form +text uses scoped resources and transformed geometry. Native and adaptive text +quality gates run against the same born-digital and hard-font floors. Host adoption remains explicit; accepted schema families and parser/compiler pins must be updated together. diff --git a/benchmark/eval/README.md b/benchmark/eval/README.md index b71a203..72dc140 100644 --- a/benchmark/eval/README.md +++ b/benchmark/eval/README.md @@ -582,8 +582,15 @@ pdf-parser benchmark \ ``` Checked-in homogeneous gates live under `benchmark/eval/gates/`. CI runs the -born-digital and hard-font gates alongside the broad tiny-corpus scorecard; -table, OCR, render, and encrypted-twin checks retain their specialist +born-digital and hard-font gates in both `pdf-parser:native` and +`pdf-parser:adaptive` lanes alongside the broad tiny-corpus scorecard. Both lanes +use the same absolute text floors; successful record parsing is a separate check. +The JavaScript fidelity tests additionally verify batch/stream artifacts, +semantic source text, chunks and locators for ActualText, writing boundaries and +resource-scoped Forms. Run `node --test integrations/javascript/*.test.mjs` after +building the CLI; skipped real-CLI tests do not qualify this boundary. + +Table, OCR, render, and encrypted-twin checks retain their specialist comparators because their success contracts include structure, routing, pixels, or twin equivalence rather than text metrics alone. diff --git a/build.zig b/build.zig index 16d30a5..0de659e 100644 --- a/build.zig +++ b/build.zig @@ -437,6 +437,28 @@ pub fn build(b: *std.Build) void { }); benchmark_eval_step.dependOn(&benchmark_font_quality_cmd.step); + // Native decoding and adaptive reconstruction are separate quality lanes. + // Both must satisfy the same text floors before public artifacts qualify. + const benchmark_adaptive_quality_cmd = b.addRunArtifact(exe); + benchmark_adaptive_quality_cmd.step.dependOn(b.getInstallStep()); + benchmark_adaptive_quality_cmd.addArgs(&.{ + "benchmark", "--manifest", "benchmark/eval/gates/born-digital.tsv", + "--suite-id", "adaptive-born-digital-gate", "--tools", + "pdf-parser:adaptive", "--thresholds", "benchmark/eval/gates/born-digital-thresholds.json", + "--fail-on-regression", "--fail-on-skipped", + }); + benchmark_eval_step.dependOn(&benchmark_adaptive_quality_cmd.step); + + const benchmark_adaptive_font_quality_cmd = b.addRunArtifact(exe); + benchmark_adaptive_font_quality_cmd.step.dependOn(b.getInstallStep()); + benchmark_adaptive_font_quality_cmd.addArgs(&.{ + "benchmark", "--manifest", "benchmark/eval/gates/hard-fonts.tsv", + "--suite-id", "adaptive-hard-font-gate", "--tools", + "pdf-parser:adaptive", "--thresholds", "benchmark/eval/gates/hard-fonts-thresholds.json", + "--fail-on-regression", "--fail-on-skipped", + }); + benchmark_eval_step.dependOn(&benchmark_adaptive_font_quality_cmd.step); + const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_lib_unit_tests.step); test_step.dependOn(&run_simd_unit_tests.step); diff --git a/docs/host-adapters.md b/docs/host-adapters.md index a185eea..62cc14a 100644 --- a/docs/host-adapters.md +++ b/docs/host-adapters.md @@ -74,7 +74,31 @@ asynchronous I/O, supplies the input SHA-256 as source identity, uses a private 0600 temporary PDF and removes scratch files on completion/failure. Default limits are 45 seconds and 25 MiB. OCR is disabled by default; `enableOcr:true` uses already-configured local OCR tools. Cancellation, timeout, -oversized output and process failures remain explicit. +oversized output and process failures remain explicit. Specialist failure rejects +the result by default; `allowPartial:true` explicitly permits retaining partial +artifacts and unresolved recovery assessments. + +Both batch and streaming options accept positive integer OCR budgets: + +```js +const options = { + binaryPath, + enableOcr: true, + allowPartial: true, + ocrLimits: { + maxImagePixels: 40_000_000, + maxImageBytes: 32 * 1024 * 1024, + maxRegionsPerPage: 8, + maxAttemptsPerPage: 16, + }, +}; +``` + +These forward `--ocr-max-image-pixels`, `--ocr-max-image-bytes`, +`--ocr-max-regions` and `--ocr-max-attempts`. Caps bound accepted rasters and +per-page invocation counts; child RSS and temporary disk growth are outside +these budgets. Crop records retain actual PDF-space bounds, accepted PNG SHA-256 +and byte length. Rotation is explicitly unsupported for OCR crops. The result retains the manifest, raw JSONL, all protocol records, spans, blocks and parser chunks. Its `sourceText` is exact block text in artifact order joined @@ -105,7 +129,7 @@ text hash/view and the record ids; do not resolve them against another run by phrase alone. `parseArtifactJsonl(rawJsonl, {inputSha256, expectedSourceId})` validates manifest-first batch -ordering, schema `0.14.x`/`0.15.x`, input/provenance identity, text hashes, range boundaries, +ordering, schema `0.14.x`/`0.15.x`/`0.16.x`/`0.17.x`, input/provenance identity, text hashes, range boundaries, full source-map coverage, referenced artifact sets and page relationships. It rejects truncated/malformed streams, forged synthetic text and mismatched source views. This is a batch reader; use `extractPdfPages` for lifecycle streams. @@ -115,7 +139,7 @@ views. This is a batch reader; use `extractPdfPages` for lifecycle streams. extraction adapter always binds it to the input hash supplied to the CLI. For `0.14.x` only, chunks can carry an internal label when manifest/provenance source identity is null. The reader retains that label as metadata; it cannot -override manifest, input hash or provenance identity. Current `0.15.x` records +override manifest, input hash or provenance identity. Current `0.15.x`–`0.17.x` records must agree with the manifest, including null when no source id was supplied. Specialist records retain their legacy shared provenance labels; uniqueness is @@ -123,7 +147,7 @@ checked with their actual request/attempt/response/result ids. Some current tabl provenance block references describe pre-reconciliation indices. The adapter preserves those raw values and reports missing references as `unresolvedProvenanceLinks`, rather than using them for source-text locators. -Core span/block/chunk maps remain strict. In schema `0.15.x`, `document.tables` +Core span/block/chunk maps remain strict. In schema `0.15.x`–`0.17.x`, `document.tables` exposes table/cell `reconciled_block_ids` checked against owning source spans. Span `reconciled_block_id` and ownership provenance must agree with block maps; cells must share their table's page. Use these authoritative fields for links. @@ -148,8 +172,24 @@ Schema `0.15.x` adds `document.pageOutcomes` and page/region route `coverage`. These distinguish retained text, empty unverified output, required/failed recovery and recovered regions. The reader rejects out-of-document pages and contradictory recovery states. Preserve unresolved assessments in intake and -retrieval policy. Paint/clipping evidence and regional OCR crops remain pending; -these assessments do not establish quote verification or publishing authority. +retrieval policy. Schema `0.16.x` adds span `paint`, three paint counters and +page/region-scoped OCR crop evidence. The reader rejects inconsistent selected +attempts, crop budgets and specialist context links. Paint describes operators +and bbox clipping, with unknown states for unqualified cases; it does not prove +pixel visibility. These assessments do not establish quote verification or +publishing authority. + +Schema `0.17.x` adds nullable span `content_scope`. A known scope carries its +page, PDF stream object reference and per-page Form invocation index. Repeated +Form placements remain distinguishable even when they reuse local font names or +MCIDs. The adapter rejects out-of-page or malformed scopes and inconsistent +specialist context. A locator's `sourceSpanId` resolves to the retained span and +its scope; null scope explicitly withholds that attribution. + +ActualText replacements are preserved once per scope in `sourceText` and parser +chunks. Unequal glyph/character counts retain span-bbox precision; no exact +character geometry is inferred from a semantic replacement. Explicit/inferred +separators survive reconstruction, with synthetic source-map gaps retained. ## Incremental pages and resource budgets @@ -191,8 +231,8 @@ as `memory_limit`/`output_limit`, without a complete receipt. ## Adoption and verification Readers limited to `0.11.x`/`0.13.x` must add -`0.15.x` with contract tests before changing their parser pin. The adapter retains -`0.14.x` reader support. Applications must pair +`0.17.x` with contract tests before changing their parser pin. The adapter retains +`0.14.x`, `0.15.x` and `0.16.x` reader support. Applications must pair their adopted parser revision with the selected Zig 0.17/compiler/archive identities and run its baseline CPU/runtime smoke lane. Formula children must echo the new request schema version. Neither host is changed by this work. @@ -211,3 +251,7 @@ It reports a skip when no built executable exists; that skip does not qualify the integration. Unit tests cover Unicode byte/unit translation, repeated phrases, zero/large overlap, forged mappings, process limits, cancellation and scratch cleanup. No host database, browser, deployment or paid model is exercised. +The fidelity regressions also exercise ActualText, Unicode, font/paint changes, +rotated word boundaries, Form resource collisions and repeated placements through +batch/stream source text and locators. Forms are tested with external text tools +both present and unavailable. diff --git a/docs/output-schema.md b/docs/output-schema.md index 857fe46..df712fb 100644 --- a/docs/output-schema.md +++ b/docs/output-schema.md @@ -6,10 +6,10 @@ should depend on the records documented here. ## Version -Current schema version: `0.15.0` +Current schema version: `0.17.0` Current parser build identity: `0.4.0-dev`. The `v0.3.0` release emitted schema -`0.11.0`; the development suffix keeps post-release schema `0.15.0` artifacts +`0.11.0`; the development suffix keeps post-release schema `0.17.0` artifacts distinguishable until the next release is tagged. The project is still pre-`1.0.0`, so incompatible schema changes may happen. @@ -34,6 +34,18 @@ JSON output is a `document_manifest` object with arrays of typed records. but records are produced page by page so host applications can persist partial artifacts and enqueue embeddings before the document finishes. +Schema `0.17.0` adds nullable `content_scope` to spans and inline specialist span +context. It identifies native page content or a particular Form invocation. +Semantic replacements and inferred text boundaries survive adaptive +reconstruction; the existing text-view and source-map contract remains in place. +Readers must explicitly accept `0.17.x`; formula children must echo it. + +Schema `0.16.0` adds span paint evidence, coverage paint counts, and bounded +regional OCR attempts with accepted crop identity and execution budgets. +Specialist span/block context uses document-global IDs in both batch and stream +output. Readers must explicitly accept `0.16.x`; formula children must echo it. +These additions retain extracted text and keep visual completeness unverified. + Schema `0.15.0` adds authoritative reconciled block references to spans, tables and cells, and page/region recovery assessments to route traces. Span provenance block references now resolve to actual reconciled owners, including later pages @@ -253,6 +265,38 @@ failures from recognize failures. successful candidate is selected deterministically by outcome, confidence, coverage, character count, then lower DPI and attempt index. +In `0.16`, OCR attempts have `page_index` and nullable `region_index`. IDs use +`specialist-attempt-ocr-page-P-I` for whole pages and +`specialist-attempt-ocr-page-P-region-R-I` for crops; response/result IDs use the +same page/region scope. Selection, counts and recovery bind to that exact scope. +A response describes the last attempt; its selected winner may be an earlier +completed attempt after a failed retry. Completed results require a completed +selected OCR attempt. +Compatibility OCR responses summarize timeout/invalid output as `failed`; +the attempt retains the detailed `timeout`/`invalid_output` status and diagnostics. + +OCR `config` also records `max_image_pixels`, `max_image_bytes`, +`max_regions_per_page` and `max_attempts_per_page`. Defaults are 40,000,000 +pixels, 33,554,432 bytes, 8 regions and 16 attempts per page. Whole-page attempts +have `crop:null`; regional attempts carry `crop` with `bbox`, `dpi`, +`pixel_width`, `pixel_height`, nullable `image_sha256` and `image_byte_length`. +The hash and length identify the accepted PNG bytes delivered to recognition. +They stay null when rasterization is refused or fails. The bbox is the actual +pixel-aligned crop in PDF user space: padded by two points, clamped to MediaBox, +then rounded outward using the nonzero page origin. Ephemeral PNG paths are +deleted after each attempt and never exposed as public crop identity. +Whole-page OCR preserves its established MediaBox coordinate mapping; crop +rounding does not rescale whole-page recognition geometry. + +Good native pages route image placements individually; pages failing native +quality use whole-page recovery. Native text survives failed recovery. Quota +skips retain requested routes and `not_invoked` attempts with `ocr_region_limit` +or `ocr_attempt_limit`. Pixel/byte refusal, invalid geometry and rotation have +explicit diagnostics. Rotated OCR pages are refused until their raster mapping +is qualified. Pixel bounds apply before launch and to accepted PNG dimensions; +byte bounds apply before reading the PNG. These are not child RSS or in-flight +temporary-file disk caps. + ### `specialist_response` Response record from a specialist boundary. Existing Tesseract OCR output is @@ -370,8 +414,71 @@ page; retained native text does not prove that image regions were recognized. Selected OCR/formula attempts count as recovery only when retained recognized spans intersect the assessed region. Successful OCR cannot hide unresolved formula or disjoint image regions. Lifecycle `completed` means extraction -finished, not that the source has complete visual coverage. Paint/clipping -evidence and bounded regional OCR crops remain future work. +finished, not that the source has complete visual coverage. + +### Paint and clipping evidence (0.16) + +Spans and inline specialist span context include `paint`: + +```json +{"rendering_mode":0,"paint_state":"painting","clip_state":"inside","clip_bbox":{"x0":0,"y0":0,"x1":300,"y1":300}} +``` + +`rendering_mode` is 0–7 or null. `paint_state` is `painting`, `non_painting` or +`unknown`; modes 3 and 7 are nonpainting. Type 3 fonts retain unknown paint +state for the other modes. `clip_state` is `unclipped`, `inside`, `outside`, +`partial` or `unknown`. Known rectangular clips carry their PDF-space bbox. +Span geometry follows the current affine transform, with graphics state restored +by q/Q. Text clipping takes effect after the text object; arbitrary paths, +multiple subpaths and uncertain transforms retain unknown clip evidence. + +This is operator and bbox evidence, not final pixel visibility. It does not +qualify opacity, transparency, glyph outlines, recursive Form bounds or the +initial page clip. Nonpainting/clipped text is retained as native source text. +Page/region `coverage` adds `non_painting_span_count`, `clipped_out_span_count` +and `uncertain_paint_span_count`; these counts never establish completeness. + +### Semantic text and content scope (0.17) + +`content_scope` is null when a single native scope is unknown or not retained by +a derived artifact. Native page content has the following scope: + +```json +{"page_index":0,"stream_object_num":null,"stream_generation":0,"invocation_index":0} +``` + +A Form scope carries its PDF object number/generation and a positive invocation +index assigned in traversal order on that page. Repeated placements of the same +Form share the object reference but have distinct invocation indices. Scope is +bound to the input identity and page; it does not establish logical reading order. +MCIDs are interpreted within their content stream, not as page-global identities. +Fallback/OCR and reconstructed table text may have null scope. Specialist span +context must agree with the corresponding public span's scope. + +ActualText replacement strings are authoritative semantic text. PDF text strings +decode UTF-16BE with a BOM or PDFDocEncoding, including extended punctuation, +accents, ligatures and Euro. Undefined PDFDocEncoding bytes become U+FFFD. +Named marked-content properties resolve through the current resource scope. +The native +geometry API retains original glyph evidence separately from the semantic view; +adaptive spans, blocks, chunks and text views consume the semantic view. Each +replacement is emitted once over its marked-content scope, including across font, +paint and text-operator changes. Empty replacement strings suppress that scope's +raw text. A differing glyph/character count uses union geometry, not fabricated +per-character placement; conflicting paint or content-scope evidence stays unknown. + +For conflicting nested ActualText declarations, the parser uses an explicit +outer-owner policy: the enclosing replacement owns its entire lexical scope. +This is a deterministic parser policy, not a claim of agreement among external +extractors. Source locators retain span-bbox precision. Search results for an +aggregate semantic replacement may have null raw glyph indices while retaining +the union bbox. + +Form traversal shares resource resolution across extraction paths, composes the +Form matrix, and retains Form-bbox clip evidence conservatively. Local font +resources are scoped independently of page font names. Cycles, nesting beyond ten +Forms and more than 10,000 Form invocations per page fail explicitly. These are +traversal bounds, not whole-process memory or visual-completeness guarantees. ### Streaming resource budgets diff --git a/docs/parser-host-implementation-plan.md b/docs/parser-host-implementation-plan.md index dcfed56..e4138c7 100644 --- a/docs/parser-host-implementation-plan.md +++ b/docs/parser-host-implementation-plan.md @@ -1,6 +1,6 @@ # Parser, Research consumer, and Editorial consumer implementation plan -Status: phases 0/1 implemented and locally verified; reconciled table links, bounded streaming and recovery assessments implemented in pdf-parser, host adoption pending, 2026-10-10. All current writes are restricted to pdf-parser: planned host edits below remain adoption instructions. Based on the [local reference review](local-reference-review-2026-10-09.md) and [host use-case review](host-usecase-reference-review-2026-10-10.md). Paint/clipping evidence, regional OCR crops, richer hierarchy/figures and host rollout remain planned. +Status: phases 0/1, reconciled table links, bounded streaming and recovery assessments landed in [PR #5](https://github.com/Tom-R-Main/pdf-parser/pull/5), merge commit `0dd534d`. Paint/clipping, bounded regional OCR and the text-fidelity continuation are implemented locally in schema 0.17; verification is recorded below. Host adoption remains pending, 2026-10-10. All writes are restricted to pdf-parser: planned host edits below remain adoption instructions. Based on the [local reference review](local-reference-review-2026-10-09.md) and [host use-case review](host-usecase-reference-review-2026-10-10.md). Richer hierarchy/figures and host rollout remain planned. ## Delivery order and ownership @@ -159,13 +159,106 @@ and covered by permanent regressions. The hash-bound [continuation receipt](../benchmark/eval/outputs/parser-capabilities-2026-10-10/receipt.json) retains the gates, corpus results, actual OCR outcome and limitations. -Next parser work is paint/clipping provenance and bounded regional OCR crops, -with outlined-lettering, invisible-text, clipped-text and mixed-region controls. -Host activation remains a separate adoption step with reader/persistence tests. -Table topology, cross-page continuation, hierarchy and figures need their own -holdouts; exact reconciled links alone do not qualify those capabilities. - -Inferred next priority from the financial fixture's three unresolved legacy -table/block references: implement exact reconciled table linkage with additive -fields, then bound streaming/retained memory, then expose page/region coverage. -These are recommendations; no subsequent phase was started by this review. +### Paint provenance and bounded crops continuation (0.16) + +Observed: PR #5 merged after all six CI checks passed on its final head. The +post-merge continuation adds operator paint evidence to native spans and +specialist context, with rendering modes, rectangular clipping and affine +graphics-state restoration. It retains hidden/clipped text, and reports unknown +evidence for unqualified paths rather than claiming visual completeness. + +Good native pages now OCR image placements individually, using pixel-aligned +PDF-space crops with accepted PNG SHA-256 and byte length. Native-quality failure +retains whole-page recovery. Pixel/byte limits and per-page region/attempt quotas +are public controls, and refusal leaves native text and unresolved targets. +Selected attempts bind to the exact page/region. Stream specialist references +now use the same global span/block IDs as public maps. Both neutral JavaScript +adapters expose crop budgets and an explicit partial-result policy. + +Independent review reproduced four issues: image transforms leaked across q/Q, +later-page specialist context named local IDs, readers accepted inconsistent +selected OCR attempts, and duplicate-route suppression erased independent +hidden-text needs. Each has a failing control and a remediation regression. +Allocation sweeps also exposed lexer string/array OOM text loss; those operations +now propagate failure and release partial buffers. + +The [continuation receipt](../benchmark/eval/outputs/paint-regional-ocr-2026-10-10/receipt.json) +binds the final source/binary identity to native gates, reader tests, actual +two-image/two-page OCR, quota skips, corpus results and remaining limits. This +continuation is locally checked; remote CI and a second merge are not established. + +Observed final checks: 169 JavaScript tests pass without skips; formatting, +strict declaration checks, native tests (62/62 steps), build (12/12), shared +(6/6) and WASM (5/5) gates pass. All 36 corpus PDFs pass batch and streaming +readers with OCR disabled; adaptive evaluation completes all 36 with local OCR. +Scanned-form, JBIG2 and JPX quality gates pass using the installed local tool +versions. The unchanged JBIG2 route-count gate exposed fractional whole-page +mapping drift, which was fixed without relaxing expectations. These are local +contract/fixture checks, not universal accuracy or production qualification. + +The next priority inferred at the end of that continuation was rotated raster mapping and Form/opacity +evidence, then add unexplained vector-region routing with held-out outlined-text +controls. Paint evidence describes operators and bboxes, not final pixels; +regional budgets do not cap child RSS or temporary disk during rasterization. +Host activation needs its own reader/persistence tests. Table topology, +cross-page continuation, hierarchy and figures need separate holdouts. + +### Adaptive text fidelity continuation (0.17) + +Fresh assessment moved semantic text fidelity ahead of additional recovery: +the checked-in ActualText fixture retained the wrong glyph text in adaptive +output, Form text depended on an external page-sized fallback, and horizontal +and rotated controls lost word boundaries in final chunks. + +ActualText now supplies a separate semantic glyph projection while raw glyph +evidence remains available. Replacement text is emitted once across operators, +fonts and paint changes; empty replacements suppress raw text. Unicode and +PDFDocEncoding controls cover non-BMP characters, punctuation and ligatures. +Named properties resolve through the active resources. Conflicting nested +replacements use the documented outer-owner policy. Replacement geometry is a +union bbox, and search does not invent per-character raw glyph indices. + +Form traversal now shares resource frames across recall, geometry, structured +extraction and image inventory. Local, indirect and inherited fonts, composed +matrices, conservative BBox clips and repeated placements retain their evidence. +Cycles, depth beyond ten Forms and more than 10,000 invocations per page fail +explicitly. Schema 0.17 adds page/stream/invocation scope to native spans and +specialist context; readers validate those identities. Earlier reader families +remain supported. Scoped MCIDs never substitute for duplicate-text checks. + +Word grouping uses local writing coordinates and font-space advances, preserves +explicit boundaries across reconstructed spans, and retains semantic replacement +text verbatim. Both native and adaptive born-digital/hard-font benchmark lanes +now block regressions at the same existing floors. Public CLI regressions cover +batch, stream, source text, chunks and locators, including Form extraction with +external tools absent. No evaluation threshold was lowered. + +Cross-module review found cold font-cache initialization and pending Form span +metadata lifetime defects. Both were fixed with regression tests and allocation +sweeps. Review used independent contexts on the inherited session model; it does +not establish model diversity. The +[text-fidelity receipt](../benchmark/eval/outputs/text-fidelity-2026-10-10/receipt.json) +binds the current source and binary to the final local checks. This continuation +is uncommitted; no new remote CI, merge, host adoption or deployment is claimed. + +Observed final local checks: 183 JavaScript tests pass without skips; native +tests pass all 62 steps, shared builds 6/6 and WASM 5/5. The combined benchmark +gate passes all 18 steps. All twelve hard-font documents pass exact text floors +in both native and adaptive lanes, and both born-digital controls pass in each +lane. All 36 corpus PDFs pass both public readers with OCR disabled; adaptive +evaluation completes all 36 with local OCR enabled. These results qualify the +exercised corpus and contracts, not universal extraction accuracy. + +Linux/aarch64 Memcheck passes checker qualification, 16 parser/ABI/counter +checks and six additional ActualText/Form controls, without suppressions. +Expected injected faults remain detected. This qualifies the diagnostic native +lane; Python and OCR memory lifetimes were excluded, and logical suballocation +boundaries inside live arenas remain outside its proof. + +Inferred next implementation unit: a shared forward/inverse transform contract +for page boxes, rotation, UserUnit and raster crops. Qualify quarter-turn pages, +nonzero origins, fractional edges and transformed Forms before enabling rotated +OCR. Then identify unexplained vector regions with new outlined-text controls and +negative cases for rules, highlights, borders and logos. Keep recovered text +separate from visual completeness. Table topology and reading order need new +held-out suites; the earlier reading-order holdout must not be retuned or promoted. diff --git a/integrations/javascript/extract.d.mts b/integrations/javascript/extract.d.mts index a0bef6b..137ac94 100644 --- a/integrations/javascript/extract.d.mts +++ b/integrations/javascript/extract.d.mts @@ -4,6 +4,12 @@ export class PdfExtractionError extends Error { cleanupError?: PdfExtractionError; constructor(code: ExtractionErrorCode, message: string, options?: ErrorOptions); } +export interface OcrLimits { + maxImagePixels?: number; + maxImageBytes?: number; + maxRegionsPerPage?: number; + maxAttemptsPerPage?: number; +} export interface ExtractionOptions { /** Host-owned executable selection. No environment or default path discovery. */ binaryPath: string; @@ -12,6 +18,9 @@ export interface ExtractionOptions { signal?: AbortSignal; /** Defaults to false. Requires host-installed OCR tools when enabled. */ enableOcr?: boolean; + ocrLimits?: OcrLimits; + /** Explicitly retain results with specialist failures; coverage stays unverified. */ + allowPartial?: boolean; } export interface TextView { text_view_id: string; @@ -60,6 +69,22 @@ export interface SpanRecord extends ArtifactRecord { bbox: PdfBBox; text: string; text_view: TextView; + /** 0.16 operator/bbox evidence; not final rendered visibility. */ + paint?: PaintEvidence; + /** 0.17 PDF content-stream and Form placement identity; null for derived evidence. */ + content_scope?: ContentScope | null; +} +export interface ContentScope { + page_index: number; + stream_object_num: number | null; + stream_generation: number; + invocation_index: number; +} +export interface PaintEvidence { + rendering_mode: number | null; + paint_state: 'painting' | 'non_painting' | 'unknown'; + clip_state: 'unclipped' | 'inside' | 'outside' | 'partial' | 'unknown'; + clip_bbox: PdfBBox | null; } export interface ByteSourceSegment { text_start_byte: number; @@ -153,6 +178,9 @@ export interface PageOutcome { requested_recovery?: boolean; native_span_count?: number; recognized_span_count?: number; + non_painting_span_count?: number; + clipped_out_span_count?: number; + uncertain_paint_span_count?: number; unresolved_region_count?: number; } export function utf8BoundaryMap(text: string): Map; diff --git a/integrations/javascript/extract.mjs b/integrations/javascript/extract.mjs index 8c005ca..db79a04 100644 --- a/integrations/javascript/extract.mjs +++ b/integrations/javascript/extract.mjs @@ -4,6 +4,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { terminateSubprocess } from './subprocess.mjs'; +import { ocrArguments } from './ocr-options.mjs'; export class PdfExtractionError extends Error { constructor(code, message, options) { @@ -93,7 +94,7 @@ export function parseArtifactJsonl(rawJsonl, { inputSha256, expectedSourceId } = }); const manifest = records[0]; if (!object(manifest) || manifest.record_type !== 'document_manifest') fail('Expected manifest first'); - if (!/^0\.(14|15)\.\d+$/.test(manifest.schema_version ?? '') || manifest.schema_name !== 'document_manifest' || + if (!/^0\.(14|15|16|17)\.\d+$/.test(manifest.schema_version ?? '') || manifest.schema_name !== 'document_manifest' || typeof manifest.document_id !== 'string' || !manifest.document_id || !integer(manifest.page_count) || manifest.input_sha256 !== inputSha256) fail('Unsupported schema or manifest input identity'); if (!(manifest.source_id === null || (typeof manifest.source_id === 'string' && manifest.source_id.length > 0)) || @@ -184,7 +185,7 @@ export function parseArtifactJsonl(rawJsonl, { inputSha256, expectedSourceId } = checkIdentitySet(chunk.provenance.chunk_ids, new Set([chunk.chunk_id]), 'chunk identity provenance'); } const tables = records.filter(record => record.record_type === 'table'); - if (manifest.schema_version.startsWith('0.15.')) { + if (!manifest.schema_version.startsWith('0.14.')) { const owners = new Map(); for (const block of blocks) for (const id of block.provenance.span_ids) { if (!owners.has(id)) owners.set(id, new Set()); @@ -220,13 +221,92 @@ export function parseArtifactJsonl(rawJsonl, { inputSha256, expectedSourceId } = } const pageOutcomes = records.filter(record => record.record_type === 'route_trace' && record.trace_kind === 'page_route') .map(record => ({ pageIndex: record.page_index, ...record.coverage })); - if (manifest.schema_version.startsWith('0.15.')) { + if (/^0\.(16|17)\./u.test(manifest.schema_version)) { + const validBox = box => object(box) && ['x0', 'y0', 'x1', 'y1'].every(key => Number.isFinite(box[key])) && box.x0 <= box.x1 && box.y0 <= box.y1; + for (const span of spans) { + const paint = span.paint; + if (!object(paint) || !(paint.rendering_mode === null || (integer(paint.rendering_mode) && paint.rendering_mode <= 7)) || + !['painting', 'non_painting', 'unknown'].includes(paint.paint_state) || !['unclipped', 'inside', 'outside', 'partial', 'unknown'].includes(paint.clip_state) || + !(paint.clip_bbox === null || validBox(paint.clip_bbox)) || + (paint.clip_state === 'unclipped' && paint.clip_bbox !== null) || + (['inside', 'outside', 'partial'].includes(paint.clip_state) && paint.clip_bbox === null) || + (paint.paint_state === 'painting' && (paint.rendering_mode === null || [3, 7].includes(paint.rendering_mode))) || + (paint.paint_state === 'non_painting' && ![3, 7].includes(paint.rendering_mode))) fail('Invalid paint evidence'); + if (manifest.schema_version.startsWith('0.17.')) { + const scope = span.content_scope; + if (!(scope === null || (object(scope) && scope.page_index === span.page_index && + integer(scope.stream_generation) && scope.stream_generation <= 65535 && integer(scope.invocation_index) && scope.invocation_index <= 0xffffffff && + (scope.stream_object_num === null ? scope.stream_generation === 0 && scope.invocation_index === 0 : + integer(scope.stream_object_num) && scope.stream_object_num > 0 && scope.stream_object_num <= 0xffffffff && scope.invocation_index > 0)))) fail('Invalid content scope'); + } + } + const requests = new Map(records.filter(record => record.record_type === 'specialist_request').map(record => [record.request_id, record])); + const attempts = new Map(records.filter(record => record.record_type === 'specialist_attempt').map(record => [record.attempt_id, record])); + const attemptStatuses = ['not_invoked', 'completed', 'empty', 'unavailable', 'failed', 'timeout', 'invalid_output']; + // Specialist context is a view of the same page's public artifacts. + for (const record of records.filter(record => record.record_type.startsWith('specialist_'))) { + for (const [field, items, sourceIndex, idField] of [['span_ids', 'spans', spanIndex, 'span_id'], ['block_ids', 'blocks', blockIndex, 'block_id']]) { + if (record[field] !== undefined && (!Array.isArray(record[field]) || new Set(record[field]).size !== record[field].length || + record[field].some(id => !sourceIndex.has(id) || sourceIndex.get(id).record.page_index !== record.page_index))) fail('Invalid specialist artifact references'); + if (record[items] === undefined) continue; + if (!Array.isArray(record[items])) fail('Invalid specialist context'); + const seen = new Set(); + for (const item of record[items]) { + const original = object(item) ? sourceIndex.get(item[idField])?.record : undefined; + if (!original || seen.has(item[idField]) || original.page_index !== record.page_index || item.text !== original.text || + // Public bboxes use two decimals; specialist context uses three. + !validBox(item.bbox) || ['x0', 'y0', 'x1', 'y1'].some(key => Math.abs(item.bbox[key] - original.bbox[key]) > 0.0055 + Number.EPSILON * Math.max(1, Math.abs(original.bbox[key])))) fail('Invalid specialist context'); + if (items === 'spans' && (!object(item.paint) || ['rendering_mode', 'paint_state', 'clip_state'].some(key => item.paint[key] !== original.paint[key]) || + (original.paint.clip_bbox === null ? item.paint.clip_bbox !== null : !validBox(item.paint.clip_bbox) || + ['x0', 'y0', 'x1', 'y1'].some(key => item.paint.clip_bbox[key] !== original.paint.clip_bbox[key])))) fail('Inconsistent specialist paint evidence'); + if (items === 'spans' && manifest.schema_version.startsWith('0.17.') && + (original.content_scope === null ? item.content_scope !== null : !object(item.content_scope) || + ['page_index', 'stream_object_num', 'stream_generation', 'invocation_index'].some(key => item.content_scope[key] !== original.content_scope[key]))) fail('Inconsistent specialist content scope'); + seen.add(item[idField]); + } + if (record[field] !== undefined) checkIdentitySet(record[field], seen, 'specialist context identity'); + } + } + for (const record of records.filter(record => ['specialist_attempt', 'specialist_response', 'specialist_result'].includes(record.record_type) && record.specialist_kind === 'ocr')) { + const request = requests.get(record.request_id); + if (!integer(record.page_index) || record.page_index >= manifest.page_count || + !(record.region_index === null || integer(record.region_index)) || !request || + request.page_index !== record.page_index || request.region_index !== record.region_index || request.requested_kind !== 'ocr') fail('Invalid OCR request scope'); + if (record.selected_attempt_id !== undefined && record.selected_attempt_id !== null) { + const selected = attempts.get(record.selected_attempt_id); + if (!selected || selected.specialist_kind !== 'ocr' || selected.selected !== true || !['completed', 'empty'].includes(selected.attempt_status) || + selected.page_index !== record.page_index || selected.region_index !== record.region_index || selected.request_id !== record.request_id || + (record.record_type === 'specialist_result' && selected.attempt_status !== 'completed')) fail('Invalid selected OCR attempt'); + } + if (record.record_type === 'specialist_result' && (record.status !== 'completed' || typeof record.selected_attempt_id !== 'string')) fail('Invalid completed OCR result'); + if (record.record_type === 'specialist_response') { + const scoped = [...attempts.values()].filter(attempt => attempt.request_id === record.request_id && attempt.specialist_kind === 'ocr'); + // Responses describe the last attempt; an earlier completed winner may + // still be selected after a later retry fails. + const last = scoped.at(-1); + const status = ['failed', 'timeout', 'invalid_output'].includes(last?.attempt_status) ? 'failed' : (last?.attempt_status ?? 'not_invoked'); + if (record.attempt_count !== scoped.length || record.status !== status) fail('Invalid OCR response summary'); + } + if (record.record_type !== 'specialist_attempt') continue; + if (typeof record.selected !== 'boolean' || !attemptStatuses.includes(record.attempt_status) || + (record.selected && !['completed', 'empty'].includes(record.attempt_status))) fail('Invalid OCR attempt status'); + if (!object(record.config) || !['max_image_pixels', 'max_image_bytes', 'max_regions_per_page', 'max_attempts_per_page'].every(key => integer(record.config[key]))) fail('Invalid OCR execution budgets'); + const crop = record.crop; + if (crop === null && record.region_index === null) continue; + if (!object(crop) || !validBox(crop.bbox) || !integer(crop.dpi) || crop.dpi < 1 || !integer(crop.pixel_width) || !integer(crop.pixel_height) || + !(crop.image_sha256 === null || (typeof crop.image_sha256 === 'string' && /^[a-f0-9]{64}$/u.test(crop.image_sha256))) || !(crop.image_byte_length === null || integer(crop.image_byte_length))) fail('Invalid OCR crop evidence'); + if (['completed', 'empty'].includes(record.attempt_status) && (crop.pixel_width < 1 || crop.pixel_height < 1 || crop.image_sha256 === null || crop.image_byte_length === null || crop.image_byte_length < 24 || + BigInt(crop.pixel_width) * BigInt(crop.pixel_height) > BigInt(record.config.max_image_pixels) || crop.image_byte_length > record.config.max_image_bytes)) fail('OCR crop exceeds its declared budgets'); + } + } + if (!manifest.schema_version.startsWith('0.14.')) { const statuses = new Set(['text_available', 'empty_unverified', 'recovery_required', 'recovery_failed', 'recovered']); const recoveryStates = new Set(['not_required', 'not_invoked', 'completed', 'empty', 'unavailable', 'failed', 'timeout', 'invalid_output']); const failedStates = new Set(['unavailable', 'failed', 'timeout', 'invalid_output']); const pageRoutes = new Set(); for (const record of records.filter(record => record.record_type === 'route_trace' && ['page_route', 'region_route'].includes(record.trace_kind))) { const assessment = record.coverage; + if (/^0\.(16|17)\./u.test(manifest.schema_version) && (!object(assessment) || !['non_painting_span_count', 'clipped_out_span_count', 'uncertain_paint_span_count'].every(key => integer(assessment[key])))) fail('Invalid paint coverage counts'); if (!integer(record.page_index) || record.page_index >= manifest.page_count || !object(assessment) || !statuses.has(assessment.status) || assessment.completeness !== 'unverified' || !recoveryStates.has(assessment.recovery_state) || typeof assessment.requested_recovery !== 'boolean' || @@ -353,6 +433,8 @@ export async function extractPdfDocument(buffer, options) { if (!options || typeof options.binaryPath !== 'string' || !options.binaryPath) throw new TypeError('binaryPath is required'); const timeoutMs = options.timeoutMs ?? 45_000; const maxOutputBytes = options.maxOutputBytes ?? 25 * 1024 * 1024; + const ocrArgs = ocrArguments(options.ocrLimits); + if (options.allowPartial !== undefined && typeof options.allowPartial !== 'boolean') throw new TypeError('allowPartial must be a boolean'); if (!integer(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647 || !integer(maxOutputBytes) || maxOutputBytes < 1) throw new RangeError('Invalid extraction limits'); if (options.signal?.aborted) throw new PdfExtractionError('aborted', 'Extraction was aborted'); // Own a snapshot before awaiting I/O: a caller can reuse or mutate its buffer. @@ -364,8 +446,9 @@ export async function extractPdfDocument(buffer, options) { tempDir = await mkdtemp(join(tmpdir(), 'pdf-parser-extract-')); const inputPath = join(tempDir, 'input.pdf'); await writeFile(inputPath, inputBytes, { mode: 0o600 }); - const args = ['extract-adaptive', '--input', inputPath, '--source-id', inputSha256, '--format', 'artifact-jsonl']; + const args = ['extract-adaptive', '--input', inputPath, '--source-id', inputSha256, '--format', 'artifact-jsonl', ...ocrArgs]; if (!options.enableOcr) args.push('--no-ocr'); + if (options.allowPartial) args.push('--allow-partial'); const rawJsonl = await runParser(options.binaryPath, args, { timeoutMs, maxOutputBytes, signal: options.signal }); return parseArtifactJsonl(rawJsonl, { inputSha256, expectedSourceId: inputSha256 }); } catch (error) { diff --git a/integrations/javascript/extract.test.mjs b/integrations/javascript/extract.test.mjs index 9a2dfa7..75299b4 100644 --- a/integrations/javascript/extract.test.mjs +++ b/integrations/javascript/extract.test.mjs @@ -56,6 +56,138 @@ function currentFixture() { return records; } +function paintFixture() { + const records = currentFixture(); + for (const record of records) record.schema_version = '0.16.0'; + for (const span of records.filter(record => record.record_type === 'span')) span.paint = { + rendering_mode: 0, paint_state: 'painting', clip_state: 'unclipped', clip_bbox: null, + }; + Object.assign(records[7].coverage, { non_painting_span_count: 0, clipped_out_span_count: 0, uncertain_paint_span_count: 0 }); + const envelope = (type, id) => ({ schema_name: type, schema_version: '0.16.0', record_type: type, + document_id: 'doc', source_id: inputSha256, provenance: { ...records[0].provenance, artifact_id: id } }); + Object.assign(records[0].artifact_counts, { specialist_requests: 1, specialist_attempts: 1, specialist_responses: 1 }); + records.push({ ...envelope('specialist_request', 'ocr-request'), request_id: 'ocr-request', page_index: 0, region_index: 0, requested_kind: 'ocr' }); + records.push({ ...envelope('specialist_attempt', 'ocr-attempt'), attempt_id: 'ocr-attempt', request_id: 'ocr-request', page_index: 0, region_index: 0, + specialist_kind: 'ocr', attempt_status: 'completed', selected: true, + config: { max_image_pixels: 100, max_image_bytes: 1024, max_regions_per_page: 1, max_attempts_per_page: 1 }, + crop: { bbox, dpi: 200, pixel_width: 10, pixel_height: 10, image_sha256: 'a'.repeat(64), image_byte_length: 100 } }); + records.push({ ...envelope('specialist_response', 'ocr-response'), response_id: 'ocr-response', request_id: 'ocr-request', page_index: 0, region_index: 0, + specialist_kind: 'ocr', status: 'completed', attempt_count: 1, selected_attempt_id: 'ocr-attempt' }); + return records; +} + +function scopeFixture() { + const records = paintFixture(); + for (const record of records) record.schema_version = '0.17.0'; + for (const span of records.filter(record => record.record_type === 'span')) span.content_scope = { + page_index: span.page_index, stream_object_num: null, stream_generation: 0, invocation_index: 0, + }; + return records; +} + +test('content scopes retain page roots and repeated Form identities', () => { + const records = scopeFixture(); + records[1].content_scope = { page_index: 0, stream_object_num: 42, stream_generation: 2, invocation_index: 1 }; + records[2].content_scope = { ...records[1].content_scope, invocation_index: 2 }; + assert.equal(parse(records).spans[1].content_scope.invocation_index, 2); +}); +for (const [name, mutate] of [ + ['missing scope', r => { delete r[1].content_scope; }], + ['wrong page', r => { r[1].content_scope.page_index = 1; }], + ['page invocation', r => { r[1].content_scope.invocation_index = 1; }], + ['Form without invocation', r => { r[1].content_scope.stream_object_num = 42; }], + ['bad generation', r => { r[1].content_scope.stream_generation = 65536; }], + ['forged scope context', r => { r[8].spans = [{ span_id: 'span-0', text: r[1].text, bbox, paint: r[1].paint, + content_scope: { ...r[1].content_scope, stream_object_num: 42, invocation_index: 1 } }]; }], +]) test(`scope contract rejects ${name}`, () => { + const records = scopeFixture(); + mutate(records); + assert.throws(() => parse(records), { code: 'protocol' }); +}); + +test('paint and crop evidence remain separate from visual completeness', () => { + const document = parse(paintFixture()); + assert.equal(document.spans[0].paint.paint_state, 'painting'); + assert.equal(document.pageOutcomes[0].completeness, 'unverified'); +}); + +test('OCR response can report a failed retry while retaining an earlier completed winner', () => { + const records = paintFixture(); + const retry = structuredClone(records[9]); + retry.attempt_id = retry.provenance.artifact_id = 'ocr-retry'; + retry.attempt_status = 'failed'; + retry.selected = false; + records.splice(10, 0, retry); + records[0].artifact_counts.specialist_attempts = 2; + records[11].attempt_count = 2; + records[11].status = 'failed'; + assert.equal(parse(records).records.at(-1).selected_attempt_id, 'ocr-attempt'); +}); + +test('completed OCR results require a completed selected OCR attempt', () => { + const records = paintFixture(); + const result = structuredClone(records[10]); + result.record_type = result.schema_name = 'specialist_result'; + result.specialist_result_id = result.provenance.artifact_id = 'ocr-result'; + records.push(result); + records[0].artifact_counts.specialist_results = 1; + assert.ok(parse(records)); + records[9].attempt_status = 'empty'; + records[10].status = 'empty'; + assert.throws(() => parse(records), { code: 'protocol' }); +}); + +test('OCR responses preserve legacy failed summary with detailed timeout and invalid-output attempts', () => { + for (const status of ['timeout', 'invalid_output']) { + const records = paintFixture(); + records[9].attempt_status = status; + records[9].selected = false; + records[10].status = 'failed'; + records[10].selected_attempt_id = null; + assert.ok(parse(records)); + } +}); +for (const [name, mutate] of [ + ['missing paint', r => { delete r[1].paint; }], + ['unknown rendering mode', r => { r[1].paint.rendering_mode = 8; }], + ['nonpainting mode called painting', r => { r[1].paint.rendering_mode = 3; }], + ['unbounded inside clip', r => { r[1].paint.clip_state = 'inside'; }], + ['invalid clip geometry', r => { r[1].paint.clip_state = 'outside'; r[1].paint.clip_bbox = { ...bbox, x0: 100 }; }], + ['missing paint counts', r => { delete r[7].coverage.non_painting_span_count; }], + ['crop without request', r => { r[9].request_id = 'unknown'; }], + ['crop on wrong scope', r => { r[9].region_index = 1; }], + ['crop exceeds pixel budget', r => { r[9].crop.pixel_width = 11; }], + ['crop exceeds byte budget', r => { r[9].crop.image_byte_length = 1025; }], + ['crop without hash', r => { r[9].crop.image_sha256 = null; }], + ['crop missing budgets', r => { delete r[9].config.max_image_pixels; }], + ['unknown selected attempt', r => { r[10].selected_attempt_id = 'unknown'; }], + ['unselected chosen attempt', r => { r[9].selected = false; }], + ['selected wrong specialist', r => { r[9].specialist_kind = 'formula'; }], + ['selected failed attempt', r => { r[9].attempt_status = 'failed'; }], + ['nonboolean selection', r => { r[9].selected = 'true'; }], + ['unknown attempt status', r => { r[9].attempt_status = 'made_up'; }], + ['wrong response status', r => { r[10].status = 'failed'; }], + ['wrong response count', r => { r[10].attempt_count = 2; }], + ['foreign specialist span', r => { r[8].span_ids = ['span-999']; }], + ['forged specialist span context', r => { r[8].spans = [{ span_id: 'span-0', text: 'forged', bbox }]; }], + ['forged specialist paint context', r => { r[8].spans = [{ span_id: 'span-0', text: r[1].text, bbox, paint: { ...r[1].paint, paint_state: 'unknown' } }]; }], + ['foreign specialist block', r => { r[8].block_ids = ['block-999']; }], +]) test(`paint contract rejects ${name}`, () => { + const records = paintFixture(); + mutate(records); + assert.throws(() => parse(records), { code: 'protocol' }); +}); + +test('specialist context matches paint and tolerates only declared bbox rounding', () => { + const records = paintFixture(); + const context = { span_id: 'span-0', text: records[1].text, bbox: { ...bbox, x0: bbox.x0 + 0.004 }, paint: structuredClone(records[1].paint) }; + records[8].spans = [context]; + records[8].span_ids = ['span-0']; + assert.ok(parse(records)); + context.bbox.x0 = bbox.x0 + 0.006; + assert.throws(() => parse(records), { code: 'protocol' }); +}); + test('current schema exposes authoritative table ownership and unverified page outcomes', () => { const document = parse(currentFixture()); assert.deepEqual(document.tables[0].reconciled_block_ids, ['block-0']); @@ -81,6 +213,18 @@ test('malformed table rows remain protocol errors through the subprocess adapter await assert.rejects(extractPdfDocument(bytes, { binaryPath }), { code: 'protocol' }); }); +test('batch forwards explicit OCR budgets and partial-result policy', async (t) => { + const { dir, binaryPath } = await fakeParser(t, `import {writeFileSync} from 'node:fs'; + writeFileSync(new URL('./args.json',import.meta.url),JSON.stringify(process.argv.slice(2))); + process.stdout.write(${JSON.stringify(serialize(fixture()))});`); + await extractPdfDocument(bytes, { binaryPath, allowPartial: true, ocrLimits: { maxImagePixels: 1000, maxImageBytes: 2048, maxRegionsPerPage: 2, maxAttemptsPerPage: 3 } }); + const args = JSON.parse(await readFile(join(dir, 'args.json'), 'utf8')); + for (const [flag, value] of [['--ocr-max-image-pixels', '1000'], ['--ocr-max-image-bytes', '2048'], ['--ocr-max-regions', '2'], ['--ocr-max-attempts', '3']]) assert.equal(args[args.indexOf(flag) + 1], value); + assert.ok(args.includes('--allow-partial')); + for (const limits of [{ maxImagePixels: 0 }, { maxImageBytes: 1.5 }, { maxImagesPixels: 100 }]) await assert.rejects(extractPdfDocument(bytes, { binaryPath, ocrLimits: limits }), RangeError); + await assert.rejects(extractPdfDocument(bytes, { binaryPath, allowPartial: 'yes' }), TypeError); +}); + for (const [name, mutate] of [ ['unknown span owner', r => { r[1].reconciled_block_id = 'block-999'; }], ['wrong existing span owner', r => { r[1].reconciled_block_id = 'block-1'; }], diff --git a/integrations/javascript/fidelity.test.mjs b/integrations/javascript/fidelity.test.mjs new file mode 100644 index 0000000..7cc31b8 --- /dev/null +++ b/integrations/javascript/fidelity.test.mjs @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { access, mkdtemp, writeFile, rm, readFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { extractPdfDocument, extractPdfPages, locateSourceRange, composeChunksWithOrigins, parseArtifactJsonl } from './index.mjs'; + +const binaryPath = fileURLToPath(new URL('../../zig-out/bin/pdf-parser', import.meta.url)); +let available = true; +try { await access(binaryPath, constants.X_OK); } catch { available = false; } +const stream = text => `<< /Length ${Buffer.byteLength(text)} >>\nstream\n${text}\nendstream`; +function pdf(objects) { + let result = '%PDF-1.4\n'; + const offsets = [0]; + for (const [index, object] of objects.entries()) { + offsets.push(Buffer.byteLength(result)); + result += `${index + 1} 0 obj\n${object}\nendobj\n`; + } + const xref = Buffer.byteLength(result); + result += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets.slice(1)) result += `${String(offset).padStart(10, '0')} 00000 n \n`; + result += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return Buffer.from(result, 'ascii'); +} +function textPdf(content, resources = '') { + return pdf(['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [4 0 R 6 0 R] /Count 2 >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 3 0 R >> ${resources} >> /Contents 5 0 R >>`, stream(content), + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 3 0 R >> ${resources} >> /Contents 7 0 R >>`, stream(content)]); +} +async function both(bytes, check) { + const document = await extractPdfDocument(bytes, { binaryPath }); + check(document, null); + const pages = []; + const receipt = await extractPdfPages(bytes, { binaryPath, onPage: page => { check(page, page.pageIndex); pages.push(page); } }); + assert.equal(receipt.complete, true); + assert.equal(pages.length, document.manifest.page_count); + return document; +} +function checkLocator(document, phrase) { + const start = document.sourceText.indexOf(phrase); + assert.ok(start >= 0, phrase); + const locator = locateSourceRange(document, start, start + phrase.length, { sourceTextSha256: document.sourceTextSha256 }); + assert.ok(locator.segments.some(segment => segment.sourceSpanId !== null && segment.bbox !== null)); + for (const segment of locator.segments.filter(segment => segment.sourceSpanId !== null)) { + const span = document.spans.find(span => span.span_id === segment.sourceSpanId); + assert.equal(document.sourceText.slice(segment.start, segment.end), span.text.slice(segment.sourceStart, segment.sourceEnd)); + } +} + +test('ActualText fixture is authoritative in public text, chunks and source locators', { skip: !available }, async () => { + const bytes = await readFile(new URL('../../benchmark/eval/corpus/weird_fonts/actualtext-repair.pdf', import.meta.url)); + await both(bytes, document => { + assert.equal(document.sourceText, 'Correct ActualText replacement'); + assert.ok(document.chunks.every(chunk => chunk.content === 'Correct ActualText replacement')); + assert.ok(!document.rawJsonl.includes('WRONGVISIBLE')); + checkLocator(document, 'ActualText replacement'); + }); +}); + +test('semantic replacements survive Unicode, operators and paint changes exactly once per page', { skip: !available }, async () => { + const unicode = 'A 😀 office'; + const hex = Buffer.from(unicode, 'utf16le').swap16().toString('hex'); + const bytes = textPdf(`BT /F1 12 Tf 20 250 Td /Span << /ActualText (ONE REPLACEMENT) >> BDC (raw) Tj 3 Tr [(glyph) 100 (text)] TJ 0 Tr /F1 10 Tf (bad) Tj EMC +0 -30 Td /Span << /ActualText >> BDC (wrong) Tj EMC +0 -30 Td /Span << /ActualText () >> BDC (SUPPRESSED) Tj EMC +0 -30 Td (Ordinary suffix) Tj ET`); + await both(bytes, (document, pageIndex) => { + const count = pageIndex === null ? 2 : 1; + assert.equal(document.sourceText.split('ONE REPLACEMENT').length - 1, count); + assert.equal(document.sourceText.split(unicode).length - 1, count); + assert.ok(!document.sourceText.includes('SUPPRESSED')); + assert.ok(!document.sourceText.includes('wrong')); + checkLocator(document, unicode); + for (const chunk of composeChunksWithOrigins({ text: document.sourceText, maxTokens: 128, overlapTokens: 10 })) { + assert.ok(!chunk.body.includes('SUPPRESSED')); + } + }); +}); + +test('named marked-content properties retain semantic text and MCID through public adapters', { skip: !available }, async () => { + const bytes = textPdf('BT /F1 12 Tf 20 250 Td /Span /Replacement BDC (WRONG) Tj EMC ET', '/Properties << /Replacement << /ActualText (Named replacement) /MCID 7 >> >>'); + await both(bytes, (document, pageIndex) => { + assert.equal(document.sourceText, pageIndex === null ? 'Named replacement\n\nNamed replacement' : 'Named replacement'); + assert.ok(document.spans.every(span => span.mcid === 7)); + checkLocator(document, 'Named replacement'); + }); +}); + +test('PDFDocEncoding replacement text survives both public adapters', { skip: !available }, async () => { + const bytes = textPdf('BT /F1 12 Tf 20 250 Td /Span << /ActualText <18808d8e9394a07f9fad> >> BDC (WRONG) Tj EMC ET'); + const expected = '˘•“”fifl€���'; + await both(bytes, (document, pageIndex) => { + assert.equal(document.sourceText, pageIndex === null ? `${expected}\n\n${expected}` : expected); + assert.ok(!document.sourceText.includes('WRONG')); + checkLocator(document, expected); + }); +}); + +test('horizontal and rotated writing controls keep boundaries in final chunks', { skip: !available }, async () => { + for (const matrix of ['1 0 0 1', '.70710678 .70710678 -.70710678 .70710678']) { + const bytes = textPdf(`BT /F1 12 Tf ${matrix} 100 50 Tm (A) Tj 8.004 0 Td (B) Tj 11.340 0 Td (C) Tj 8.664 0 Td (D) Tj ET`); + await both(bytes, document => { + assert.ok(document.chunks.every(chunk => chunk.content.split('\n\n').every(text => text === 'AB CD'))); + assert.ok(!document.sourceText.includes('ABCD')); + checkLocator(document, 'AB CD'); + }); + } +}); + +function formPdf(cyclic = false) { + const body = 'BT /F1 12 Tf 20 280 Td (Body line one has ordinary readable text.) Tj 0 -24 Td (Body line two has ordinary readable text.) Tj 0 -24 Td (Body line three has ordinary readable text.) Tj 0 -24 Td (Body line four has ordinary readable text.) Tj ET\n' + + 'q 1 0 0 1 20 50 cm /One Do Q\nq 1 0 0 1 180 50 cm /Two Do Q\nq 1 0 0 1 100 10 cm /One Do Q'; + const first = cyclic ? '/One Do' : 'BT /F1 12 Tf 5 8 Td /Span << /MCID 0 >> BDC (A) Tj EMC ET'; + const form = (content, extra) => `<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] ${extra} /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`; + return pdf(['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [4 0 R 6 0 R] /Count 2 >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Resources 12 0 R /Contents 5 0 R >>', stream(body), + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Resources 12 0 R /Contents 9 0 R >>', + form(first, '/Matrix [2 0 0 2 10 20] /Resources 13 0 R'), + form('BT /F1 12 Tf 5 8 Td /Span << /MCID 0 >> BDC (A) Tj EMC ET', '/Resources << /Font << /F1 11 0 R >> >>'), stream(body), + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /BaseEncoding /WinAnsiEncoding /Differences [65 /B] >> >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /BaseEncoding /WinAnsiEncoding /Differences [65 /C] >> >>', + '<< /Font << /F1 3 0 R >> /XObject << /One 7 0 R /Two 8 0 R >> >>', + '<< /Font << /F1 10 0 R >> /XObject << /One 7 0 R >> >>']); +} + +test('Form text uses local resources, matrices and distinct repeated-placement scope without fallback', { skip: !available }, async (t) => { + const bytes = formPdf(); + await both(bytes, (document, pageIndex) => { + const page = pageIndex ?? 0; + const scoped = document.spans.filter(span => span.page_index === page && span.content_scope?.stream_object_num !== null && span.content_scope !== null); + assert.equal(scoped.length, 3); + const first = scoped.find(span => span.content_scope.stream_object_num === 7 && span.content_scope.invocation_index === 1); + const second = scoped.find(span => span.content_scope.stream_object_num === 8); + assert.equal(first.text, 'B'); + assert.equal(first.bbox.x0, 40); + assert.ok(first.bbox.x1 > 55 && first.bbox.x1 < 57); + assert.equal(second.text, 'C'); + assert.equal(second.bbox.x0, 185); + assert.equal(new Set(scoped.map(span => span.content_scope.invocation_index)).size, 3); + assert.ok(!document.spans.some(span => span.source === 'poppler_text')); + checkLocator(document, 'B'); + }); + const directory = await mkdtemp(join(tmpdir(), 'pdf-parser-form-fidelity-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const input = join(directory, 'forms.pdf'); + await writeFile(input, bytes, { mode: 0o600 }); + for (const format of ['artifact-jsonl', 'stream-jsonl']) { + const output = execFileSync(binaryPath, ['extract-adaptive', '--input', input, '--format', format, '--no-ocr'], { + env: { ...process.env, PATH: '' }, encoding: 'utf8', timeout: 5000, + }); + const records = output.trim().split('\n').map(line => JSON.parse(line)); + assert.equal(records.filter(record => record.record_type === 'span' && record.content_scope?.stream_object_num === 7).length, 4); + if (format === 'artifact-jsonl') assert.ok(parseArtifactJsonl(output, { inputSha256: createHash('sha256').update(bytes).digest('hex') })); + } +}); + +test('cyclic Form traversal fails explicitly through both adapters', { skip: !available }, async () => { + const bytes = formPdf(true); + await assert.rejects(extractPdfDocument(bytes, { binaryPath }), { code: 'document' }); + let pages = 0; + await assert.rejects(extractPdfPages(bytes, { binaryPath, onPage: () => { pages++; } }), { code: 'document' }); + assert.equal(pages, 0); +}); diff --git a/integrations/javascript/integration.test.mjs b/integrations/javascript/integration.test.mjs index ba3f95f..e377f1a 100644 --- a/integrations/javascript/integration.test.mjs +++ b/integrations/javascript/integration.test.mjs @@ -30,8 +30,7 @@ test('actual CLI without caller source identity keeps every artifact source null // Small generated, redistribution-safe PDF. Each marked group contains repeated // lines; a second page checks that origin identity includes the actual page. -function fixturePdf() { - const content = '/Span << /MCID 7 >> BDC BT /F1 12 Tf 20 250 Td (Repeated evidence.) Tj 0 -24 Td (Repeated evidence.) Tj ET EMC'; +function fixturePdf(content = '/Span << /MCID 7 >> BDC BT /F1 12 Tf 20 250 Td (Repeated evidence.) Tj 0 -24 Td (Repeated evidence.) Tj ET EMC') { const objects = [ '<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [4 0 R 6 0 R] /Count 2 >>', @@ -54,11 +53,34 @@ function fixturePdf() { return Buffer.from(pdf, 'ascii'); } +// Reuse the checked-in scan pixels; an incremental update supplies two disjoint +// placements per page without adding a binary fixture or another PDF library. +function repeatedImagePdf(original, pageCount = 1) { + const content = 'BT /F1 12 Tf 50 740 Td (Native cover text) Tj ET\n' + + 'q 240 0 0 320 20 100 cm /Im1 Do Q\nq 240 0 0 320 300 100 cm /Im1 Do Q'; + const page = '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im1 6 0 R >> >> >>'; + const previous = Number(original.toString('latin1').match(/startxref\s+(\d+)\s+%%EOF\s*$/u)[1]); + const entries = [[2, `<< /Type /Pages /Kids [3 0 R${pageCount === 2 ? ' 7 0 R' : ''}] /Count ${pageCount} >>`], + [3, page], [4, `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`]]; + if (pageCount === 2) entries.push([7, page]); + let tail = '\n'; + const offsets = []; + for (const [id, object] of entries) { + offsets.push([id, original.length + Buffer.byteLength(tail)]); + tail += `${id} 0 obj\n${object}\nendobj\n`; + } + const xref = original.length + Buffer.byteLength(tail); + tail += 'xref\n'; + for (const [id, offset] of offsets) tail += `${id} 1\n${String(offset).padStart(10, '0')} 00000 n \n`; + tail += `trailer\n<< /Size ${pageCount === 2 ? 8 : 7} /Root 1 0 R /Prev ${previous} >>\nstartxref\n${xref}\n%%EOF\n`; + return Buffer.concat([original, Buffer.from(tail, 'ascii')]); +} + test('actual CLI -> validated rich text -> overlapping chunks -> page/span locators', { skip: available ? false : 'Build the parser with zig build to run the real CLI integration', }, async () => { const document = await extractPdfDocument(fixturePdf(), { binaryPath }); - assert.equal(document.manifest.schema_version, '0.15.0'); + assert.equal(document.manifest.schema_version, '0.17.0'); assert.equal(document.spans.length, 4); assert.equal(document.blocks.length, 4); assert.equal(document.sourceText, Array(4).fill('Repeated evidence.').join('\n\n')); @@ -119,7 +141,7 @@ test('actual multi-page stream preserves global ownership with page-local locato const receipt = await extractPdfPages(fixturePdf(), { binaryPath, onPage: async page => { pages.push(page); } }); assert.equal(receipt.complete, true); assert.equal(receipt.pageCount, 2); - assert.equal(receipt.schemaVersion, '0.15.0'); + assert.equal(receipt.schemaVersion, '0.17.0'); assert.deepEqual(pages.map(page => page.pageIndex), [0, 1]); assert.equal(new Set(pages.flatMap(page => page.spans.map(span => span.span_id))).size, 4); for (const page of pages) { @@ -147,6 +169,24 @@ test('actual native page allocation limit fails without delivering a page or rec onPage: () => {} })).complete, true); }); +test('actual hidden text specialist contexts bind to global second-page IDs', { skip: !available }, async () => { + const pages = []; + const receipt = await extractPdfPages(fixturePdf('BT /F1 12 Tf 3 Tr 20 250 Td (HIDDEN evidence) Tj ET'), { + binaryPath, onPage: page => pages.push(page), + }); + assert.equal(receipt.complete, true); + assert.equal(pages.length, 2); + for (const page of pages) { + const requests = page.records.filter(record => record.record_type === 'specialist_request'); + assert.ok(requests.length > 0); + for (const request of requests) { + for (const id of request.span_ids) assert.equal(page.spans.find(span => span.span_id === id)?.page_index, page.pageIndex); + for (const id of request.block_ids) assert.equal(page.blocks.find(block => block.block_id === id)?.page_index, page.pageIndex); + } + } + assert.ok(pages[1].records.some(record => record.record_type === 'specialist_request' && record.span_ids.includes('span-1'))); +}); + test('actual mixed scan keeps native evidence and exposes disabled recovery', { skip: !available }, async () => { const pdf = await readFile(new URL('../../benchmark/eval/corpus/scanned_typewritten/mixed-native-scan.pdf', import.meta.url)); const document = await extractPdfDocument(pdf, { binaryPath }); @@ -159,3 +199,91 @@ test('actual mixed scan keeps native evidence and exposes disabled recovery', { assert.equal(outcome.completeness, 'unverified'); assert.ok(outcome.native_span_count > 0); }); + +test('actual public artifacts preserve nonpainting and clipped text with transformed evidence', { skip: !available }, async () => { + const bytes = fixturePdf(`BT /F1 12 Tf 20 260 Td (VISIBLE) Tj ET + q BT /F1 12 Tf 3 Tr 20 230 Td (HIDDEN) Tj ET Q + q 0 0 40 40 re W n BT /F1 12 Tf 180 180 Td (CLIPPED) Tj ET Q + q 2 0 0 2 30 40 cm 0 0 50 50 re W n BT /F1 12 Tf 10 10 Td (TRANSFORMED) Tj ET Q`); + const document = await extractPdfDocument(bytes, { binaryPath }); + for (const text of ['VISIBLE', 'HIDDEN', 'CLIPPED', 'TRANSFORMED']) assert.ok(document.sourceText.includes(text)); + const hidden = document.spans.find(span => span.text.includes('HIDDEN')); + assert.equal(hidden.paint.rendering_mode, 3); + assert.equal(hidden.paint.paint_state, 'non_painting'); + const clipped = document.spans.find(span => span.text.includes('CLIPPED')); + assert.equal(clipped.paint.clip_state, 'outside'); + const transformed = document.spans.filter(span => span.page_index === 0 && span.paint.clip_bbox?.x0 === 30 && span.paint.clip_bbox?.y0 === 40); + assert.equal(transformed.map(span => span.text).join(''), 'TRANSFORMED'); + assert.ok(transformed.every(span => JSON.stringify(span.paint.clip_bbox) === JSON.stringify({ x0: 30, y0: 40, x1: 130, y1: 140 }))); + assert.equal(transformed[0].bbox.x0, 50); + assert.ok(document.pageOutcomes.every(outcome => outcome.non_painting_span_count > 0 && outcome.clipped_out_span_count > 0 && outcome.completeness === 'unverified')); + let pages = 0; + assert.equal((await extractPdfPages(bytes, { binaryPath, onPage(page) { + assert.ok(page.spans.some(span => span.paint.paint_state === 'non_painting')); + pages++; + } })).complete, true); + assert.equal(pages, 2); +}); + +test('actual regional OCR binds crop identity and budgets, preserving native text on refusal', { skip: !available }, async () => { + const pdf = await readFile(new URL('../../benchmark/eval/corpus/scanned_typewritten/mixed-native-scan.pdf', import.meta.url)); + const document = await extractPdfDocument(pdf, { binaryPath, enableOcr: true }); + assert.ok(document.sourceText.includes('Native cover text')); + assert.ok(document.sourceText.includes('SCANNED TYPEWRITTEN')); + assert.equal(document.pageOutcomes[0].status, 'recovered'); + const attempts = document.records.filter(record => record.record_type === 'specialist_attempt' && record.specialist_kind === 'ocr'); + assert.ok(attempts.length > 0); + for (const attempt of attempts) { + assert.ok(Number.isInteger(attempt.region_index)); + assert.match(attempt.crop.image_sha256, /^[a-f0-9]{64}$/u); + assert.ok(attempt.crop.image_byte_length > 24); + assert.ok(attempt.crop.pixel_width * attempt.crop.pixel_height <= attempt.config.max_image_pixels); + } + await assert.rejects(extractPdfDocument(pdf, { binaryPath, enableOcr: true, ocrLimits: { maxImagePixels: 1 } }), { code: 'document' }); + const partial = await extractPdfDocument(pdf, { binaryPath, enableOcr: true, allowPartial: true, ocrLimits: { maxImagePixels: 1 } }); + assert.ok(partial.sourceText.includes('Native cover text')); + assert.equal(partial.pageOutcomes[0].status, 'recovery_failed'); + assert.equal(partial.pageOutcomes[0].completeness, 'unverified'); + assert.ok(partial.records.some(record => record.record_type === 'specialist_attempt' && record.errors.some(error => error.code === 'rasterizer_pixel_limit'))); +}); + +test('actual independent image crops retain quota failures and stream global OCR identities', { skip: !available }, async () => { + const original = await readFile(new URL('../../benchmark/eval/corpus/scanned_typewritten/mixed-native-scan.pdf', import.meta.url)); + const quota = await extractPdfDocument(repeatedImagePdf(original), { binaryPath, enableOcr: true, allowPartial: true, + timeoutMs: 120000, ocrLimits: { maxRegionsPerPage: 1 } }); + const images = quota.records.filter(record => record.record_type === 'route_trace' && record.trace_kind === 'region_route' && record.layout_block_index === null && record.image_count === 1); + assert.equal(images.length, 2); + assert.deepEqual(images.map(record => record.bbox), [{ x0: 20, y0: 100, x1: 260, y1: 420 }, { x0: 300, y0: 100, x1: 540, y1: 420 }]); + const attempts = quota.records.filter(record => record.record_type === 'specialist_attempt' && record.specialist_kind === 'ocr'); + assert.ok(attempts.some(attempt => attempt.region_index === images[0].region_index && attempt.selected)); + assert.ok(attempts.some(attempt => attempt.region_index === images[1].region_index && attempt.attempt_status === 'not_invoked' && [...attempt.warnings, ...attempt.errors].some(diagnostic => diagnostic.code === 'ocr_region_limit'))); + assert.equal(quota.pageOutcomes[0].status, 'recovery_required'); + assert.equal(quota.pageOutcomes[0].completeness, 'unverified'); + assert.ok(quota.sourceText.includes('Native cover text')); + + const attemptQuota = await extractPdfDocument(repeatedImagePdf(original), { binaryPath, enableOcr: true, allowPartial: true, + timeoutMs: 120000, ocrLimits: { maxAttemptsPerPage: 1 } }); + assert.ok(attemptQuota.records.some(record => record.record_type === 'specialist_attempt' && record.attempt_status === 'not_invoked' && + [...record.warnings, ...record.errors].some(diagnostic => diagnostic.code === 'ocr_attempt_limit'))); + assert.equal(attemptQuota.pageOutcomes[0].status, 'recovery_required'); + assert.ok(attemptQuota.sourceText.includes('Native cover text')); + + const pages = []; + const receipt = await extractPdfPages(repeatedImagePdf(original, 2), { binaryPath, enableOcr: true, timeoutMs: 120000, + onPage: page => pages.push(page) }); + assert.equal(receipt.complete, true); + assert.equal(pages.length, 2); + const identities = pages.flatMap(page => page.records.filter(record => record.record_type === 'specialist_attempt').map(record => record.attempt_id)); + assert.equal(new Set(identities).size, identities.length); + for (const page of pages) { + assert.equal(page.pageOutcomes[0].status, 'recovered'); + assert.ok(page.sourceText.includes('Native cover text')); + assert.ok(page.sourceText.includes('SCANNED')); + const results = page.records.filter(record => record.record_type === 'specialist_result' && record.specialist_kind === 'ocr'); + assert.equal(results.length, 2); + for (const result of results) { + assert.ok(result.span_ids.length > 0); + assert.ok(result.span_ids.every(id => page.spans.some(span => span.span_id === id && span.page_index === page.pageIndex))); + } + } +}); diff --git a/integrations/javascript/ocr-options.mjs b/integrations/javascript/ocr-options.mjs new file mode 100644 index 0000000..6f79804 --- /dev/null +++ b/integrations/javascript/ocr-options.mjs @@ -0,0 +1,15 @@ +const flags = { maxImagePixels: '--ocr-max-image-pixels', maxImageBytes: '--ocr-max-image-bytes', + maxRegionsPerPage: '--ocr-max-regions', maxAttemptsPerPage: '--ocr-max-attempts' }; + +export function ocrArguments(limits) { + if (limits === undefined) return []; + if (limits === null || typeof limits !== 'object' || Array.isArray(limits)) throw new TypeError('OCR limits must be an object'); + if (Object.keys(limits).some(key => !Object.hasOwn(flags, key))) throw new RangeError('Unknown OCR limit'); + const args = []; + for (const [key, flag] of Object.entries(flags)) { + if (limits[key] === undefined) continue; + if (!Number.isSafeInteger(limits[key]) || limits[key] < 1) throw new RangeError('OCR limits must be positive safe integers'); + args.push(flag, String(limits[key])); + } + return args; +} diff --git a/integrations/javascript/stream.d.mts b/integrations/javascript/stream.d.mts index befd2d4..5be12ba 100644 --- a/integrations/javascript/stream.d.mts +++ b/integrations/javascript/stream.d.mts @@ -1,4 +1,4 @@ -import type { RichPdfDocument } from './extract.mjs'; +import type { RichPdfDocument, OcrLimits } from './extract.mjs'; /** Global artifact IDs and page index; source positions are local to this page. * Delivered pages remain provisional until the extraction receipt succeeds. */ export interface RichPdfPage extends RichPdfDocument { @@ -19,6 +19,8 @@ export interface StreamExtractionOptions { maxNativePageMemoryBytes?: number; signal?: AbortSignal; enableOcr?: boolean; + ocrLimits?: OcrLimits; + allowPartial?: boolean; } export interface StreamExtractionReceipt { complete: true; diff --git a/integrations/javascript/stream.mjs b/integrations/javascript/stream.mjs index 859a42a..bce3f2d 100644 --- a/integrations/javascript/stream.mjs +++ b/integrations/javascript/stream.mjs @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parseArtifactJsonl, PdfExtractionError } from './extract.mjs'; import { terminateSubprocess } from './subprocess.mjs'; +import { ocrArguments } from './ocr-options.mjs'; const integer = (value) => Number.isSafeInteger(value) && value >= 0; const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); @@ -77,7 +78,7 @@ async function consume(binaryPath, args, options, inputSha256) { const firstRecord = !manifest; if (firstRecord) { if (type !== 'document_manifest' || record.streaming !== true || record.sequence_scope !== 'document' || - !/^0\.(14|15)\.\d+$/.test(record.schema_version ?? '') || !integer(record.page_count) || + !/^0\.(14|15|16|17)\.\d+$/.test(record.schema_version ?? '') || !integer(record.page_count) || typeof record.document_id !== 'string' || !record.document_id || record.input_sha256 !== inputSha256 || record.source_id !== inputSha256) fail('Invalid stream manifest'); if (record.page_count > options.maxPages) throw new PdfExtractionError('output_limit', 'Stream exceeded its page-count budget'); if (!Array.isArray(record.errors) || record.errors.length) fail('Stream manifest reports document errors'); @@ -194,6 +195,8 @@ export async function extractPdfPages(buffer, options) { const defaults = { timeoutMs: 45_000, maxOutputBytes: 25 * 1024 * 1024, maxPageBytes: 4 * 1024 * 1024, maxRecordBytes: 1024 * 1024, maxRecords: 100_000, maxPages: 10_000 }; const limits = { ...options }; + const ocrArgs = ocrArguments(options.ocrLimits); + if (options.allowPartial !== undefined && typeof options.allowPartial !== 'boolean') throw new TypeError('allowPartial must be a boolean'); for (const [key, defaultValue] of Object.entries(defaults)) { limits[key] ??= defaultValue; if (!integer(limits[key]) || limits[key] < 1 || (key === 'timeoutMs' && limits[key] > 2_147_483_647)) throw new RangeError('Invalid streaming limits'); @@ -208,9 +211,10 @@ export async function extractPdfPages(buffer, options) { directory = await mkdtemp(join(tmpdir(), 'pdf-parser-stream-')); const path = join(directory, 'input.pdf'); await writeFile(path, input, { mode: 0o600 }); - const args = ['extract-adaptive', '--input', path, '--source-id', inputSha256, '--format', 'stream-jsonl']; + const args = ['extract-adaptive', '--input', path, '--source-id', inputSha256, '--format', 'stream-jsonl', ...ocrArgs]; if (options.maxNativePageMemoryBytes !== undefined) args.push('--max-page-memory-bytes', String(options.maxNativePageMemoryBytes)); if (!options.enableOcr) args.push('--no-ocr'); + if (options.allowPartial) args.push('--allow-partial'); return await consume(options.binaryPath, args, limits, inputSha256); } catch (error) { failure = error instanceof PdfExtractionError ? error : new PdfExtractionError('unavailable', 'Local streaming I/O failed', { cause: error }); diff --git a/integrations/javascript/stream.test.mjs b/integrations/javascript/stream.test.mjs index 2df9c07..e57f136 100644 --- a/integrations/javascript/stream.test.mjs +++ b/integrations/javascript/stream.test.mjs @@ -82,6 +82,15 @@ test('forwards the native allocation budget and validates it before starting', a } }); +test('stream forwards explicit OCR bounds and partial-result policy', async (t) => { + const result = await run(t, fixture(), { allowPartial: true, ocrLimits: { maxRegionsPerPage: 2, maxAttemptsPerPage: 3 } }); + const { args } = JSON.parse(await readFile(result.audit, 'utf8')); + assert.equal(args[args.indexOf('--ocr-max-regions') + 1], '2'); + assert.equal(args[args.indexOf('--ocr-max-attempts') + 1], '3'); + assert.ok(args.includes('--allow-partial')); + await assert.rejects(extractPdfPages(input, { binaryPath: result.path, onPage() {}, ocrLimits: { maxRegionsPerPage: 0 } }), RangeError); +}); + for (const [diagnostic, code] of [['MemoryBudgetExceeded', 'memory_limit'], ['OutputLimitExceeded', 'output_limit']]) { test(`classifies native ${code} without a terminal receipt`, async (t) => { const driver = await fake(t, `process.stderr.write('error: ${diagnostic}'); process.exitCode=1;`); diff --git a/src/adaptive.zig b/src/adaptive.zig index cf757c7..6701c8d 100644 --- a/src/adaptive.zig +++ b/src/adaptive.zig @@ -118,6 +118,10 @@ pub const TraceRecord = struct { pub const OcrAttempt = struct { attempt_index: u16, page_index: u32, + region_index: ?u32 = null, + crop_bbox: ?BBox = null, + crop_image_sha256: ?[32]u8 = null, + crop_image_byte_length: ?usize = null, status: ocr.AttemptStatus, page_bbox: BBox = .{}, failure_stage: ?ocr.AttemptStage = null, @@ -127,6 +131,10 @@ pub const OcrAttempt = struct { lang: []u8, grayscale: bool, timeout_ms: u32, + max_image_pixels: usize = 40_000_000, + max_image_bytes: usize = 32 * 1024 * 1024, + max_regions_per_page: usize = 8, + max_attempts_per_page: usize = 16, duration_ms: u64 = 0, pixel_width: u32 = 0, pixel_height: u32 = 0, @@ -527,7 +535,8 @@ pub fn extractDocument( const raw_recall_text = document.extractTextRawRecall(page_idx, allocator) catch try allocator.alloc(u8, 0); defer allocator.free(raw_recall_text); - var native_result = try native_layout.analyze(allocator, geometry.glyphs, page_bbox, raw_recall_text); + const semantic_glyphs = geometry.semantic_glyphs orelse geometry.glyphs; + var native_result = try native_layout.analyze(allocator, semantic_glyphs, page_bbox, raw_recall_text); defer native_result.deinit(); const fallback_page_text = if (fallback_page_index) |index| index.get(page_idx) else null; if (fallback_page_text) |reference_text| { @@ -542,14 +551,14 @@ pub fn extractDocument( try layout_pages.ensureUnusedCapacity(allocator, 1); var readable_spans: []layout.TextSpan = undefined; if (native_result.quality.quality_pass) { - readable_spans = try native_layout.buildLineSpans(allocator, geometry.glyphs, &native_result, page_index); + readable_spans = try native_layout.buildLineSpans(allocator, semantic_glyphs, &native_result, page_index); } else if (fallback_page_text) |page_text| { readable_spans = try fallbackTextSpans(allocator, page_text, page_index, page_bbox); } else { // Keep the best native readable reconstruction available when the // external comparator is unavailable, while preserving the failed // quality result as a hard routing signal below. - readable_spans = try native_layout.buildLineSpans(allocator, geometry.glyphs, &native_result, page_index); + readable_spans = try native_layout.buildLineSpans(allocator, semantic_glyphs, &native_result, page_index); } layout_pages.appendAssumeCapacity(readable_spans); try trace_records.append(allocator, .{ @@ -606,110 +615,65 @@ pub fn extractDocument( 0, ); + // Image routes precede recognition so attempts retain the exact routed + // region identity even when native text makes the page score look good. + const image_route_start = region_routes.items.len; + const regional_ocr = usesRegionalOcr(page_route, native_result.quality.quality_pass); + if (regional_ocr) { + for (image_boxes) |image| { + const score = complexity.scoreRegion(page_input, image.bbox); + const route = RegionRoute.fromScore(.{ .region_index = region_index, .score = score }); + try region_routes.append(allocator, route); + try appendRouteTraces(allocator, &trace_records, page_index, region_index, route.route, route.reason_mask, route.span_count, 0); + region_index += 1; + } + } + const image_route_end = region_routes.items.len; var page_ocr_spans: ?[]layout.TextSpan = null; - if (page_route.route.needs_ocr) { - if (!options.enable_ocr) { - _ = try appendOcrAttempt(allocator, &ocr_attempts, options.ocr_config, .{ - .attempt_index = 0, - .page_index = page_index, - .status = .not_invoked, - .page_bbox = page_bbox, - .diagnostic_code = .ocr_disabled, - }); - } else { - var best_spans: ?[]layout.TextSpan = null; - errdefer if (best_spans) |owned| ocr.freeSpans(allocator, owned); - var best_attempt_index: ?usize = null; - const configured_attempts: usize = if (options.ocr_config.policy == .bounded) - @max(1, @min(@as(usize, options.ocr_config.max_attempts), 2)) - else - 1; - - for (0..configured_attempts) |attempt_index| { - var attempt_config = options.ocr_config; - if (attempt_index > 0) { - attempt_config.dpi = options.ocr_config.fallback_dpi; - attempt_config.psm = options.ocr_config.fallback_psm; - } - - var raster_outcome = try document.rasterizePageForOcrDetailed(allocator, page_idx, attempt_config); - defer raster_outcome.deinit(allocator); - if (raster_outcome.status != .completed or raster_outcome.input == null) { - _ = try appendOcrAttempt(allocator, &ocr_attempts, attempt_config, .{ - .attempt_index = @intCast(attempt_index), - .page_index = page_index, - .status = raster_outcome.status, - .page_bbox = page_bbox, - .failure_stage = .rasterize, - .duration_ms = raster_outcome.duration_ms, - .exit_code = raster_outcome.exit_code, - .diagnostic_code = raster_outcome.diagnostic_code, - .stderr = raster_outcome.stderr, - }); - break; - } - - const ocr_input = raster_outcome.input.?; - defer { - runtime.deleteFileCwd(ocr_input.image_path); - allocator.free(@constCast(ocr_input.image_path)); - } - - var recognition = try ocr.recognizeRegionDetailed(allocator, ocr_input, attempt_config); - defer recognition.deinit(allocator); - if (recognition.status != .completed and recognition.status != .empty) { - _ = try appendOcrAttempt(allocator, &ocr_attempts, attempt_config, .{ - .attempt_index = @intCast(attempt_index), - .page_index = page_index, - .status = recognition.status, - .page_bbox = page_bbox, - .failure_stage = .recognize, - .duration_ms = raster_outcome.duration_ms + recognition.duration_ms, - .pixel_width = ocr_input.pixel_width, - .pixel_height = ocr_input.pixel_height, - .exit_code = recognition.exit_code, - .diagnostic_code = recognition.diagnostic_code, - .stderr = recognition.stderr, - }); - break; - } - - const raw_ocr_spans = recognition.spans; - recognition.spans = &.{}; - const candidate_spans = try filterOcrSpansForPage(allocator, raw_ocr_spans, spans, image_boxes); - errdefer ocr.freeSpans(allocator, candidate_spans); - const recorded_index = try appendOcrAttempt(allocator, &ocr_attempts, attempt_config, .{ - .attempt_index = @intCast(attempt_index), + var page_attempt_count: usize = 0; + if (!regional_ocr and page_route.route.needs_ocr) { + const selected = try recognizeOcrTarget(document, allocator, &ocr_attempts, options, page_idx, page_bbox, null, spans, image_boxes, &page_attempt_count); + if (selected) |owned| { + errdefer ocr.freeSpans(allocator, owned); + try ocr_pages.append(allocator, owned); + page_ocr_spans = owned; + } + } else if (regional_ocr) { + var combined: std.ArrayList(layout.TextSpan) = .empty; + defer combined.deinit(allocator); + errdefer for (combined.items) |span| allocator.free(@constCast(span.text)); + var requested_regions: usize = 0; + for (region_routes.items[image_route_start..image_route_end]) |route| { + if (!route.route.needs_ocr) continue; + if (requested_regions >= options.ocr_config.max_regions_per_page) { + _ = try appendOcrAttempt(allocator, &ocr_attempts, options.ocr_config, .{ + .attempt_index = 0, .page_index = page_index, - .status = if (candidate_spans.len > 0) .completed else .empty, - .page_bbox = page_bbox, - .duration_ms = raster_outcome.duration_ms + recognition.duration_ms, - .pixel_width = ocr_input.pixel_width, - .pixel_height = ocr_input.pixel_height, - .spans = candidate_spans, - .exit_code = recognition.exit_code, - .stderr = if (recognition.stderr.len > 0) recognition.stderr else raster_outcome.stderr, + .region_index = route.region_index, + .status = .not_invoked, + .page_bbox = route.bbox, + .crop_bbox = route.bbox, + .diagnostic_code = .ocr_region_limit, }); - - if (best_attempt_index == null or betterOcrAttempt(ocr_attempts.items[recorded_index], ocr_attempts.items[best_attempt_index.?])) { - if (best_spans) |owned| ocr.freeSpans(allocator, owned); - best_spans = candidate_spans; - best_attempt_index = recorded_index; - } else { - ocr.freeSpans(allocator, candidate_spans); - } - - if (attempt_index + 1 >= configured_attempts or !shouldRetryOcrAttempt(ocr_attempts.items[recorded_index], options.ocr_config)) break; + continue; } - - if (best_attempt_index) |selected_index| { - ocr_attempts.items[selected_index].selected = true; - const selected_spans = best_spans.?; - try ocr_pages.append(allocator, selected_spans); - best_spans = null; - page_ocr_spans = selected_spans; + requested_regions += 1; + if (try recognizeOcrTarget(document, allocator, &ocr_attempts, options, page_idx, route.bbox, route.region_index, spans, image_boxes, &page_attempt_count)) |owned| { + // Transfer strings only after capacity succeeds; free the + // temporary array while combined owns its span strings. + errdefer ocr.freeSpans(allocator, owned); + try combined.appendSlice(allocator, owned); + allocator.free(owned); } } + if (combined.items.len > 0) { + const owned = try combined.toOwnedSlice(allocator); + errdefer ocr.freeSpans(allocator, owned); + try ocr_pages.append(allocator, owned); + page_ocr_spans = owned; + } + } + if (page_route.route.needs_ocr or image_route_end > image_route_start) { try trace_records.append(allocator, .{ .page_index = page_index, .stage = .ocr_recognize, @@ -951,6 +915,7 @@ pub fn extractDocument( .specialist = specialist, }); if (!page_view_quality_pass) suppressLayoutSpecialists(&route.route, &route.reason_mask); + if (regional_ocr) suppressDerivedOcr(&route, page_input, region_routes.items[image_route_start..image_route_end]); try region_routes.append(allocator, route); try appendRouteTraces( allocator, @@ -985,6 +950,7 @@ pub fn extractDocument( .specialist = specialist, }); if (!page_view_quality_pass) suppressLayoutSpecialists(&route.route, &route.reason_mask); + if (regional_ocr) suppressDerivedOcr(&route, page_input, region_routes.items[image_route_start..image_route_end]); try region_routes.append(allocator, route); try appendRouteTraces( allocator, @@ -1524,9 +1490,191 @@ fn spanSubsumesOcr(represented: layout.TextSpan, ocr_span: layout.TextSpan) bool return before_ok and after_ok; } +fn usesRegionalOcr(route: PageRoute, native_quality_pass: bool) bool { + return route.image_count > 0 and route.span_count > 0 and native_quality_pass and + route.signals.bad_unicode < 0.35 and route.signals.hidden_ocr < 0.45; +} + +fn suppressDerivedOcr(route: *RegionRoute, input: complexity.PageInput, image_routes: []const RegionRoute) void { + if (!route.route.needs_ocr or route.image_count == 0) return; + // Score the original native evidence: newly recognized spans may carry an + // OCR font name or lower confidence, neither of which requests another OCR + // pass over the same crop. Missing ToUnicode alone is not broken text. + const native_score = complexity.scoreRegion(input, route.bbox); + if (native_score.signals.hidden_ocr >= 0.45 or native_score.signals.bad_unicode >= 0.35) return; + // Only suppress an image-driven duplicate when every intersecting image + // already has its own requested crop. A crop elsewhere on the page cannot + // explain a hidden or damaged text region. + var matched_image = false; + for (input.images) |image| { + if (overlapArea(route.bbox, image.bbox) <= 0) continue; + var owned = false; + for (image_routes) |owner| { + if (owner.page_index == route.page_index and owner.route.needs_ocr and + owner.bbox.x0 <= image.bbox.x0 and owner.bbox.y0 <= image.bbox.y0 and + owner.bbox.x1 >= image.bbox.x1 and owner.bbox.y1 >= image.bbox.y1) + { + owned = true; + break; + } + } + if (!owned) return; + matched_image = true; + } + if (!matched_image) return; + route.route.needs_ocr = false; + route.reason_mask &= ~reasonBit(.ocr_route_stub); +} + +fn recognizeOcrTarget( + document: anytype, + allocator: std.mem.Allocator, + attempts: *std.ArrayList(OcrAttempt), + options: ExtractOptions, + page_idx: usize, + page_bbox: BBox, + region_id: ?u32, + spans: []const layout.TextSpan, + image_boxes: []const complexity.ImageBox, + attempt_count: *usize, +) !?[]layout.TextSpan { + const page_index: u32 = @intCast(page_idx); + if (!options.enable_ocr) { + _ = try appendOcrAttempt(allocator, attempts, options.ocr_config, .{ + .attempt_index = 0, + .page_index = page_index, + .status = .not_invoked, + .page_bbox = page_bbox, + .region_index = region_id, + .crop_bbox = if (region_id != null) page_bbox else null, + .diagnostic_code = .ocr_disabled, + }); + } else { + var best_spans: ?[]layout.TextSpan = null; + errdefer if (best_spans) |owned| ocr.freeSpans(allocator, owned); + var best_attempt_index: ?usize = null; + const configured_attempts: usize = if (options.ocr_config.policy == .bounded) + @max(1, @min(@as(usize, options.ocr_config.max_attempts), 2)) + else + 1; + + for (0..configured_attempts) |attempt_index| { + var attempt_config = options.ocr_config; + if (attempt_index > 0) { + attempt_config.dpi = options.ocr_config.fallback_dpi; + attempt_config.psm = options.ocr_config.fallback_psm; + } + + if (attempt_count.* >= options.ocr_config.max_attempts_per_page) { + _ = try appendOcrAttempt(allocator, attempts, attempt_config, .{ + .attempt_index = @intCast(attempt_index), + .page_index = page_index, + .region_index = region_id, + .page_bbox = page_bbox, + .crop_bbox = if (region_id != null) page_bbox else null, + .status = .not_invoked, + .diagnostic_code = .ocr_attempt_limit, + }); + break; + } + attempt_count.* += 1; + var raster_outcome = if (region_id != null) + try document.rasterizeOcrRegionDetailed(allocator, page_idx, page_bbox, attempt_config) + else + try document.rasterizePageForOcrDetailed(allocator, page_idx, attempt_config); + defer raster_outcome.deinit(allocator); + if (raster_outcome.status != .completed or raster_outcome.input == null) { + _ = try appendOcrAttempt(allocator, attempts, attempt_config, .{ + .attempt_index = @intCast(attempt_index), + .page_index = page_index, + .status = raster_outcome.status, + .page_bbox = page_bbox, + .region_index = region_id, + .crop_bbox = if (region_id != null) page_bbox else null, + .failure_stage = .rasterize, + .duration_ms = raster_outcome.duration_ms, + .exit_code = raster_outcome.exit_code, + .diagnostic_code = raster_outcome.diagnostic_code, + .stderr = raster_outcome.stderr, + }); + break; + } + + const ocr_input = raster_outcome.input.?; + defer { + runtime.deleteFileCwd(ocr_input.image_path); + allocator.free(@constCast(ocr_input.image_path)); + } + + var recognition = try ocr.recognizeRegionDetailed(allocator, ocr_input, attempt_config); + defer recognition.deinit(allocator); + if (recognition.status != .completed and recognition.status != .empty) { + _ = try appendOcrAttempt(allocator, attempts, attempt_config, .{ + .attempt_index = @intCast(attempt_index), + .page_index = page_index, + .status = recognition.status, + .page_bbox = page_bbox, + .region_index = region_id, + .failure_stage = .recognize, + .duration_ms = raster_outcome.duration_ms + recognition.duration_ms, + .pixel_width = ocr_input.pixel_width, + .pixel_height = ocr_input.pixel_height, + .crop_bbox = if (region_id != null) ocr_input.pdf_bbox else null, + .crop_image_sha256 = if (region_id != null) ocr_input.image_sha256 else null, + .crop_image_byte_length = if (region_id != null) ocr_input.image_byte_length else null, + .exit_code = recognition.exit_code, + .diagnostic_code = recognition.diagnostic_code, + .stderr = recognition.stderr, + }); + break; + } + + const raw_ocr_spans = recognition.spans; + recognition.spans = &.{}; + const candidate_spans = try filterOcrSpansForPage(allocator, raw_ocr_spans, spans, image_boxes); + errdefer ocr.freeSpans(allocator, candidate_spans); + const recorded_index = try appendOcrAttempt(allocator, attempts, attempt_config, .{ + .attempt_index = @intCast(attempt_index), + .page_index = page_index, + .status = if (candidate_spans.len > 0) .completed else .empty, + .page_bbox = page_bbox, + .region_index = region_id, + .duration_ms = raster_outcome.duration_ms + recognition.duration_ms, + .pixel_width = ocr_input.pixel_width, + .pixel_height = ocr_input.pixel_height, + .crop_bbox = if (region_id != null) ocr_input.pdf_bbox else null, + .crop_image_sha256 = if (region_id != null) ocr_input.image_sha256 else null, + .crop_image_byte_length = if (region_id != null) ocr_input.image_byte_length else null, + .spans = candidate_spans, + .exit_code = recognition.exit_code, + .stderr = if (recognition.stderr.len > 0) recognition.stderr else raster_outcome.stderr, + }); + + if (best_attempt_index == null or betterOcrAttempt(attempts.items[recorded_index], attempts.items[best_attempt_index.?])) { + if (best_spans) |owned| ocr.freeSpans(allocator, owned); + best_spans = candidate_spans; + best_attempt_index = recorded_index; + } else { + ocr.freeSpans(allocator, candidate_spans); + } + + if (attempt_index + 1 >= configured_attempts or !shouldRetryOcrAttempt(attempts.items[recorded_index], options.ocr_config)) break; + } + + if (best_attempt_index) |selected_index| { + attempts.items[selected_index].selected = true; + const selected_spans = best_spans.?; + best_spans = null; + return selected_spans; + } + } + + return null; +} + fn selectedOcrPageQualityPass(attempts: []const OcrAttempt, page_index: u32) bool { for (attempts) |attempt| { - if (attempt.page_index == page_index and attempt.selected) { + if (attempt.page_index == page_index and attempt.region_index == null and attempt.selected) { return attempt.status == .completed and attempt.span_count > 0 and attempt.character_count > 0; } } @@ -1841,6 +1989,26 @@ fn appendLayoutBlockSpans( } for (block.lines) |line| { + // A reconstructed line must not erase distinct operator evidence. + // Keep its original spans when paint differs across that line. + var distinct_paint = false; + for (line.words) |word| { + for (word.spans) |span| { + if (!std.meta.eql(span.paint, line.bounds.paint) or !std.meta.eql(span.content_scope, line.bounds.content_scope)) distinct_paint = true; + } + } + if (distinct_paint) { + for (line.words) |word| { + for (word.spans) |source_span| { + var span = source_span; + span.text = try allocator.dupe(u8, source_span.text); + errdefer allocator.free(@constCast(span.text)); + span.font = stableFont(source_span.font); + try spans.append(allocator, span); + } + } + continue; + } const text = try lineTextOwned(allocator, &line); errdefer allocator.free(text); if (text.len == 0) { @@ -1857,6 +2025,12 @@ fn appendLayoutBlockSpans( .block_id = line.bounds.block_id, .line_id = line.bounds.line_id, .mcid = line.bounds.mcid, + .actual_text = line.bounds.actual_text, + .unicode_map_error = line.bounds.unicode_map_error, + .writing_mode = line.bounds.writing_mode, + .paint = line.bounds.paint, + .content_scope = line.bounds.content_scope, + .leading_boundary = line.bounds.leading_boundary, })); } } @@ -2408,6 +2582,10 @@ fn freeTextSpans(allocator: std.mem.Allocator, spans: []layout.TextSpan) void { const AppendOcrAttemptOptions = struct { attempt_index: u16, page_index: u32, + region_index: ?u32 = null, + crop_bbox: ?BBox = null, + crop_image_sha256: ?[32]u8 = null, + crop_image_byte_length: ?usize = null, status: ocr.AttemptStatus, page_bbox: BBox = .{}, failure_stage: ?ocr.AttemptStage = null, @@ -2446,6 +2624,10 @@ fn appendOcrAttempt( try attempts.append(allocator, .{ .attempt_index = options.attempt_index, .page_index = options.page_index, + .region_index = options.region_index, + .crop_bbox = options.crop_bbox, + .crop_image_sha256 = options.crop_image_sha256, + .crop_image_byte_length = options.crop_image_byte_length, .status = options.status, .page_bbox = options.page_bbox, .failure_stage = options.failure_stage, @@ -2455,6 +2637,10 @@ fn appendOcrAttempt( .lang = lang, .grayscale = config.rasterize_grayscale, .timeout_ms = config.timeout_ms, + .max_image_pixels = config.max_image_pixels, + .max_image_bytes = config.max_image_bytes, + .max_regions_per_page = config.max_regions_per_page, + .max_attempts_per_page = config.max_attempts_per_page, .duration_ms = options.duration_ms, .pixel_width = options.pixel_width, .pixel_height = options.pixel_height, @@ -2920,3 +3106,110 @@ test "adaptive owned lists are safe across every allocation failure" { .{}, ); } + +test "regional OCR attempts preserve route identity and enforce per-page invocation cap" { + const FakeDocument = struct { + calls: usize = 0, + pub fn rasterizeOcrRegionDetailed(self: *@This(), _: std.mem.Allocator, _: usize, _: BBox, _: ocr.OcrConfig) !ocr.RasterizeOutcome { + self.calls += 1; + return .{ .status = .unavailable, .diagnostic_code = .rasterizer_unavailable }; + } + pub fn rasterizePageForOcrDetailed(self: *@This(), _: std.mem.Allocator, _: usize, _: ocr.OcrConfig) !ocr.RasterizeOutcome { + self.calls += 1; + return .{ .status = .unavailable, .diagnostic_code = .rasterizer_unavailable }; + } + }; + var doc = FakeDocument{}; + const allocator = std.testing.allocator; + var attempts: std.ArrayList(OcrAttempt) = .empty; + defer { + freeOcrAttemptItems(allocator, attempts.items); + attempts.deinit(allocator); + } + var count: usize = 0; + const bbox = BBox{ .x0 = 10, .y0 = 20, .x1 = 50, .y1 = 60 }; + try std.testing.expectEqual(@as(?[]layout.TextSpan, null), try recognizeOcrTarget(&doc, allocator, &attempts, .{ .enable_ocr = false }, 2, bbox, 37, &.{}, &.{}, &count)); + try std.testing.expectEqual(@as(usize, 0), doc.calls); + try std.testing.expectEqual(@as(?u32, 37), attempts.items[0].region_index); + try std.testing.expectEqual(ocr.DiagnosticCode.ocr_disabled, attempts.items[0].diagnostic_code.?); + _ = try recognizeOcrTarget(&doc, allocator, &attempts, .{ .ocr_config = .{ .max_attempts_per_page = 0 } }, 2, bbox, 38, &.{}, &.{}, &count); + try std.testing.expectEqual(@as(usize, 0), doc.calls); + try std.testing.expectEqual(@as(?u32, 38), attempts.items[1].region_index); + try std.testing.expectEqual(ocr.DiagnosticCode.ocr_attempt_limit, attempts.items[1].diagnostic_code.?); + _ = try recognizeOcrTarget(&doc, allocator, &attempts, .{ .ocr_config = .{ .max_attempts_per_page = 1 } }, 2, bbox, 39, &.{}, &.{}, &count); + try std.testing.expectEqual(@as(usize, 1), doc.calls); + try std.testing.expectEqual(@as(?u32, 39), attempts.items[2].region_index); + try std.testing.expectEqual(ocr.AttemptStatus.unavailable, attempts.items[2].status); + _ = try recognizeOcrTarget(&doc, allocator, &attempts, .{ .ocr_config = .{ .max_attempts_per_page = 1 } }, 2, bbox, 40, &.{}, &.{}, &count); + try std.testing.expectEqual(@as(usize, 1), doc.calls); + try std.testing.expectEqual(ocr.DiagnosticCode.ocr_attempt_limit, attempts.items[3].diagnostic_code.?); + try std.testing.expect(!attempts.items[3].selected); +} + +test "native-good minority hidden text without images keeps its independent OCR route" { + var hidden = testSpan("Hidden text", 10, 10, 110, 20); + hidden.paint = .{ .rendering_mode = 3, .paint_state = .non_painting }; + const spans = [_]layout.TextSpan{ + testSpan("Readable native paragraph with substantial ordinary text", 10, 100, 500, 300), + testSpan("Readable native paragraph with substantial ordinary text", 10, 300, 500, 500), + testSpan("Readable native paragraph with substantial ordinary text", 10, 500, 500, 700), + hidden, + }; + const input = complexity.PageInput{ .page_index = 0, .bbox = .{ .x0 = 0, .y0 = 0, .x1 = 612, .y1 = 792 }, .spans = &spans }; + const page_route = PageRoute.fromScore(complexity.scorePage(input)); + try std.testing.expect(page_route.signals.hidden_ocr < 0.45); + try std.testing.expect(!usesRegionalOcr(page_route, true)); + var region = RegionRoute.fromScore(.{ .region_index = 0, .score = complexity.scoreRegion(input, hidden.bbox) }); + try std.testing.expect(region.route.needs_ocr); + suppressDerivedOcr(®ion, input, &.{}); + try std.testing.expect(region.route.needs_ocr); +} + +test "a crop does not explain independent hidden or bad-Unicode recovery" { + var hidden = testSpan("Hidden text", 10, 10, 110, 20); + hidden.paint = .{ .rendering_mode = 3, .paint_state = .non_painting }; + const images = [_]complexity.ImageBox{.{ .bbox = .{ .x0 = 300, .y0 = 400, .x1 = 500, .y1 = 600 } }}; + const input = complexity.PageInput{ .page_index = 0, .bbox = .{ .x0 = 0, .y0 = 0, .x1 = 612, .y1 = 792 }, .spans = &.{hidden}, .images = &images }; + const crop = RegionRoute.fromScore(.{ .region_index = 6, .score = complexity.scoreRegion(input, images[0].bbox) }); + var target = RegionRoute.fromScore(.{ .region_index = 7, .score = complexity.scoreRegion(input, hidden.bbox) }); + try std.testing.expect(target.route.needs_ocr); + suppressDerivedOcr(&target, input, &.{crop}); + try std.testing.expect(target.route.needs_ocr); + hidden.paint = .{}; + hidden.unicode_map_error = true; + const broken_input = complexity.PageInput{ .page_index = 0, .bbox = input.bbox, .spans = &.{hidden}, .images = &images }; + var broken = RegionRoute.fromScore(.{ .region_index = 8, .score = complexity.scoreRegion(broken_input, hidden.bbox) }); + try std.testing.expect(broken.route.needs_ocr); + suppressDerivedOcr(&broken, broken_input, &.{crop}); + try std.testing.expect(broken.route.needs_ocr); + var duplicate = crop; + suppressDerivedOcr(&duplicate, input, &.{crop}); + try std.testing.expect(!duplicate.route.needs_ocr); + var unowned = crop; + suppressDerivedOcr(&unowned, input, &.{}); + try std.testing.expect(unowned.route.needs_ocr); +} + +test "recognized crop metadata does not request OCR again over readable native text" { + var native = testSpan("Native cover text", 60, 730, 190, 750); + native.font.has_to_unicode = false; + const images = [_]complexity.ImageBox{.{ .bbox = .{ .x0 = 46, .y0 = 30, .x1 = 566, .y1 = 720 } }}; + const original = complexity.PageInput{ .page_index = 0, .bbox = .{ .x0 = 0, .y0 = 0, .x1 = 612, .y1 = 792 }, .spans = &.{native}, .images = &images }; + const owner = RegionRoute.fromScore(.{ .region_index = 0, .score = complexity.scoreRegion(original, images[0].bbox) }); + var recognized = testSpan("Recovered image text", 65, 590, 450, 625); + recognized.source = .fresh_ocr; + recognized.font.name = "TesseractOCR"; + recognized.confidence = 0.8; + const enriched = complexity.PageInput{ .page_index = 0, .bbox = original.bbox, .spans = &.{ native, recognized }, .images = &images }; + var derived = RegionRoute.fromScore(.{ .region_index = 1, .score = complexity.scoreRegion(enriched, .{ .x0 = 60, .y0 = 580, .x1 = 450, .y1 = 750 }) }); + try std.testing.expect(derived.route.needs_ocr); + try std.testing.expect(derived.signals.hidden_ocr >= 0.45); + try std.testing.expect(derived.signals.bad_unicode >= 0.35); + suppressDerivedOcr(&derived, original, &.{owner}); + try std.testing.expect(!derived.route.needs_ocr); + native.unicode_map_error = true; + const damaged = complexity.PageInput{ .page_index = 0, .bbox = original.bbox, .spans = &.{native}, .images = &images }; + var substantive = RegionRoute.fromScore(.{ .region_index = 2, .score = complexity.scoreRegion(enriched, derived.bbox) }); + suppressDerivedOcr(&substantive, damaged, &.{owner}); + try std.testing.expect(substantive.route.needs_ocr); +} diff --git a/src/complexity.zig b/src/complexity.zig index 3586e3a..176e694 100644 --- a/src/complexity.zig +++ b/src/complexity.zig @@ -210,9 +210,8 @@ const RegionStats = struct { self.unknown_tounicode_fonts += 1; } - if (text_span.font.name) |name| { - if (looksLikeHiddenOcrFont(name)) self.hidden_ocr_spans += 1; - } + if (text_span.paint.paint_state == .non_painting or + (if (text_span.font.name) |name| looksLikeHiddenOcrFont(name) else false)) self.hidden_ocr_spans += 1; if (prev_seen) { const line_threshold = @max(3.0, @max(prev_font_size, text_span.font_size) * 0.75); @@ -600,6 +599,18 @@ test "bad unicode and hidden OCR layer are OCR signals" { try std.testing.expect(page.score.route.needs_ocr); } +test "ordinary font nonpainting text contributes hidden-layer evidence without changing source" { + var text_span = span("Hidden ordinary font", 20, 20, 200, 40); + text_span.font.name = "Ordinary"; + text_span.paint = .{ .rendering_mode = 3, .paint_state = .non_painting, .clip_state = .unclipped }; + const hidden = scorePage(.{ .bbox = .{ .x0 = 0, .y0 = 0, .x1 = 612, .y1 = 792 }, .spans = &.{text_span} }); + try std.testing.expectEqual(@as(f32, 1), hidden.score.signals.hidden_ocr); + try std.testing.expectEqual(layout.SourceKind.native_pdf, text_span.source); + text_span.paint = .{ .rendering_mode = 0, .paint_state = .painting, .clip_state = .outside }; + const clipped = scorePage(.{ .bbox = .{ .x0 = 0, .y0 = 0, .x1 = 612, .y1 = 792 }, .spans = &.{text_span} }); + try std.testing.expectEqual(@as(f32, 0), clipped.score.signals.hidden_ocr); +} + test "dense native text layer does not OCR solely for broken Unicode metadata" { const prose = "Clinical history and diagnosis remain readable in the native text layer despite incomplete font metadata."; const spans = [_]TextSpan{ diff --git a/src/coverage.zig b/src/coverage.zig index aeba54c..2fc7165 100644 --- a/src/coverage.zig +++ b/src/coverage.zig @@ -13,6 +13,9 @@ pub const Assessment = struct { completeness: Completeness = .unverified, native_span_count: usize = 0, recognized_span_count: usize = 0, + non_painting_span_count: usize = 0, + clipped_out_span_count: usize = 0, + uncertain_paint_span_count: usize = 0, requested_recovery: bool = false, recovery_state: RecoveryState = .not_required, unresolved_region_count: usize = 0, @@ -23,7 +26,7 @@ pub fn assessRegion(result: anytype, route: anytype) Assessment { var unresolved = false; if (route.route.needs_ocr) { assessment.requested_recovery = true; - const state = ocrState(result, route.page_index); + const state = ocrState(result, route.page_index, route.region_index, route.bbox); assessment.recovery_state = combine(assessment.recovery_state, state); unresolved = state != .completed or countRecognized(result, route.page_index, route.bbox, .fresh_ocr) == 0; } @@ -43,8 +46,17 @@ pub fn assessPage(result: anytype, page_index: u32) Assessment { var unresolved = false; for (result.page_routes) |route| { if (route.page_index != page_index or !route.route.needs_ocr) continue; + // Regional attempts can satisfy a page OCR decision. Assess each + // routed target below rather than inventing an absent page failure. + var page_attempt = false; + var regional_attempt = false; + for (result.ocr_attempts) |attempt| { + if (attempt.page_index != page_index) continue; + if (attempt.region_index == null) page_attempt = true else regional_attempt = true; + } + if (!page_attempt and regional_attempt) continue; assessment.requested_recovery = true; - const state = ocrState(result, page_index); + const state = ocrState(result, page_index, null, null); assessment.recovery_state = combine(assessment.recovery_state, state); unresolved = state != .completed or countRecognized(result, page_index, null, .fresh_ocr) == 0; } @@ -89,6 +101,9 @@ fn countSpans(result: anytype, page_index: u32, bbox: ?layout.BBox) Assessment { assessment.status = .text_available; if (reconcile.hasSource(span.source_mask, .native_pdf) or reconcile.hasSource(span.source_mask, .embedded_ocr)) assessment.native_span_count += 1; if (reconcile.hasSource(span.source_mask, .fresh_ocr) or reconcile.hasSource(span.source_mask, .formula_model)) assessment.recognized_span_count += 1; + if (span.span.paint.paint_state == .non_painting) assessment.non_painting_span_count += 1; + if (span.span.paint.clip_state == .outside) assessment.clipped_out_span_count += 1; + if (span.span.paint.paint_state == .unknown or span.span.paint.clip_state == .unknown or span.span.paint.clip_state == .partial) assessment.uncertain_paint_span_count += 1; } return assessment; } @@ -101,10 +116,12 @@ fn countRecognized(result: anytype, page_index: u32, bbox: ?layout.BBox, source: return count; } -fn ocrState(result: anytype, page_index: u32) RecoveryState { +fn ocrState(result: anytype, page_index: u32, region_index: ?u32, bbox: ?layout.BBox) RecoveryState { var state: RecoveryState = .not_invoked; + var found = false; for (result.ocr_attempts) |attempt| { - if (attempt.page_index != page_index) continue; + if (attempt.page_index != page_index or attempt.region_index != region_index) continue; + found = true; state = switch (attempt.status) { .not_invoked => .not_invoked, .completed => .completed, @@ -116,6 +133,21 @@ fn ocrState(result: anytype, page_index: u32) RecoveryState { }; if (attempt.selected) return state; } + if (!found and region_index != null) { + for (result.ocr_attempts) |attempt| { + if (attempt.page_index == page_index and attempt.region_index == null) return ocrState(result, page_index, null, null); + } + // Derived layout regions can be explained by a successful image crop, + // but a disjoint or only partly covering crop cannot qualify recovery. + if (bbox) |region| { + for (result.ocr_attempts) |attempt| { + if (attempt.page_index != page_index or !attempt.selected or attempt.status != .completed) continue; + if (attempt.crop_bbox) |crop| { + if (crop.x0 <= region.x0 and crop.y0 <= region.y0 and crop.x1 >= region.x1 and crop.y1 >= region.y1) return .completed; + } + } + } + } // A nonselected completed attempt is insufficient to attribute output to it. return if (state == .completed) .not_invoked else state; } @@ -160,7 +192,7 @@ const TestRoute = struct { route: struct { needs_ocr: bool = false, needs_formula_model: bool = false, needs_table_model: bool = false } = .{}, }; const TestOcrStatus = enum { not_invoked, completed, empty, unavailable, failed, timeout, invalid_output }; -const TestOcrAttempt = struct { page_index: u32 = 0, status: TestOcrStatus, selected: bool = false }; +const TestOcrAttempt = struct { page_index: u32 = 0, region_index: ?u32 = null, crop_bbox: ?layout.BBox = null, status: TestOcrStatus, selected: bool = false }; const TestFormulaStatus = enum { completed, empty, unavailable, failed, timeout, invalid_output }; const TestFormulaAttempt = struct { page_index: u32 = 0, region_index: u32 = 0, status: TestFormulaStatus }; const TestResult = struct { @@ -180,6 +212,37 @@ fn testSpan(source: layout.SourceKind, bbox: layout.BBox) reconcile.ReconciledSp }; } +test "regional OCR recovery scopes include derived regions but exclude disjoint or skipped targets" { + const recognized = testSpan(.fresh_ocr, .{ .x1 = 10, .y1 = 10 }); + const regions = [_]TestRoute{ + .{ .region_index = 0, .bbox = .{ .x1 = 20, .y1 = 20 }, .route = .{ .needs_ocr = true } }, + .{ .region_index = 1, .bbox = .{ .x1 = 10, .y1 = 10 }, .route = .{ .needs_ocr = true } }, + }; + var result = TestResult{ .page_routes = &.{.{ .route = .{ .needs_ocr = true } }}, .region_routes = ®ions, .ocr_attempts = &.{.{ .region_index = 0, .crop_bbox = .{ .x1 = 20, .y1 = 20 }, .selected = true, .status = .completed }}, .reconciled = .{ .spans = &.{recognized} } }; + try std.testing.expectEqual(Status.recovered, assessPage(result, 0).status); + const disjoint = TestRoute{ .region_index = 2, .bbox = .{ .x0 = 50, .y0 = 50, .x1 = 100, .y1 = 100 }, .route = .{ .needs_ocr = true } }; + try std.testing.expectEqual(Status.recovery_required, assessRegion(result, disjoint).status); + result.ocr_attempts = &.{ + .{ .region_index = 0, .crop_bbox = .{ .x1 = 20, .y1 = 20 }, .selected = true, .status = .completed }, + .{ .region_index = 1, .crop_bbox = .{ .x1 = 10, .y1 = 10 }, .status = .not_invoked }, + }; + try std.testing.expectEqual(Status.recovery_required, assessPage(result, 0).status); + try std.testing.expectEqual(@as(usize, 1), assessPage(result, 0).unresolved_region_count); +} + +test "coverage retains nonpainting clipped and uncertain evidence counts" { + var hidden = testSpan(.native_pdf, .{ .x1 = 10, .y1 = 10 }); + hidden.span.paint = .{ .rendering_mode = 3, .paint_state = .non_painting, .clip_state = .unclipped }; + var clipped = testSpan(.native_pdf, .{ .x1 = 10, .y1 = 10 }); + clipped.span.paint = .{ .rendering_mode = 0, .paint_state = .painting, .clip_state = .outside, .clip_bbox = .{ .x0 = 20, .y0 = 20, .x1 = 30, .y1 = 30 } }; + const unknown = testSpan(.manual, .{ .x1 = 10, .y1 = 10 }); + const assessed = assessPage(TestResult{ .reconciled = .{ .spans = &.{ hidden, clipped, unknown } } }, 0); + try std.testing.expectEqual(@as(usize, 1), assessed.non_painting_span_count); + try std.testing.expectEqual(@as(usize, 1), assessed.clipped_out_span_count); + try std.testing.expectEqual(@as(usize, 1), assessed.uncertain_paint_span_count); + try std.testing.expectEqual(Completeness.unverified, assessed.completeness); +} + test "coverage never proves blankness or completeness from text presence" { const empty = assessPage(TestResult{}, 0); try std.testing.expectEqual(Status.empty_unverified, empty.status); diff --git a/src/encoding.zig b/src/encoding.zig index 773ba43..7e56405 100644 --- a/src/encoding.zig +++ b/src/encoding.zig @@ -1038,6 +1038,10 @@ fn applyBase14FallbackMetrics(encoding: *FontEncoding, base_font_raw: []const u8 if (fontNameContains(base_font, "Helvetica")) { fillRepresentativeWidths(&encoding.widths.simple_widths, .helvetica); + // Standard ASCII metrics include every letter: using the default + // width for C or D creates geometric gaps inside an ordinary word. + const ascii_widths = if (fontNameContains(base_font, "Bold")) helvetica_bold_ascii_widths else helvetica_ascii_widths; + for (ascii_widths, 32..) |width, code| encoding.widths.simple_widths[code] = width; encoding.widths.default_width = 556; encoding.metrics.ascender = 718; encoding.metrics.descender = -207; @@ -1064,6 +1068,9 @@ fn applyBase14FallbackMetrics(encoding: *FontEncoding, base_font_raw: []const u8 const Base14Kind = enum { helvetica, times, symbol, zapf_dingbats }; +const helvetica_ascii_widths = [_]f64{ 278, 278, 355, 556, 556, 889, 667, 222, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 222, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584 }; +const helvetica_bold_ascii_widths = [_]f64{ 278, 333, 474, 556, 556, 889, 722, 278, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 278, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584 }; + fn fillRepresentativeWidths(widths: *[256]f64, kind: Base14Kind) void { switch (kind) { .helvetica => { @@ -2271,13 +2278,15 @@ const standard_encoding = blk: { break :blk table; }; -const pdf_doc_encoding = blk: { +/// PDF text-string encoding; zero marks an undefined byte for callers to +/// handle explicitly. Font decoding keeps its existing unresolved-glyph policy. +pub const pdf_doc_encoding: [256]u21 = blk: { var table: [256]u21 = undefined; - // Start with WinAnsi - table = win_ansi_encoding; + // ASCII and Latin-1 ranges, with the PDF-specific exceptions below. + // Preserve legacy control values, including tab, linefeed and carriage return. + for (&table, 0..) |*codepoint, byte| codepoint.* = @intCast(byte); - // PDFDocEncoding differs in a few places table[0x18] = 0x02D8; // breve table[0x19] = 0x02C7; // caron table[0x1A] = 0x02C6; // circumflex @@ -2286,6 +2295,17 @@ const pdf_doc_encoding = blk: { table[0x1D] = 0x02DB; // ogonek table[0x1E] = 0x02DA; // ring table[0x1F] = 0x02DC; // tilde + const extended = [_]u21{ + 0x2022, 0x2020, 0x2021, 0x2026, 0x2014, 0x2013, 0x0192, 0x2044, + 0x2039, 0x203a, 0x2212, 0x2030, 0x201e, 0x201c, 0x201d, 0x2018, + 0x2019, 0x201a, 0x2122, 0xfb01, 0xfb02, 0x0141, 0x0152, 0x0160, + 0x0178, 0x017d, 0x0131, 0x0142, 0x0153, 0x0161, 0x017e, + }; + for (extended, 0x80..) |codepoint, byte| table[byte] = codepoint; + table[0x7f] = 0; + table[0x9f] = 0; + table[0xad] = 0; + table[0xa0] = 0x20ac; // Euro, not Latin-1 nonbreaking space break :blk table; }; @@ -2305,6 +2325,27 @@ test "WinAnsi decode ASCII" { try std.testing.expectEqualStrings("Hello", output.items); } +test "PDFDocEncoding extended characters follow independent Unicode golden text" { + const bytes = [_]u8{ 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0xa0, 0xe9, 0xff }; + var enc = FontEncoding.init(std.testing.allocator); + defer enc.deinit(); + applyNamedEncoding(&enc, "PDFDocEncoding"); + var output: std.ArrayList(u8) = .empty; + defer output.deinit(std.testing.allocator); + try enc.decode(&bytes, runtime.arrayListWriter(&output, std.testing.allocator)); + try std.testing.expectEqualStrings("•†‡…—–ƒ⁄‹›−‰„“”‘’‚™fiflŁŒŠŸŽıłœšž€éÿ", output.items); +} + +test "PDFDocEncoding marks undefined bytes without inventing Unicode mappings" { + for ([_]u8{ 0x7f, 0x9f, 0xad }) |byte| try std.testing.expectEqual(@as(u21, 0), pdf_doc_encoding[byte]); + try std.testing.expectEqual(@as(u21, '\t'), pdf_doc_encoding[9]); + try std.testing.expectEqual(@as(u21, '\n'), pdf_doc_encoding[10]); + try std.testing.expectEqual(@as(u21, '\r'), pdf_doc_encoding[13]); + try std.testing.expectEqual(@as(u21, 0x02d8), pdf_doc_encoding[0x18]); + try std.testing.expectEqual(@as(u21, 0x02dc), pdf_doc_encoding[0x1f]); + try std.testing.expectEqual(@as(u21, 'A'), pdf_doc_encoding['A']); +} + test "WinAnsi decode extended" { var enc = FontEncoding.init(std.testing.allocator); defer enc.deinit(); @@ -3048,6 +3089,21 @@ test "Base14 fallback metrics and symbolic encodings" { try std.testing.expectEqual(@as(u21, 0x03C0), symbol.simple_encoding.codepoints['p']); } +test "Helvetica fallback ASCII advances retain word geometry and bold distinctions" { + var regular = FontEncoding.init(std.testing.allocator); + defer regular.deinit(); + applyBase14FallbackMetrics(®ular, "Helvetica"); + try std.testing.expectEqual(@as(f64, 667), regular.widths.getWidth('B')); + try std.testing.expectEqual(@as(f64, 722), regular.widths.getWidth('C')); + try std.testing.expectEqual(@as(f64, 722), regular.widths.getWidth('D')); + try std.testing.expectEqual(@as(f64, 611), regular.widths.getWidth('F')); + var bold = FontEncoding.init(std.testing.allocator); + defer bold.deinit(); + applyBase14FallbackMetrics(&bold, "ABCDEE+Helvetica-BoldOblique"); + try std.testing.expectEqual(@as(f64, 722), bold.widths.getWidth('B')); + try std.testing.expectEqual(@as(f64, 278), bold.widths.getWidth('i')); +} + test "predefined CJK CMaps set width and writing mode conservatively" { var enc = FontEncoding.init(std.testing.allocator); defer enc.deinit(); diff --git a/src/form_scope.zig b/src/form_scope.zig new file mode 100644 index 0000000..cbcdbd0 --- /dev/null +++ b/src/form_scope.zig @@ -0,0 +1,74 @@ +//! Content-stream identity and deterministic bounds for recursive Form traversal. +const std = @import("std"); + +pub const Scope = struct { + page_index: u32, + stream_object_num: ?u32 = null, + stream_generation: u16 = 0, + invocation_index: u32 = 0, + + pub fn page(page_index: u32) Scope { + return .{ .page_index = page_index }; + } +}; + +pub const Reference = struct { object_num: u32, generation: u16 = 0 }; + +/// Keep one traversal per page/extraction. Repeated placements are valid after +/// leaving a frame; recursion through an already active stream is a cycle. +pub const Traversal = struct { + pub const max_depth = 10; + page_index: u32, + max_invocations: u32 = 10_000, + invocation_count: u32 = 0, + active: [max_depth]Reference = undefined, + depth: usize = 0, + + pub fn enter(self: *Traversal, reference: Reference) !Scope { + for (self.active[0..self.depth]) |parent| { + if (std.meta.eql(parent, reference)) return error.FormCycleDetected; + } + if (self.depth == max_depth) return error.FormDepthLimitExceeded; + if (self.invocation_count >= self.max_invocations) return error.FormInvocationLimitExceeded; + self.invocation_count += 1; + self.active[self.depth] = reference; + self.depth += 1; + return .{ + .page_index = self.page_index, + .stream_object_num = reference.object_num, + .stream_generation = reference.generation, + .invocation_index = self.invocation_count, + }; + } + + pub fn leave(self: *Traversal) void { + std.debug.assert(self.depth > 0); + self.depth -= 1; + } +}; + +test "Form scopes distinguish repeated placement and reject only active cycles" { + var traversal = Traversal{ .page_index = 7 }; + const first = try traversal.enter(.{ .object_num = 42, .generation = 2 }); + try std.testing.expectError(error.FormCycleDetected, traversal.enter(.{ .object_num = 42, .generation = 2 })); + const other_generation = try traversal.enter(.{ .object_num = 42, .generation = 3 }); + try std.testing.expectEqual(@as(u16, 3), other_generation.stream_generation); + traversal.leave(); + traversal.leave(); + const second = try traversal.enter(.{ .object_num = 42, .generation = 2 }); + defer traversal.leave(); + try std.testing.expectEqual(@as(u32, 7), second.page_index); + try std.testing.expectEqual(first.stream_object_num, second.stream_object_num); + try std.testing.expect(first.invocation_index != second.invocation_index); +} + +test "Form traversal depth and invocation bounds fail explicitly without pushing" { + var traversal = Traversal{ .page_index = 0 }; + for (0..Traversal.max_depth) |index| _ = try traversal.enter(.{ .object_num = @intCast(index) }); + try std.testing.expectError(error.FormDepthLimitExceeded, traversal.enter(.{ .object_num = 99 })); + try std.testing.expectEqual(Traversal.max_depth, traversal.depth); + while (traversal.depth > 0) traversal.leave(); + traversal.max_invocations = traversal.invocation_count; + try std.testing.expectError(error.FormInvocationLimitExceeded, traversal.enter(.{ .object_num = 99 })); + try std.testing.expectEqual(@as(usize, 0), traversal.depth); +} diff --git a/src/integration_test.zig b/src/integration_test.zig index 9880e80..f2d47a8 100644 --- a/src/integration_test.zig +++ b/src/integration_test.zig @@ -218,7 +218,7 @@ test "structured extraction preserves Form XObject text omitted by partial tags" const output = try doc.extractTextStructured(0, allocator); defer allocator.free(output); try std.testing.expectEqualStrings( - "Tagged main Form-only recall text", + "Tagged main\n\nForm-only recall text", std.mem.trim(u8, output, " \t\r\n\x0c"), ); @@ -698,7 +698,7 @@ test "versioned schema renders native document manifest spans blocks chunks and const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{}); defer parsed.deinit(); try std.testing.expectEqualStrings("document_manifest", parsed.value.object.get("schema_name").?.string); - try std.testing.expectEqualStrings("0.15.0", parsed.value.object.get("schema_version").?.string); + try std.testing.expectEqualStrings("0.17.0", parsed.value.object.get("schema_version").?.string); try std.testing.expectEqualStrings("0.4.0-dev", parsed.value.object.get("parser_version").?.string); try std.testing.expectEqualStrings("document_manifest", parsed.value.object.get("record_type").?.string); try std.testing.expectEqualStrings("external-clean-native", parsed.value.object.get("source_id").?.string); @@ -744,7 +744,7 @@ test "versioned schema renders native document manifest spans blocks chunks and const debug_assets = parsed.value.object.get("debug_assets").?.array.items; try std.testing.expect(debug_assets.len > 0); try expectProvenanceObject(debug_assets[0]); - try std.testing.expectEqualStrings("0.15.0", debug_assets[0].object.get("schema_version").?.string); + try std.testing.expectEqualStrings("0.17.0", debug_assets[0].object.get("schema_version").?.string); try std.testing.expect(debug_assets[0].object.get("asset_kind") != null); try std.testing.expect(debug_assets[0].object.get("path") != null); try std.testing.expectEqual(.null, debug_assets[0].object.get("path").?); @@ -755,7 +755,7 @@ test "versioned schema renders native document manifest spans blocks chunks and try std.testing.expectEqualStrings("debug", debug_assets[0].object.get("provenance").?.object.get("source_kind").?.string); try std.testing.expect(std.mem.indexOf(u8, json, "\"schema_name\":\"document_manifest\"") != null); - try std.testing.expect(std.mem.indexOf(u8, json, "\"schema_version\":\"0.15.0\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"schema_version\":\"0.17.0\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"record_type\":\"span\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"record_type\":\"block\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"record_type\":\"rag_chunk\"") != null); @@ -879,7 +879,7 @@ test "versioned artifact jsonl starts with manifest then typed records" { try std.testing.expect(manifest.value.object.get("output_artifacts") != null); try std.testing.expect(manifest.value.object.get("extraction_counts") != null); try std.testing.expect(manifest.value.object.get("capability_coverage") != null); - try std.testing.expect(std.mem.indexOf(u8, jsonl[first_newline + 1 ..], "\"schema_version\":\"0.15.0\"") != null); + try std.testing.expect(std.mem.indexOf(u8, jsonl[first_newline + 1 ..], "\"schema_version\":\"0.17.0\"") != null); try std.testing.expect(std.mem.indexOf(u8, jsonl[first_newline + 1 ..], "\"record_type\":\"span\"") != null); try std.testing.expect(std.mem.indexOf(u8, jsonl, "\"record_type\":\"route_trace\"") != null); try expectJsonlLinesParse(allocator, jsonl); @@ -911,7 +911,7 @@ test "specialist protocol emits batch request records for routed table formula r const requests = parsed.value.object.get("specialist_requests").?.array.items; try std.testing.expect(requests.len > 0); try expectProvenanceObject(requests[0]); - try std.testing.expectEqualStrings("0.15.0", requests[0].object.get("schema_version").?.string); + try std.testing.expectEqualStrings("0.17.0", requests[0].object.get("schema_version").?.string); try std.testing.expectEqualStrings("specialist_request", requests[0].object.get("record_type").?.string); try std.testing.expectEqualStrings("external-specialist", requests[0].object.get("source_id").?.string); try std.testing.expect(requests[0].object.get("requested_kind") != null); @@ -990,7 +990,7 @@ test "configured formula specialist produces owned linked lifecycle records" { \\request_id=$(printf '%s' "$request" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p') \\crop=$(printf '%s' "$request" | sed -n 's/.*"crop_image_path":"\([^"]*\)".*/\1/p') \\test -n "$crop" && test -f "$crop" || exit 7 - \\printf '{"schema_version":"0.15.0","record_type":"specialist_response","request_id":"%s","specialist_id":"fixture-formula","specialist_kind":"formula","status":"completed","formulas":[{"text":"x^2+y^2=z^2","format":"latex","confidence":0.95}]}\n' "$request_id" + \\printf '{"schema_version":"0.17.0","record_type":"specialist_response","request_id":"%s","specialist_id":"fixture-formula","specialist_kind":"formula","status":"completed","formulas":[{"text":"x^2+y^2=z^2","format":"latex","confidence":0.95}]}\n' "$request_id" \\ ); runtime.closeFile(script); @@ -1066,7 +1066,7 @@ test "configured formula specialist produces owned linked lifecycle records" { const formulas = response.object.get("formulas").?.array.items; try std.testing.expectEqual(@as(usize, 1), formulas.len); try std.testing.expectEqualStrings("formula", formulas[0].object.get("schema_name").?.string); - try std.testing.expectEqualStrings("0.15.0", formulas[0].object.get("schema_version").?.string); + try std.testing.expectEqualStrings("0.17.0", formulas[0].object.get("schema_version").?.string); try std.testing.expectEqualStrings("formula", formulas[0].object.get("record_type").?.string); try std.testing.expectEqualStrings("source-formula", formulas[0].object.get("source_id").?.string); try expectProvenanceObject(formulas[0]); @@ -1466,7 +1466,7 @@ test "versioned schema exposes financial table cell span metadata" { const tables = parsed.value.object.get("tables").?.array.items; try std.testing.expect(tables.len > 0); try expectProvenanceObject(tables[0]); - try std.testing.expectEqualStrings("0.15.0", tables[0].object.get("schema_version").?.string); + try std.testing.expectEqualStrings("0.17.0", tables[0].object.get("schema_version").?.string); try std.testing.expect(tables[0].object.get("logical_table_id") != null); try std.testing.expect(tables[0].object.get("table_part_index") != null); try std.testing.expect(tables[0].object.get("continued_from_table_id") != null); @@ -1476,7 +1476,7 @@ test "versioned schema exposes financial table cell span metadata" { const cells = tables[0].object.get("rows").?.array.items[0].object.get("cells").?.array.items; try std.testing.expect(cells.len > 0); try std.testing.expectEqualStrings("table_cell", cells[0].object.get("schema_name").?.string); - try std.testing.expectEqualStrings("0.15.0", cells[0].object.get("schema_version").?.string); + try std.testing.expectEqualStrings("0.17.0", cells[0].object.get("schema_version").?.string); try std.testing.expect(cells[0].object.get("cell_id") != null); try std.testing.expectEqualStrings("external-merged-cells", cells[0].object.get("source_id").?.string); try std.testing.expect(cells[0].object.get("raw_text") != null); @@ -2208,14 +2208,33 @@ test "detailed search preserves Form XObject inventory recall" { try std.testing.expectEqual(@as(usize, 1), report.matches.len); try std.testing.expectEqual(@as(usize, 0), report.matches[0].page); - try std.testing.expect( - report.matches[0].source_kind == .legacy_structured or - report.matches[0].source_kind == .full_context_text, - ); - try std.testing.expect(report.matches[0].bbox == null); - try std.testing.expect(report.matches[0].normalized_offset != null); - try std.testing.expect(report.matches[0].source_start != null); - try std.testing.expect(report.matches[0].source_end != null); + // Native Form traversal now supplies geometry instead of relying on a + // page-text inventory match with no placement evidence. + try std.testing.expect(report.matches[0].source_kind == .native_reading or report.matches[0].source_kind == .native_content); + try std.testing.expect(report.matches[0].bbox != null); + try std.testing.expect(report.matches[0].first_glyph_index != null); + // Reconstructed glyph views expose placement and indices; they do not + // claim stable offsets into a separately retained page-text projection. + try std.testing.expect(report.matches[0].normalized_offset == null); + try std.testing.expect(report.matches[0].source_start == null); + try std.testing.expect(report.matches[0].source_end == null); +} + +test "semantic search retains ActualText union geometry without fabricated glyph indices" { + const allocator = std.testing.allocator; + const pdf_data = try testpdf.generateActualTextRepairPdf(allocator); + defer allocator.free(pdf_data); + const doc = try zpdf.Document.openFromMemory(allocator, pdf_data, zpdf.ErrorConfig.permissive()); + defer doc.close(); + var report = try doc.searchDetailed(allocator, "ActualText replacement", .{}); + defer report.deinit(); + try std.testing.expectEqual(@as(usize, 1), report.matches.len); + try std.testing.expect(report.matches[0].bbox != null); + try std.testing.expect(report.matches[0].first_glyph_index == null); + try std.testing.expect(report.matches[0].last_glyph_index == null); + var superseded = try doc.searchDetailed(allocator, "WRONGVISIBLE", .{}); + defer superseded.deinit(); + try std.testing.expectEqual(@as(usize, 0), superseded.matches.len); } test "detailed search reports pages considered for an empty normalized query" { @@ -2301,6 +2320,13 @@ test "decodePdfString - plain ASCII passthrough" { try std.testing.expectEqualStrings("Hello", result); } +test "decodePdfString - PDFDocEncoding accents punctuation and undefined bytes" { + const allocator = std.testing.allocator; + const result = try zpdf.decodePdfString(allocator, &.{ 0x18, 0x80, 0x8d, 0x8e, 0x93, 0x94, 0xa0, 0x7f, 0x9f, 0xad }); + defer allocator.free(result); + try std.testing.expectEqualStrings("˘•“”fifl€���", result); +} + test "decodePdfString - empty string" { const allocator = std.testing.allocator; const result = try zpdf.decodePdfString(allocator, ""); diff --git a/src/interpreter.zig b/src/interpreter.zig index 49b5311..6e3921e 100644 --- a/src/interpreter.zig +++ b/src/interpreter.zig @@ -17,6 +17,8 @@ const encoding_mod = @import("encoding.zig"); const decompress = @import("decompress.zig"); const simd = @import("simd.zig"); const runtime = @import("runtime.zig"); +const paint_mod = @import("paint.zig"); +const form_scope = @import("form_scope.zig"); pub const layout = @import("layout.zig"); pub const TextSpan = layout.TextSpan; @@ -36,6 +38,12 @@ pub const GlyphSpan = struct { source_byte_count: u8 = 0, text: []const u8 = "", advance: f64 = 0, + font_space_advance: f64 = 0, + content_scope: ?form_scope.Scope = null, + raw_glyph_start: ?u32 = null, + raw_glyph_count: u32 = 1, + semantic_replacement: bool = false, + semantic_run_id: ?u32 = null, mapping_source: encoding_mod.MappingSource = .unresolved, unicode_map_error: bool = false, generated: bool = false, @@ -43,6 +51,7 @@ pub const GlyphSpan = struct { actual_text: bool = false, mcid: ?i32 = null, writing_mode: u8 = 0, + paint: layout.PaintEvidence = .{}, }; pub const CharSpan = struct { @@ -61,6 +70,17 @@ pub const CharSpan = struct { actual_text: bool = false, mcid: ?i32 = null, writing_mode: u8 = 0, + paint: layout.PaintEvidence = .{}, + content_scope: ?form_scope.Scope = null, +}; + +/// Authoritative replacement for a contiguous raw glyph range. Text is owned +/// by the collector; glyphs remain raw decoding evidence. +pub const ActualTextRun = struct { + glyph_start: u32, + glyph_count: u32, + text: []const u8, + mcid: ?i32, }; /// Text state within a content stream @@ -317,8 +337,9 @@ pub fn ContentInterpreter(comptime Writer: type) type { fn moveText(self: *Self, tx: f64, ty: f64) !void { // Calculate new position - const new_x = self.state.text.line_matrix[4] + tx; - const new_y = self.state.text.line_matrix[5] + ty; + const matrix = self.state.text.line_matrix; + const new_x = matrix[4] + matrix[0] * tx + matrix[2] * ty; + const new_y = matrix[5] + matrix[1] * tx + matrix[3] * ty; try self.checkLineBreak(new_y); @@ -450,7 +471,11 @@ pub const SpanCollector = struct { current_font_size: f64 = 12, current_font_name: ?[]const u8 = null, current_font_has_to_unicode: ?bool = null, + current_font_is_type3: bool = false, + current_font_space_width: f64 = 0, + current_scope: ?form_scope.Scope = null, current_text_matrix: [6]f64 = .{ 1, 0, 0, 1, 0, 0 }, + graphics_state: ?paint_mod.State = null, current_char_spacing: f64 = 0, current_word_spacing: f64 = 0, current_horizontal_scale: f64 = 100, @@ -464,6 +489,9 @@ pub const SpanCollector = struct { pending_unicode_map_error: bool = false, pending_actual_text: bool = false, current_actual_text: ?[]const u8 = null, + actual_text_runs: std.ArrayList(ActualTextRun) = .empty, + actual_text_glyph_start: usize = 0, + actual_text_mcid: ?i32 = null, avg_char_width: f64 = 0.5, pub fn init(allocator: std.mem.Allocator, page_index: u32) SpanCollector { @@ -474,6 +502,7 @@ pub const SpanCollector = struct { .text_buffer = .empty, .allocator = allocator, .page_index = page_index, + .current_scope = .{ .page_index = page_index }, }; } @@ -495,6 +524,8 @@ pub const SpanCollector = struct { if (char.font_name) |name| self.allocator.free(@constCast(name)); } if (self.current_actual_text) |text| self.allocator.free(@constCast(text)); + for (self.actual_text_runs.items) |run| self.allocator.free(@constCast(run.text)); + self.actual_text_runs.deinit(self.allocator); self.spans.deinit(self.allocator); self.glyphs.deinit(self.allocator); self.chars.deinit(self.allocator); @@ -535,20 +566,75 @@ pub const SpanCollector = struct { self.current_text_matrix = matrix; } + pub fn setGraphicsState(self: *SpanCollector, state: paint_mod.State) void { + self.graphics_state = state; + } + + fn paintEvidence(self: *const SpanCollector, bbox: layout.BBox) layout.PaintEvidence { + var value: layout.PaintEvidence = if (self.graphics_state) |state| state.evidence(bbox) else .{}; + // Type3 glyph programs determine painting in ordinary modes; 3/7 + // still suppress rendering. Their programs are not interpreted here. + if (self.current_font_is_type3 and value.paint_state == .painting) value.paint_state = .unknown; + return value; + } + + fn userTextMatrix(self: *const SpanCollector) [6]f64 { + return paint_mod.concat(if (self.graphics_state) |state| state.ctm else paint_mod.identity, self.current_text_matrix); + } + pub fn setMcid(self: *SpanCollector, mcid: ?i32) void { self.current_mcid = mcid; } + pub fn setContentScope(self: *SpanCollector, scope: ?form_scope.Scope) void { + self.current_scope = scope; + } + pub fn setActualText(self: *SpanCollector, text: ?[]const u8) !void { - if (self.current_actual_text) |existing| { - self.allocator.free(@constCast(existing)); - self.current_actual_text = null; - } + if (self.current_actual_text != null and text != null and std.mem.eql(u8, self.current_actual_text.?, text.?)) return; + if (self.current_actual_text != null) try self.finishActualTextRun(); if (text) |value| { + try self.flush(); self.current_actual_text = try self.allocator.dupe(u8, value); + self.actual_text_glyph_start = self.glyphs.items.len; + self.actual_text_mcid = self.current_mcid; } } + fn finishActualTextRun(self: *SpanCollector) !void { + const text = self.current_actual_text orelse return; + try self.flush(); + const raw = self.glyphs.items[self.actual_text_glyph_start..]; + if (raw.len == 0) { + self.allocator.free(@constCast(text)); + self.current_actual_text = null; + return; + } + try self.actual_text_runs.ensureUnusedCapacity(self.allocator, 1); + if (text.len > 0) { + try self.spans.ensureUnusedCapacity(self.allocator, 1); + const owned_text = try self.allocator.dupe(u8, text); + errdefer self.allocator.free(owned_text); + const glyph = semanticRunGlyph(raw, text, self.actual_text_mcid); + const font_name = if (glyph.font_name) |name| try self.allocator.dupe(u8, name) else null; + self.spans.appendAssumeCapacity(TextSpan.init(.{ + .page_index = self.page_index, + .bbox = glyph.bbox, + .text = owned_text, + .font = .{ .name = font_name, .size = glyph.font_size }, + .block_id = self.current_block_id, + .line_id = self.current_line_id, + .mcid = self.actual_text_mcid, + .actual_text = true, + .writing_mode = glyph.writing_mode, + .paint = glyph.paint, + .content_scope = glyph.content_scope, + })); + } + self.actual_text_runs.appendAssumeCapacity(.{ .glyph_start = @intCast(self.actual_text_glyph_start), .glyph_count = @intCast(raw.len), .text = text, .mcid = self.actual_text_mcid }); + self.current_actual_text = null; + } + pub fn writeAll(self: *SpanCollector, data: []const u8) !void { try self.text_buffer.appendSlice(self.allocator, data); } @@ -562,8 +648,8 @@ pub const SpanCollector = struct { pub fn writeDecodedGlyph(self: *SpanCollector, decoded: encoding_mod.FontEncoding.DecodedGlyph) !void { self.current_writing_mode = decoded.writing_mode; const has_actual_text = self.current_actual_text != null; - const mapping_source: encoding_mod.MappingSource = if (has_actual_text) .actual_text else decoded.mapping_source; - const unicode_map_error = mapping_source == .unresolved; + const mapping_source = decoded.mapping_source; + const unicode_map_error = decoded.unicode_map_error or mapping_source == .unresolved; const advance = self.scaledAdvance(decoded); const bbox = self.currentGlyphBBox(advance, decoded); const text = try self.allocator.dupe(u8, decoded.utf8_text); @@ -582,7 +668,7 @@ pub const SpanCollector = struct { try self.glyphs.append(self.allocator, .{ .page_index = self.page_index, .bbox = bbox, - .text_matrix = self.current_text_matrix, + .text_matrix = self.userTextMatrix(), .font_name = font_name, .font_size = self.current_font_size, .source_code = decoded.source_code, @@ -590,6 +676,10 @@ pub const SpanCollector = struct { .source_byte_count = @intCast(source_len), .text = text, .advance = advance, + .font_space_advance = self.spaceAdvance(), + .content_scope = self.current_scope, + .raw_glyph_start = glyph_index, + .semantic_run_id = if (has_actual_text) @intCast(self.actual_text_runs.items.len) else null, .mapping_source = mapping_source, .unicode_map_error = unicode_map_error, .generated = false, @@ -597,6 +687,7 @@ pub const SpanCollector = struct { .actual_text = has_actual_text, .mcid = self.current_mcid, .writing_mode = decoded.writing_mode, + .paint = self.paintEvidence(bbox), }); glyph_transferred = true; @@ -664,6 +755,15 @@ pub const SpanCollector = struct { } pub fn flush(self: *SpanCollector) !void { + // ActualText belongs to a marked-content scope, not a font/paint run. + // Closing that scope emits its replacement once using all raw geometry. + if (self.current_actual_text != null) { + self.text_buffer.clearRetainingCapacity(); + self.pending_bbox = null; + self.pending_unicode_map_error = false; + self.pending_actual_text = false; + return; + } if (self.text_buffer.items.len == 0 and !(self.pending_actual_text and self.pending_bbox != null)) return; const span_text = if (self.pending_actual_text) (self.current_actual_text orelse self.text_buffer.items) else self.text_buffer.items; @@ -686,6 +786,8 @@ pub const SpanCollector = struct { .unicode_map_error = self.pending_unicode_map_error, .actual_text = self.pending_actual_text, .writing_mode = self.current_writing_mode, + .paint = self.paintEvidence(bbox), + .content_scope = self.current_scope, })); self.text_buffer.clearRetainingCapacity(); @@ -716,11 +818,78 @@ pub const SpanCollector = struct { return self.glyphs.toOwnedSlice(self.allocator); } + /// Independent semantic view: replacements are indivisible union-bbox + /// glyphs; empty replacements suppress their entire raw range. + /// Call before transferring the raw glyph slice. + pub fn toOwnedSemanticGlyphSlice(self: *SpanCollector) ![]GlyphSpan { + var projected: std.ArrayList(GlyphSpan) = .empty; + errdefer { + for (projected.items) |glyph| { + self.allocator.free(@constCast(glyph.text)); + if (glyph.font_name) |name| self.allocator.free(@constCast(name)); + } + projected.deinit(self.allocator); + } + var raw_index: usize = 0; + var run_index: usize = 0; + while (raw_index < self.glyphs.items.len) { + var glyph = self.glyphs.items[raw_index]; + if (run_index < self.actual_text_runs.items.len and self.actual_text_runs.items[run_index].glyph_start == raw_index) { + const run = self.actual_text_runs.items[run_index]; + run_index += 1; + raw_index += run.glyph_count; + if (run.text.len == 0) continue; + glyph = semanticRunGlyph(self.glyphs.items[run.glyph_start..raw_index], run.text, run.mcid); + } else { + raw_index += 1; + } + try projected.ensureUnusedCapacity(self.allocator, 1); + glyph.text = try self.allocator.dupe(u8, glyph.text); + errdefer self.allocator.free(@constCast(glyph.text)); + glyph.font_name = if (glyph.font_name) |name| try self.allocator.dupe(u8, name) else null; + projected.appendAssumeCapacity(glyph); + } + return projected.toOwnedSlice(self.allocator); + } + + fn semanticRunGlyph(raw: []const GlyphSpan, text: []const u8, mcid: ?i32) GlyphSpan { + var result = raw[0]; + result.text = text; + result.mapping_source = .actual_text; + result.unicode_map_error = false; + result.actual_text = true; + result.semantic_replacement = true; + result.raw_glyph_count = @intCast(raw.len); + result.source_code = 0; + result.source_byte_count = 0; + result.generated = false; + result.hyphen = isHyphenText(text); + result.mcid = mcid; + for (raw[1..]) |glyph| { + result.bbox = unionOptionalBBox(result.bbox, glyph.bbox).?; + if (!std.meta.eql(result.paint, glyph.paint)) result.paint = .{}; + if (!std.meta.eql(result.content_scope, glyph.content_scope)) result.content_scope = null; + if (result.font_name != null and (glyph.font_name == null or !std.mem.eql(u8, result.font_name.?, glyph.font_name.?))) result.font_name = null; + } + result.paint = result.paint.forBBox(result.bbox); + const last = raw[raw.len - 1]; + const axis_x = if (result.writing_mode == 1) -result.text_matrix[2] else result.text_matrix[0]; + const axis_y = if (result.writing_mode == 1) -result.text_matrix[3] else result.text_matrix[1]; + const last_axis_x = if (last.writing_mode == 1) -last.text_matrix[2] else last.text_matrix[0]; + const last_axis_y = if (last.writing_mode == 1) -last.text_matrix[3] else last.text_matrix[1]; + const norm_squared = axis_x * axis_x + axis_y * axis_y; + result.advance = if (norm_squared > 0.00000001) + ((last.text_matrix[4] + last_axis_x * last.advance - result.text_matrix[4]) * axis_x + (last.text_matrix[5] + last_axis_y * last.advance - result.text_matrix[5]) * axis_y) / norm_squared + else + 0; + return result; + } + fn appendGeneratedSpace(self: *SpanCollector, advance: f64) !void { const bbox = if (self.current_writing_mode == 1) - transformedBounds(self.current_text_matrix, .{ .x0 = 0, .y0 = -advance, .x1 = self.current_font_size * 0.35, .y1 = 0 }) + transformedBounds(self.userTextMatrix(), .{ .x0 = 0, .y0 = -advance, .x1 = self.current_font_size * 0.35, .y1 = 0 }) else - transformedBounds(self.current_text_matrix, .{ .x0 = 0, .y0 = 0, .x1 = advance, .y1 = self.current_font_size }); + transformedBounds(self.userTextMatrix(), .{ .x0 = 0, .y0 = 0, .x1 = advance, .y1 = self.current_font_size }); const text = try self.allocator.dupe(u8, " "); var glyph_transferred = false; errdefer if (!glyph_transferred) self.allocator.free(text); @@ -732,15 +901,20 @@ pub const SpanCollector = struct { try self.glyphs.append(self.allocator, .{ .page_index = self.page_index, .bbox = bbox, - .text_matrix = self.current_text_matrix, + .text_matrix = self.userTextMatrix(), .font_name = font_name, .font_size = self.current_font_size, .text = text, .advance = advance, + .font_space_advance = self.spaceAdvance(), + .content_scope = self.current_scope, + .raw_glyph_start = glyph_index, + .semantic_run_id = if (self.current_actual_text != null) @intCast(self.actual_text_runs.items.len) else null, .generated = true, .actual_text = self.current_actual_text != null, .mcid = self.current_mcid, .writing_mode = self.current_writing_mode, + .paint = self.paintEvidence(bbox), }); glyph_transferred = true; try self.appendCharsForGlyph(glyph_index, bbox, ' ', " ", .unresolved, false, true, self.current_actual_text != null, self.current_writing_mode); @@ -759,7 +933,7 @@ pub const SpanCollector = struct { .page_index = self.page_index, .glyph_index = glyph_index, .bbox = bbox, - .text_matrix = self.current_text_matrix, + .text_matrix = self.userTextMatrix(), .font_name = font_name, .font_size = self.current_font_size, .source_code = source_code, @@ -771,17 +945,25 @@ pub const SpanCollector = struct { .actual_text = actual_text, .mcid = self.current_mcid, .writing_mode = writing_mode, + .paint = self.paintEvidence(bbox), + .content_scope = self.current_scope, }); index = end; } } fn scaledAdvance(self: *const SpanCollector, decoded: encoding_mod.FontEncoding.DecodedGlyph) f64 { - var advance = decoded.glyph_width / 1000.0 * self.current_font_size * (self.current_horizontal_scale / 100.0); - if (advance <= 0) advance = self.current_font_size * self.avg_char_width; + var advance = decoded.glyph_width / 1000.0 * self.current_font_size; advance += self.current_char_spacing; if (std.mem.eql(u8, decoded.utf8_text, " ")) advance += self.current_word_spacing; - return advance; + return advance * (self.current_horizontal_scale / 100.0); + } + + fn spaceAdvance(self: *const SpanCollector) f64 { + return if (self.current_font_space_width > 0) + (self.current_font_space_width / 1000.0 * self.current_font_size + self.current_char_spacing + self.current_word_spacing) * self.current_horizontal_scale / 100.0 + else + 0; } fn currentGlyphBBox(self: *const SpanCollector, advance: f64, decoded: encoding_mod.FontEncoding.DecodedGlyph) layout.BBox { @@ -791,7 +973,7 @@ pub const SpanCollector = struct { layout.BBox{ .x0 = descender, .y0 = -advance, .x1 = ascender, .y1 = 0 } else layout.BBox{ .x0 = 0, .y0 = self.current_text_rise + descender, .x1 = advance, .y1 = self.current_text_rise + ascender }; - return transformedBounds(self.current_text_matrix, local); + return transformedBounds(self.userTextMatrix(), local); } fn transformedBounds(matrix: [6]f64, box: layout.BBox) layout.BBox { @@ -903,7 +1085,7 @@ pub const ContentLexer = struct { const c = self.data[self.pos]; // String literal - if (c == '(') return Token{ .string = self.scanString() }; + if (c == '(') return Token{ .string = try self.scanString() }; // Hex string if (c == '<') { @@ -912,14 +1094,14 @@ pub const ContentLexer = struct { self.pos += 2; return self.next(); } - return Token{ .hex_string = self.scanHexString() }; + return Token{ .hex_string = try self.scanHexString() }; } // Name if (c == '/') return Token{ .name = self.scanName() }; // Array - if (c == '[') return Token{ .array = self.scanArray() }; + if (c == '[') return Token{ .array = try self.scanArray() }; // End markers if (c == ']' or c == '>') { @@ -963,7 +1145,7 @@ pub const ContentLexer = struct { } } - fn scanString(self: *ContentLexer) []const u8 { + fn scanString(self: *ContentLexer) ![]const u8 { self.pos += 1; // Skip '(' var depth: usize = 1; @@ -971,6 +1153,7 @@ pub const ContentLexer = struct { var stack_buf: [4096]u8 = undefined; var stack_len: usize = 0; var overflow: ?std.ArrayList(u8) = null; + errdefer if (overflow) |*list| list.deinit(self.allocator); while (self.pos < self.data.len and depth > 0) { const c = self.data[self.pos]; @@ -1013,19 +1196,19 @@ pub const ContentLexer = struct { }, else => escaped, }; - appendByte(&stack_buf, &stack_len, &overflow, self.allocator, decoded); + try appendByte(&stack_buf, &stack_len, &overflow, self.allocator, decoded); } else if (c == '(') { depth += 1; - appendByte(&stack_buf, &stack_len, &overflow, self.allocator, c); + try appendByte(&stack_buf, &stack_len, &overflow, self.allocator, c); self.pos += 1; } else if (c == ')') { depth -= 1; if (depth > 0) { - appendByte(&stack_buf, &stack_len, &overflow, self.allocator, c); + try appendByte(&stack_buf, &stack_len, &overflow, self.allocator, c); } self.pos += 1; } else { - appendByte(&stack_buf, &stack_len, &overflow, self.allocator, c); + try appendByte(&stack_buf, &stack_len, &overflow, self.allocator, c); self.pos += 1; } } @@ -1033,39 +1216,41 @@ pub const ContentLexer = struct { return finalizeBuf(&stack_buf, stack_len, &overflow, self.allocator); } - fn appendByte(stack_buf: *[4096]u8, stack_len: *usize, overflow: *?std.ArrayList(u8), allocator: std.mem.Allocator, byte: u8) void { + fn appendByte(stack_buf: *[4096]u8, stack_len: *usize, overflow: *?std.ArrayList(u8), allocator: std.mem.Allocator, byte: u8) !void { if (overflow.*) |*list| { - list.append(allocator, byte) catch {}; + try list.append(allocator, byte); } else if (stack_len.* < stack_buf.len) { stack_buf[stack_len.*] = byte; stack_len.* += 1; } else { // Overflow to heap - copy existing stack data first var list: std.ArrayList(u8) = .empty; - list.appendSlice(allocator, stack_buf[0..stack_len.*]) catch {}; - list.append(allocator, byte) catch {}; + errdefer list.deinit(allocator); + try list.appendSlice(allocator, stack_buf[0..stack_len.*]); + try list.append(allocator, byte); overflow.* = list; } } - fn finalizeBuf(stack_buf: *[4096]u8, stack_len: usize, overflow: *?std.ArrayList(u8), allocator: std.mem.Allocator) []const u8 { + fn finalizeBuf(stack_buf: *[4096]u8, stack_len: usize, overflow: *?std.ArrayList(u8), allocator: std.mem.Allocator) ![]const u8 { if (overflow.*) |*list| { - return list.toOwnedSlice(allocator) catch &.{}; + return list.toOwnedSlice(allocator); } if (stack_len == 0) return &.{}; // Single allocation + copy - const result = allocator.alloc(u8, stack_len) catch return &.{}; + const result = try allocator.alloc(u8, stack_len); @memcpy(result, stack_buf[0..stack_len]); return result; } - fn scanHexString(self: *ContentLexer) []const u8 { + fn scanHexString(self: *ContentLexer) ![]const u8 { self.pos += 1; // Skip '<' // Use stack buffer for most hex strings (avoids heap allocations) var stack_buf: [4096]u8 = undefined; var stack_len: usize = 0; var overflow: ?std.ArrayList(u8) = null; + errdefer if (overflow) |*list| list.deinit(self.allocator); var high_nibble: ?u8 = null; @@ -1086,7 +1271,7 @@ pub const ContentLexer = struct { continue; if (high_nibble) |high| { - appendByte(&stack_buf, &stack_len, &overflow, self.allocator, (high << 4) | nibble); + try appendByte(&stack_buf, &stack_len, &overflow, self.allocator, (high << 4) | nibble); high_nibble = null; } else { high_nibble = nibble; @@ -1095,7 +1280,7 @@ pub const ContentLexer = struct { // Handle odd number of hex digits (pad with 0) if (high_nibble) |high| { - appendByte(&stack_buf, &stack_len, &overflow, self.allocator, high << 4); + try appendByte(&stack_buf, &stack_len, &overflow, self.allocator, high << 4); } if (self.pos < self.data.len) self.pos += 1; // Skip '>' @@ -1162,10 +1347,16 @@ pub const ContentLexer = struct { } } - fn scanArray(self: *ContentLexer) []const Operand { + fn scanArray(self: *ContentLexer) ![]const Operand { self.pos += 1; // Skip '[' var count: usize = 0; + errdefer for (self.array_buffer[0..count]) |operand| { + switch (operand) { + .string, .hex_string => |text| if (text.len > 0) self.allocator.free(text), + else => {}, + } + }; while (self.pos < self.data.len and count < 512) { self.skipWhitespaceAndComments(); @@ -1181,10 +1372,10 @@ pub const ContentLexer = struct { // Parse element if (c == '(') { - self.array_buffer[count] = .{ .string = self.scanString() }; + self.array_buffer[count] = .{ .string = try self.scanString() }; count += 1; } else if (c == '<') { - self.array_buffer[count] = .{ .hex_string = self.scanHexString() }; + self.array_buffer[count] = .{ .hex_string = try self.scanHexString() }; count += 1; } else if (c == '-' or c == '+' or c == '.' or (c >= '0' and c <= '9')) { self.array_buffer[count] = .{ .number = self.scanNumber() }; @@ -1274,6 +1465,48 @@ test "lexer TJ array" { try std.testing.expectEqualStrings("TJ", op.operator); } +fn lexerAllocationProbe(allocator: std.mem.Allocator, content: []const u8, expected_length: usize) !void { + var lexer = ContentLexer.init(allocator, content); + const token = (try lexer.next()) orelse return error.TestUnexpectedResult; + switch (token) { + .string, .hex_string => |text| { + defer if (text.len > 0) allocator.free(text); + try std.testing.expectEqual(expected_length, text.len); + }, + .array => |operands| { + defer for (operands) |operand| switch (operand) { + .string, .hex_string => |text| if (text.len > 0) allocator.free(text), + else => {}, + }; + try std.testing.expectEqual(expected_length, operands.len); + }, + else => return error.TestUnexpectedResult, + } +} + +test "lexer string hex and TJ failures propagate instead of dropping text" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, lexerAllocationProbe, .{ "(A)", @as(usize, 1) }); + try std.testing.checkAllAllocationFailures(std.testing.allocator, lexerAllocationProbe, .{ "<41>", @as(usize, 1) }); + try std.testing.checkAllAllocationFailures(std.testing.allocator, lexerAllocationProbe, .{ "[(A) 3 <42> (C)]", @as(usize, 4) }); + + const literal = try std.testing.allocator.alloc(u8, 5002); + defer std.testing.allocator.free(literal); + literal[0] = '('; + @memset(literal[1..5001], 'A'); + literal[5001] = ')'; + try std.testing.checkAllAllocationFailures(std.testing.allocator, lexerAllocationProbe, .{ literal, @as(usize, 5000) }); + + const hex = try std.testing.allocator.alloc(u8, 10002); + defer std.testing.allocator.free(hex); + hex[0] = '<'; + for (0..5000) |index| { + hex[1 + 2 * index] = '4'; + hex[2 + 2 * index] = '1'; + } + hex[10001] = '>'; + try std.testing.checkAllAllocationFailures(std.testing.allocator, lexerAllocationProbe, .{ hex, @as(usize, 5000) }); +} + test "lexer hex string decoding" { const content = "<48656C6C6F> Tj"; // "Hello" in hex @@ -1502,6 +1735,10 @@ test "span collector derives bounds from decoded glyph advances" { try std.testing.expectApproxEqAbs(@as(f64, 7.5), span.bbox.x1 - span.bbox.x0, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 10.0), span.bbox.y1 - span.bbox.y0, 0.001); try std.testing.expectEqualStrings("A", span.text); + try std.testing.expectEqual(@as(?u8, null), span.paint.rendering_mode); + try std.testing.expectEqual(.unknown, span.paint.paint_state); + try std.testing.expectEqual(.unknown, collector.getGlyphs()[0].paint.clip_state); + try std.testing.expectEqual(.unknown, collector.getChars()[0].paint.paint_state); } test "span collector records generated gaps and vertical glyph movement" { @@ -1529,6 +1766,47 @@ test "span collector records generated gaps and vertical glyph movement" { try std.testing.expect(saw_generated); } +test "semantic projection retains raw decoding errors bytes and scoped evidence independently" { + const allocator = std.testing.allocator; + var collector = SpanCollector.init(allocator, 3); + defer collector.deinit(); + collector.setContentScope(.{ .page_index = 3, .stream_object_num = 42, .invocation_index = 9 }); + try collector.setActualText("correct 😀"); + try collector.writeDecodedGlyph(.{ .source_code = 255, .source_bytes = "\xff", .bytes_consumed = 1, .utf8_text = "bad", .glyph_width = 500, .mapping_source = .explicit_to_unicode, .unicode_map_error = true }); + try collector.setActualText(null); + const projected = try collector.toOwnedSemanticGlyphSlice(); + defer { + for (projected) |glyph| { + allocator.free(@constCast(glyph.text)); + if (glyph.font_name) |name| allocator.free(@constCast(name)); + } + allocator.free(projected); + } + try std.testing.expectEqualStrings("bad", collector.glyphs.items[0].text); + try std.testing.expect(collector.glyphs.items[0].unicode_map_error); + try std.testing.expect(collector.chars.items[0].unicode_map_error); + try std.testing.expectEqual(@as(u8, 255), collector.glyphs.items[0].source_bytes[0]); + try std.testing.expectEqualStrings("correct 😀", projected[0].text); + try std.testing.expect(!projected[0].unicode_map_error); + try std.testing.expectEqual(@as(?u32, 42), projected[0].content_scope.?.stream_object_num); + try std.testing.expectEqual(@as(?u32, 42), collector.spans.items[0].content_scope.?.stream_object_num); +} + +test "glyph and space advances scale spacing before the text matrix without invented zero widths" { + var collector = SpanCollector.init(std.testing.allocator, 0); + defer collector.deinit(); + collector.setFont("F1", 10, true); + collector.current_font_space_width = 200; + collector.setTextState(.{ .char_spacing = 1, .word_spacing = 2, .horizontal_scale = 200, .text_rise = 0 }); + collector.setTextMatrix(.{ 2, 0, 0, 2, 0, 0 }); + try collector.writeDecodedGlyph(.{ .source_code = 32, .source_bytes = " ", .bytes_consumed = 1, .utf8_text = " ", .glyph_width = 200 }); + try std.testing.expectEqual(@as(f64, 10), collector.glyphs.items[0].advance); + try std.testing.expectEqual(@as(f64, 10), collector.glyphs.items[0].font_space_advance); + try std.testing.expectEqual(@as(f64, 20), collector.current_x); + try collector.writeDecodedGlyph(.{ .source_code = 88, .source_bytes = "X", .bytes_consumed = 1, .utf8_text = "X", .glyph_width = 0 }); + try std.testing.expectEqual(@as(f64, 2), collector.glyphs.items[1].advance); +} + fn spanCollectorAllocationProbe(allocator: std.mem.Allocator) !void { var collector = SpanCollector.init(allocator, 0); defer collector.deinit(); diff --git a/src/layout.zig b/src/layout.zig index b191cf9..f9efec5 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -1,4 +1,7 @@ const std = @import("std"); +const form_scope = @import("form_scope.zig"); + +pub const TextBoundary = enum { join, space, line_break, block_break, region_break }; pub const SourceKind = enum(u8) { native_pdf, @@ -17,6 +20,33 @@ pub const BBox = struct { y1: f64 = 0, }; +/// Operator evidence, not a claim about final rendered pixel visibility. +pub const PaintEvidence = struct { + rendering_mode: ?u8 = null, + paint_state: enum { painting, non_painting, unknown } = .unknown, + clip_state: enum { unclipped, inside, outside, partial, unknown } = .unknown, + clip_bbox: ?BBox = null, + + pub fn forBBox(self: PaintEvidence, box: BBox) PaintEvidence { + var value = self; + if (self.clip_state == .unknown) return value; + if (!std.math.isFinite(box.x0) or !std.math.isFinite(box.y0) or !std.math.isFinite(box.x1) or !std.math.isFinite(box.y1)) { + value.clip_state = .unknown; + value.clip_bbox = null; + return value; + } + if (self.clip_bbox) |clip| { + value.clip_state = if (clip.x0 >= clip.x1 or clip.y0 >= clip.y1 or box.x1 <= clip.x0 or box.x0 >= clip.x1 or box.y1 <= clip.y0 or box.y0 >= clip.y1) + .outside + else if (box.x0 >= clip.x0 and box.x1 <= clip.x1 and box.y0 >= clip.y0 and box.y1 <= clip.y1) + .inside + else + .partial; + } + return value; + } +}; + pub const FontMetadata = struct { name: ?[]const u8 = null, size: f64 = 0, @@ -84,6 +114,9 @@ pub const TextSpan = struct { unicode_map_error: bool = false, actual_text: bool = false, writing_mode: u8 = 0, + paint: PaintEvidence = .{}, + content_scope: ?form_scope.Scope = null, + leading_boundary: ?TextBoundary = null, pub fn init(args: struct { page_index: u32 = 0, @@ -98,6 +131,9 @@ pub const TextSpan = struct { unicode_map_error: bool = false, actual_text: bool = false, writing_mode: u8 = 0, + paint: PaintEvidence = .{}, + content_scope: ?form_scope.Scope = null, + leading_boundary: ?TextBoundary = null, }) TextSpan { var font = args.font; const font_size = if (font.size != 0) font.size else args.bbox.y1 - args.bbox.y0; @@ -121,6 +157,9 @@ pub const TextSpan = struct { .unicode_map_error = args.unicode_map_error, .actual_text = args.actual_text, .writing_mode = args.writing_mode, + .paint = args.paint, + .content_scope = args.content_scope, + .leading_boundary = args.leading_boundary, }; } }; @@ -2560,12 +2599,16 @@ fn mergeSpanBounds(spans: []const TextSpan) TextSpan { var y0 = spans[0].y0; var x1 = spans[0].x1; var y1 = spans[0].y1; + var paint = spans[0].paint; + var content_scope = spans[0].content_scope; for (spans[1..]) |s| { x0 = @min(x0, s.x0); y0 = @min(y0, s.y0); x1 = @max(x1, s.x1); y1 = @max(y1, s.y1); + if (!std.meta.eql(paint, s.paint)) paint = .{}; + if (!std.meta.eql(content_scope, s.content_scope)) content_scope = null; } return TextSpan.init(.{ @@ -2578,6 +2621,9 @@ fn mergeSpanBounds(spans: []const TextSpan) TextSpan { .block_id = spans[0].block_id, .line_id = spans[0].line_id, .mcid = spans[0].mcid, + .paint = paint.forBBox(.{ .x0 = x0, .y0 = y0, .x1 = x1, .y1 = y1 }), + .content_scope = content_scope, + .leading_boundary = spans[0].leading_boundary, }); } @@ -2586,12 +2632,16 @@ fn mergeBounds(lines: []const TextLine) TextSpan { var y0 = lines[0].bounds.y0; var x1 = lines[0].bounds.x1; var y1 = lines[0].bounds.y1; + var paint = lines[0].bounds.paint; + var content_scope = lines[0].bounds.content_scope; for (lines[1..]) |l| { x0 = @min(x0, l.bounds.x0); y0 = @min(y0, l.bounds.y0); x1 = @max(x1, l.bounds.x1); y1 = @max(y1, l.bounds.y1); + if (!std.meta.eql(paint, l.bounds.paint)) paint = .{}; + if (!std.meta.eql(content_scope, l.bounds.content_scope)) content_scope = null; } return TextSpan.init(.{ @@ -2604,6 +2654,9 @@ fn mergeBounds(lines: []const TextLine) TextSpan { .block_id = lines[0].bounds.block_id, .line_id = lines[0].bounds.line_id, .mcid = lines[0].bounds.mcid, + .paint = paint.forBBox(.{ .x0 = x0, .y0 = y0, .x1 = x1, .y1 = y1 }), + .content_scope = content_scope, + .leading_boundary = lines[0].bounds.leading_boundary, }); } @@ -2632,6 +2685,35 @@ test "TextSpan carries provenance and semantic identity" { try std.testing.expectEqual(@as(i32, 11), span.mcid.?); } +test "layout bounds preserve homogeneous paint and withhold mixed paint claims" { + const hidden = TextSpan.init(.{ .bbox = .{ .x0 = 10, .y0 = 10, .x1 = 20, .y1 = 20 }, .text = "A", .paint = .{ .rendering_mode = 3, .paint_state = .non_painting, .clip_state = .unclipped } }); + var second = hidden; + second.bbox.x0 = 20; + second.bbox.x1 = 30; + second.x0 = 20; + second.x1 = 30; + const homogeneous = mergeSpanBounds(&.{ hidden, second }); + try std.testing.expect(std.meta.eql(hidden.paint, homogeneous.paint)); + second.paint = .{ .rendering_mode = 0, .paint_state = .painting, .clip_state = .unclipped }; + const mixed = mergeSpanBounds(&.{ hidden, second }); + try std.testing.expectEqual(.unknown, mixed.paint.paint_state); + try std.testing.expectEqual(@as(?u8, null), mixed.paint.rendering_mode); +} + +test "layout bounds preserve scoped origin only when every span shares it" { + var first = TextSpan.init(.{ .bbox = .{ .x0 = 0, .y0 = 0, .x1 = 10, .y1 = 10 }, .text = "A", .content_scope = .{ .page_index = 3, .stream_object_num = 19, .invocation_index = 1 }, .leading_boundary = .region_break }); + first.page_index = 3; + var second = first; + second.text = "B"; + second.leading_boundary = .space; + const same = mergeSpanBounds(&.{ first, second }); + try std.testing.expect(std.meta.eql(first.content_scope, same.content_scope)); + try std.testing.expectEqual(TextBoundary.region_break, same.leading_boundary.?); + second.content_scope.?.invocation_index = 2; + const mixed = mergeSpanBounds(&.{ first, second }); + try std.testing.expectEqual(@as(?form_scope.Scope, null), mixed.content_scope); +} + fn testSpan(text: []const u8, x0: f64, y0: f64, x1: f64, y1: f64) TextSpan { return TextSpan.init(.{ .bbox = .{ .x0 = x0, .y0 = y0, .x1 = x1, .y1 = y1 }, diff --git a/src/main.zig b/src/main.zig index e4616d5..2065c19 100644 --- a/src/main.zig +++ b/src/main.zig @@ -110,6 +110,10 @@ fn printUsage() !void { \\ Write specialist request JSONL without invoking specialists \\ --max-output-bytes N Stream serialized-byte cap (positive integer) \\ --max-page-memory-bytes N Stream live adaptive-page allocation cap + \\ --ocr-max-image-pixels N Accepted OCR raster pixel cap + \\ --ocr-max-image-bytes N Accepted OCR raster file byte cap + \\ --ocr-max-regions N OCR crop count cap per page + \\ --ocr-max-attempts N OCR attempt count cap per page \\ --specialist-config FILE \\ Specialist executable config for future local adapters \\ --trace Emit adaptive route trace JSON @@ -1058,6 +1062,13 @@ fn runExtractAdaptive(allocator: std.mem.Allocator, args: []const []const u8) !v if (i >= args.len) return error.InvalidArguments; const limit = try parsePositiveResourceLimit(args[i]); if (std.mem.eql(u8, arg, "--max-output-bytes")) max_output_bytes = limit else max_page_memory_bytes = limit; + } else if (std.mem.eql(u8, arg, "--ocr-max-image-pixels") or std.mem.eql(u8, arg, "--ocr-max-image-bytes") or + std.mem.eql(u8, arg, "--ocr-max-regions") or std.mem.eql(u8, arg, "--ocr-max-attempts")) + { + i += 1; + if (i >= args.len) return error.InvalidArguments; + const limit = try parsePositiveResourceLimit(args[i]); + if (std.mem.eql(u8, arg, "--ocr-max-image-pixels")) ocr_config.max_image_pixels = limit else if (std.mem.eql(u8, arg, "--ocr-max-image-bytes")) ocr_config.max_image_bytes = limit else if (std.mem.eql(u8, arg, "--ocr-max-regions")) ocr_config.max_regions_per_page = limit else ocr_config.max_attempts_per_page = limit; } else if (std.mem.eql(u8, arg, "--password")) { i += 1; if (i < args.len) password = args[i]; @@ -1272,6 +1283,12 @@ test "stream resource arguments reject invalid limits and non-stream formats" { try std.testing.expectError(error.InvalidArguments, parsePositiveResourceLimit(value)); } try std.testing.expectEqual(@as(usize, 1024), try parsePositiveResourceLimit("1024")); + for ([_][]const u8{ "--ocr-max-image-pixels", "--ocr-max-image-bytes", "--ocr-max-regions", "--ocr-max-attempts" }) |flag| { + try std.testing.expectError(error.InvalidArguments, runExtractAdaptive(std.testing.allocator, &.{flag})); + for ([_][]const u8{ "0", "-1", "1.5", "184467440737095516160" }) |value| { + try std.testing.expectError(error.InvalidArguments, runExtractAdaptive(std.testing.allocator, &.{ flag, value })); + } + } for ([_][]const u8{ "--max-output-bytes", "--max-page-memory-bytes" }) |flag| { try std.testing.expectError(error.InvalidArguments, runExtractAdaptive(std.testing.allocator, &.{flag})); try std.testing.expectError(error.InvalidArguments, runExtractAdaptive(std.testing.allocator, &.{ flag, "0" })); @@ -2040,7 +2057,7 @@ test "adaptive CLI emits specialist request JSONL sidecars" { \\request_id=$(printf '%s' "$request" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p') \\crop=$(printf '%s' "$request" | sed -n 's/.*"crop_image_path":"\([^"]*\)".*/\1/p') \\test -n "$crop" && test -f "$crop" || exit 7 - \\printf '{"schema_version":"0.15.0","record_type":"specialist_response","request_id":"%s","specialist_id":"cli-formula","specialist_kind":"formula","status":"completed","formulas":[{"text":"x^2+y^2=z^2","format":"latex","confidence":0.95}]}\n' "$request_id" + \\printf '{"schema_version":"0.17.0","record_type":"specialist_response","request_id":"%s","specialist_id":"cli-formula","specialist_kind":"formula","status":"completed","formulas":[{"text":"x^2+y^2=z^2","format":"latex","confidence":0.95}]}\n' "$request_id" \\ ); runtime.closeFile(formula_file); @@ -2114,7 +2131,7 @@ test "adaptive CLI emits specialist request JSONL sidecars" { const requests = try runtime.readFileAllocAlignedCwd(allocator, requests_path, .fromByteUnits(1)); defer allocator.free(requests); try std.testing.expect(std.mem.indexOf(u8, requests, "\"record_type\":\"specialist_request\"") != null); - try std.testing.expect(std.mem.indexOf(u8, requests, "\"schema_version\":\"0.15.0\"") != null); + try std.testing.expect(std.mem.indexOf(u8, requests, "\"schema_version\":\"0.17.0\"") != null); try std.testing.expect(std.mem.indexOf(u8, requests, "\"source_id\":\"external-specialist-cli\"") != null); try std.testing.expect(std.mem.indexOf(u8, requests, "\"requested_kind\":\"") != null); try std.testing.expect(std.mem.indexOf(u8, requests, "\"requested_outputs\"") != null); diff --git a/src/native_layout.zig b/src/native_layout.zig index 1cf65e7..928515e 100644 --- a/src/native_layout.zig +++ b/src/native_layout.zig @@ -11,13 +11,7 @@ const layout = @import("layout.zig"); pub const GlyphSpan = interpreter.GlyphSpan; pub const BBox = layout.BBox; -pub const BoundaryKind = enum(u8) { - join, - space, - line_break, - block_break, - region_break, -}; +pub const BoundaryKind = layout.TextBoundary; pub const BoundaryProvenance = enum(u8) { content_start, @@ -142,12 +136,13 @@ pub fn analyze( const glyph = glyphs[glyph_index]; if (glyph.text.len == 0) continue; const glyph_baseline = baselineCoordinate(glyph); + const glyph_font_size = baselineFontSize(glyph); var target_line: ?usize = null; var line_index = pending_lines.items.len; while (line_index > 0) { line_index -= 1; const candidate = pending_lines.items[line_index]; - const tolerance = @max(1.5, @max(candidate.max_font_size, glyph.font_size) * 0.55); + const tolerance = @max(1.5, @max(candidate.max_font_size, glyph_font_size) * 0.55); const delta = @abs(glyph_baseline - candidate.baseline()); if (delta <= tolerance and sameOrientation(glyphs[candidateGlyphIndex(line_glyphs.items, candidate)], glyph) and @@ -172,7 +167,7 @@ pub fn analyze( line.count += 1; line.baseline_sum += glyph_baseline; line.baseline_count += 1; - line.max_font_size = @max(line.max_font_size, glyph.font_size); + line.max_font_size = @max(line.max_font_size, glyph_font_size); line.bbox = unionBox(line.bbox, glyph.bbox); } else { const start = line_glyphs.items.len; @@ -182,7 +177,7 @@ pub fn analyze( .count = 1, .baseline_sum = glyph_baseline, .baseline_count = 1, - .max_font_size = glyph.font_size, + .max_font_size = glyph_font_size, .bbox = glyph.bbox, .region_index = regionForGlyph(glyph.bbox, gutter), }); @@ -309,12 +304,12 @@ pub fn buildLineSpans( const boundary = result.boundaries[ordered_index]; const glyph = glyphs[result.ordered_glyph_indices[ordered_index]]; segment_bbox = unionBox(segment_bbox, glyph.bbox); - if (local_index > 0 and boundary.kind == .space and !endsInWhitespace(text.items)) { + if (local_index > segment_start and boundary.kind == .space and !endsInWhitespace(text.items) and !(glyph.actual_text and startsInWhitespace(glyph.text))) { try text.append(allocator, ' '); } - try appendNormalizedGlyph(allocator, &text, glyph.text); + try appendNormalizedGlyph(allocator, &text, glyph.text, glyph.actual_text); } - trimTrailingWhitespace(&text); + if (!representative.actual_text) trimTrailingWhitespace(&text); if (text.items.len == 0) { text.deinit(allocator); segment_start = segment_end; @@ -328,6 +323,7 @@ pub fn buildLineSpans( }; try spans.ensureUnusedCapacity(allocator, 1); const owned_text = try text.toOwnedSlice(allocator); + const paint = representative.paint.forBBox(segment_bbox); spans.appendAssumeCapacity(layout.TextSpan.init(.{ .page_index = page_index, .bbox = segment_bbox, @@ -340,6 +336,9 @@ pub fn buildLineSpans( .unicode_map_error = representative.unicode_map_error, .actual_text = representative.actual_text, .writing_mode = representative.writing_mode, + .leading_boundary = segmentLeadingBoundary(glyphs, result, start + segment_start), + .content_scope = representative.content_scope, + .paint = paint, })); font_name_transferred = true; segment_start = segment_end; @@ -348,13 +347,28 @@ pub fn buildLineSpans( return spans.toOwnedSlice(allocator); } +fn segmentLeadingBoundary(glyphs: []const GlyphSpan, result: *const Result, ordered_index: usize) BoundaryKind { + const boundary = result.boundaries[ordered_index].kind; + // The previous provenance segment may trim an explicit trailing space, or + // a whitespace-only segment may emit no span. Carry that word boundary + // forward rather than inferring it again from touching glyph boxes. + if (boundary == .join and ordered_index > 0) { + const previous = glyphs[result.ordered_glyph_indices[ordered_index - 1]]; + if (!previous.actual_text and isWhitespace(previous.text)) return .space; + } + return boundary; +} + fn sameTextSpanProvenance(a: GlyphSpan, b: GlyphSpan) bool { return optionalTextEqual(a.font_name, b.font_name) and a.font_size == b.font_size and a.mcid == b.mcid and a.unicode_map_error == b.unicode_map_error and a.actual_text == b.actual_text and - a.writing_mode == b.writing_mode; + a.semantic_run_id == b.semantic_run_id and + a.writing_mode == b.writing_mode and + std.meta.eql(a.content_scope, b.content_scope) and + std.meta.eql(a.paint, b.paint); } fn optionalTextEqual(a: ?[]const u8, b: ?[]const u8) bool { @@ -401,19 +415,26 @@ fn compareGlyphInline(glyphs: []const GlyphSpan, a_index: u32, b_index: u32) boo } fn inlineCoordinate(glyph: GlyphSpan) f64 { - if (glyph.writing_mode == 1) return -glyph.text_matrix[5]; - const a = glyph.text_matrix[0]; - const b = glyph.text_matrix[1]; - const magnitude = @max(0.0001, @sqrt(a * a + b * b)); - return (glyph.text_matrix[4] * a + glyph.text_matrix[5] * b) / magnitude; + const axis = writingAxis(glyph); + return glyph.text_matrix[4] * axis.x + glyph.text_matrix[5] * axis.y; } fn baselineCoordinate(glyph: GlyphSpan) f64 { - if (glyph.writing_mode == 1) return glyph.text_matrix[4]; - const a = glyph.text_matrix[0]; - const b = glyph.text_matrix[1]; + const axis = writingAxis(glyph); + return -glyph.text_matrix[4] * axis.y + glyph.text_matrix[5] * axis.x; +} + +fn writingAxis(glyph: GlyphSpan) struct { x: f64, y: f64, scale: f64 } { + const a = if (glyph.writing_mode == 1) -glyph.text_matrix[2] else glyph.text_matrix[0]; + const b = if (glyph.writing_mode == 1) -glyph.text_matrix[3] else glyph.text_matrix[1]; const magnitude = @max(0.0001, @sqrt(a * a + b * b)); - return (-glyph.text_matrix[4] * b + glyph.text_matrix[5] * a) / magnitude; + return .{ .x = a / magnitude, .y = b / magnitude, .scale = magnitude }; +} + +fn baselineFontSize(glyph: GlyphSpan) f64 { + const matrix = glyph.text_matrix; + const normal_scale = @abs(matrix[0] * matrix[3] - matrix[1] * matrix[2]) / writingAxis(glyph).scale; + return glyph.font_size * normal_scale; } fn sameOrientation(a: GlyphSpan, b: GlyphSpan) bool { @@ -438,18 +459,46 @@ fn detectCentralGutter( if (page_width <= 0 or glyphs.len < 24) return null; const bin_count = 96; var occupancy: [bin_count]u32 = @splat(0); + var first_baseline: [bin_count]?f64 = @splat(null); + var first_em: [bin_count]f64 = @splat(0); + var repeated_baseline: [bin_count]bool = @splat(false); var left_count: usize = 0; var right_count: usize = 0; for (glyphs) |glyph| { - if (isWhitespace(glyph.text)) continue; - const x0 = @max(page_bbox.x0, @min(page_bbox.x1, glyph.bbox.x0)); - const x1 = @max(page_bbox.x0, @min(page_bbox.x1, glyph.bbox.x1)); - const first: usize = @intFromFloat(@floor((x0 - page_bbox.x0) / page_width * bin_count)); - const last_raw: usize = @intFromFloat(@floor((x1 - page_bbox.x0) / page_width * bin_count)); - const last = @min(bin_count - 1, last_raw); - var bin = @min(bin_count - 1, first); - while (bin <= last) : (bin += 1) occupancy[bin] += 1; + if (glyph.text.len == 0 or isWhitespace(glyph.text)) continue; + const range = horizontalBinRange(glyph.bbox, page_bbox, bin_count); + const baseline = baselineCoordinate(glyph); + const em = baselineFontSize(glyph); + for (range.first..range.last + 1) |bin| { + if (first_baseline[bin]) |first| { + if (@abs(first - baseline) > @max(1.5, @max(first_em[bin], em) * 0.55)) repeated_baseline[bin] = true; + } else { + first_baseline[bin] = baseline; + first_em[bin] = em; + } + } + } + + // Isolated centered footers must not carve a false edge into a real + // gutter. Keep whole native runs when any of their occupied columns has + // another baseline of support; do not truncate longer supported lines. + var native_run_start: usize = 0; + while (native_run_start < glyphs.len) { + var run_end = native_run_start + 1; + var run_bbox = glyphs[native_run_start].bbox; + while (run_end < glyphs.len and inlineNeighbors(glyphs[run_end - 1], glyphs[run_end])) : (run_end += 1) { + run_bbox = unionBox(run_bbox, glyphs[run_end].bbox); + } + const run_range = horizontalBinRange(run_bbox, page_bbox, bin_count); + var supported = false; + for (run_range.first..run_range.last + 1) |bin| supported = supported or repeated_baseline[bin]; + if (supported) for (glyphs[native_run_start..run_end]) |glyph| { + if (glyph.text.len == 0 or isWhitespace(glyph.text)) continue; + const range = horizontalBinRange(glyph.bbox, page_bbox, bin_count); + for (range.first..range.last + 1) |bin| occupancy[bin] += 1; + }; + native_run_start = run_end; } const allowed: u32 = @intCast(@max(1, glyphs.len / 800)); @@ -481,6 +530,8 @@ fn detectCentralGutter( if (gutter_width < @max(12.0, page_width * 0.025)) return null; const gutter_x0 = page_bbox.x0 + @as(f64, @floatFromInt(best_start)) / bin_count * page_width; const gutter_x1 = gutter_x0 + gutter_width; + const proposed_gutter = BBox{ .x0 = gutter_x0, .y0 = page_bbox.y0, .x1 = gutter_x1, .y1 = page_bbox.y1 }; + if (splitsContinuousWord(glyphs, proposed_gutter)) return null; for (glyphs) |glyph| { if (isWhitespace(glyph.text)) continue; const center = (glyph.bbox.x0 + glyph.bbox.x1) / 2.0; @@ -492,6 +543,40 @@ fn detectCentralGutter( return .{ .x0 = gutter_x0, .y0 = page_bbox.y0, .x1 = gutter_x1, .y1 = page_bbox.y1 }; } +fn horizontalBinRange(bbox: BBox, page_bbox: BBox, comptime count: usize) struct { first: usize, last: usize } { + const scale = @as(f64, @floatFromInt(count)) / (page_bbox.x1 - page_bbox.x0); + const x0 = @max(page_bbox.x0, @min(page_bbox.x1, bbox.x0)); + const x1 = @max(page_bbox.x0, @min(page_bbox.x1, bbox.x1)); + return .{ + .first = @min(count - 1, @as(usize, @intFromFloat(@floor((x0 - page_bbox.x0) * scale)))), + .last = @min(count - 1, @as(usize, @intFromFloat(@floor((x1 - page_bbox.x0) * scale)))), + }; +} + +fn inlineNeighbors(previous: GlyphSpan, current: GlyphSpan) bool { + if (!sameOrientation(previous, current)) return false; + const axis = writingAxis(previous); + const dx = current.text_matrix[4] - previous.text_matrix[4]; + const dy = current.text_matrix[5] - previous.text_matrix[5]; + const normal_em = @max(baselineFontSize(previous), baselineFontSize(current)); + if (@abs(-dx * axis.y + dy * axis.x) > @max(1.5, normal_em * 0.55)) return false; + const em = @max(1.0, @max(previous.font_size * axis.scale, current.font_size * writingAxis(current).scale)); + const font_space = @max(previous.font_space_advance * axis.scale, current.font_space_advance * writingAxis(current).scale); + const threshold = if (font_space > 0 and std.math.isFinite(font_space)) font_space * 0.5 else em * 0.105; + const gap = inlineGap(previous, current); + return gap >= -em * 0.5 and gap <= threshold; +} + +fn splitsContinuousWord(glyphs: []const GlyphSpan, gutter: BBox) bool { + if (glyphs.len < 2) return false; + for (glyphs[0 .. glyphs.len - 1], glyphs[1..]) |previous, current| { + if (isWhitespace(previous.text) or isWhitespace(current.text) or !sameOrientation(previous, current) or + regionForGlyph(previous.bbox, gutter) == regionForGlyph(current.bbox, gutter)) continue; + if (inlineNeighbors(previous, current)) return true; + } + return false; +} + fn baselineBandCount(glyphs: []const GlyphSpan, split_x: f64, left_side: bool) usize { var bands: [64]f64 = undefined; var count: usize = 0; @@ -640,8 +725,9 @@ fn classifyBoundaries( } const gap = inlineGap(previous, current); - const em = @max(1.0, @max(previous.font_size, current.font_size)); - const space_threshold = em * 0.105; + const em = @max(1.0, @max(previous.font_size * writingAxis(previous).scale, current.font_size * writingAxis(current).scale)); + const font_space = @max(previous.font_space_advance * writingAxis(previous).scale, current.font_space_advance * writingAxis(current).scale); + const space_threshold = if (font_space > 0 and std.math.isFinite(font_space)) font_space * 0.5 else em * 0.105; const is_space = gap > space_threshold; const ratio = gap / em; boundaries[ordered_index] = .{ @@ -658,13 +744,9 @@ fn classifyBoundaries( } fn inlineGap(previous: GlyphSpan, current: GlyphSpan) f64 { - if (previous.writing_mode == 1) return previous.bbox.y0 - current.bbox.y1; - const a = previous.text_matrix[0]; - const b = previous.text_matrix[1]; - if (@abs(b) > @abs(a) * 0.5) { - return inlineCoordinate(current) - inlineCoordinate(previous) - previous.advance; - } - return current.bbox.x0 - previous.bbox.x1; + const axis = writingAxis(previous); + return (current.text_matrix[4] - previous.text_matrix[4]) * axis.x + + (current.text_matrix[5] - previous.text_matrix[5]) * axis.y - previous.advance * axis.scale; } fn renderReadable( @@ -683,7 +765,7 @@ fn renderReadable( const boundary = boundaries[ordered_index]; if (ordered_index > 0) switch (boundary.kind) { .join => {}, - .space => if (!endsInWhitespace(output.items)) try output.append(allocator, ' '), + .space => if (!endsInWhitespace(output.items) and !(glyphs[glyph_index].actual_text and startsInWhitespace(glyphs[glyph_index].text))) try output.append(allocator, ' '), .line_break => try appendBreak(allocator, &output, 1), .block_break, .region_break => try appendBreak(allocator, &output, 2), }; @@ -692,12 +774,12 @@ fn renderReadable( line_index += 1; mutable_lines[line_index].text_start = @intCast(output.items.len); } - try appendNormalizedGlyph(allocator, &output, glyphs[glyph_index].text); + try appendNormalizedGlyph(allocator, &output, glyphs[glyph_index].text, glyphs[glyph_index].actual_text); } if (mutable_lines.len > 0) { mutable_lines[line_index].text_len = @intCast(output.items.len - mutable_lines[line_index].text_start); } - trimTrailingWhitespace(&output); + if (ordered.len == 0 or !glyphs[ordered[ordered.len - 1]].actual_text) trimTrailingWhitespace(&output); synchronizeLineTextRanges(mutable_lines, output.items.len); return output.toOwnedSlice(allocator); } @@ -715,7 +797,11 @@ fn synchronizeLineTextRanges(lines: []Line, text_len: usize) void { } } -fn appendNormalizedGlyph(allocator: std.mem.Allocator, output: *std.ArrayList(u8), text: []const u8) !void { +fn appendNormalizedGlyph(allocator: std.mem.Allocator, output: *std.ArrayList(u8), text: []const u8, actual_text: bool) !void { + if (actual_text) { + try output.appendSlice(allocator, text); + return; + } if (isWhitespace(text)) { if (output.items.len > 0 and !endsInWhitespace(output.items)) try output.append(allocator, ' '); return; @@ -997,6 +1083,13 @@ fn endsInWhitespace(text: []const u8) bool { return std.ascii.isWhitespace(text[text.len - 1]); } +fn startsInWhitespace(text: []const u8) bool { + if (text.len == 0) return false; + const len = std.unicode.utf8ByteSequenceLength(text[0]) catch return false; + if (len > text.len) return false; + return isWhitespaceCodepoint(std.unicode.utf8Decode(text[0..len]) catch return false); +} + fn trimTrailingSpaces(output: *std.ArrayList(u8)) void { while (output.items.len > 0 and (output.items[output.items.len - 1] == ' ' or output.items[output.items.len - 1] == '\t')) _ = output.pop(); } @@ -1039,12 +1132,232 @@ test "glyph geometry reconstructs missing spaces and line breaks" { try std.testing.expectEqual(BoundaryKind.line_break, result.boundaries[2].kind); } +fn rotatedTestGlyph(text: []const u8, distance: f64, vertical: bool) GlyphSpan { + const component = @sqrt(@as(f64, 0.5)); + var glyph = testGlyph(text, 100 + distance * component * 2, 100 + distance * component * 2 * (if (vertical) @as(f64, -1) else 1), 6); + glyph.text_matrix[0] = component * 2; + glyph.text_matrix[1] = component * 2; + glyph.text_matrix[2] = -component * 2; + glyph.text_matrix[3] = component * 2; + glyph.font_space_advance = 3; + glyph.writing_mode = if (vertical) 1 else 0; + return glyph; +} + +test "projected rotated advances preserve within-word joins and real word gaps" { + const glyphs = [_]GlyphSpan{ + rotatedTestGlyph("A", 0, false), rotatedTestGlyph("B", 6, false), + rotatedTestGlyph("C", 15, false), rotatedTestGlyph("D", 21, false), + }; + var result = try analyze(std.testing.allocator, &glyphs, .{ .x0 = 0, .y0 = 0, .x1 = 300, .y1 = 300 }, "ABCD"); + defer result.deinit(); + try std.testing.expectEqualStrings("AB CD", result.text); + try std.testing.expectEqual(@as(usize, 1), result.lines.len); + try std.testing.expectEqual(BoundaryKind.join, result.boundaries[1].kind); + try std.testing.expectEqual(BoundaryKind.space, result.boundaries[2].kind); +} + +test "projected vertical advances support rotation and font-space evidence" { + const glyphs = [_]GlyphSpan{ + rotatedTestGlyph("A", 0, true), rotatedTestGlyph("B", 6, true), + rotatedTestGlyph("C", 15, true), rotatedTestGlyph("D", 21, true), + }; + var result = try analyze(std.testing.allocator, &glyphs, .{ .x0 = 0, .y0 = 0, .x1 = 300, .y1 = 300 }, "ABCD"); + defer result.deinit(); + try std.testing.expectEqualStrings("AB CD", result.text); + try std.testing.expectEqual(@as(usize, 1), result.lines.len); +} + +test "scaled baseline bands retain mixed-font superscripts on the same line" { + var first = testGlyph("F", 100, 100, 6); + first.text_matrix[0] = 2; + first.text_matrix[3] = 2; + var superscript = testGlyph("1", 112, 110, 4); + superscript.font_size = 8; + superscript.text_matrix[0] = 2; + superscript.text_matrix[3] = 2; + var tail = testGlyph(".25", 120, 100, 12); + tail.text_matrix[0] = 2; + tail.text_matrix[3] = 2; + const glyphs = [_]GlyphSpan{ first, superscript, tail }; + var result = try analyze(std.testing.allocator, &glyphs, .{ .x0 = 0, .y0 = 0, .x1 = 300, .y1 = 300 }, "F1.25"); + defer result.deinit(); + try std.testing.expectEqualStrings("F1.25", result.text); + try std.testing.expectEqual(@as(usize, 1), result.lines.len); +} + +test "column gutter cannot split a continuous transformed word at paint boundaries" { + const words = [_][]const u8{ "VISIBLE", "HIDDEN", "CLIPPED", "TRANSFORMED" }; + const xs = [_]f64{ 20, 20, 180, 50 }; + const ys = [_]f64{ 260, 230, 180, 60 }; + var glyphs: [31]GlyphSpan = undefined; + var count: usize = 0; + for (words, 0..) |word, row| { + for (word, 0..) |_, letter| { + const scale: f64 = if (row == 3) 2 else 1; + const advance: f64 = if (row == 3) 8 else 6; + const x = xs[row] + @as(f64, @floatFromInt(letter)) * advance * scale; + var glyph = testGlyph(word[letter .. letter + 1], x, ys[row], advance); + glyph.text_matrix[0] = scale; + glyph.text_matrix[3] = scale; + glyph.bbox.x1 = x + advance * scale; + glyph.font_space_advance = 3; + if (row == 3) { + glyph.paint = (layout.PaintEvidence{ .rendering_mode = 0, .paint_state = .painting, .clip_bbox = .{ .x0 = 30, .y0 = 40, .x1 = 130, .y1 = 140 } }).forBBox(glyph.bbox); + } + glyphs[count] = glyph; + count += 1; + } + } + var native = try analyze(std.testing.allocator, glyphs[0..count], .{ .x0 = 0, .y0 = 0, .x1 = 300, .y1 = 300 }, "VISIBLE HIDDEN CLIPPED TRANSFORMED"); + defer native.deinit(); + try std.testing.expect(native.gutter == null); + try std.testing.expect(std.mem.indexOf(u8, native.text, "TRANSFORMED") != null); + const spans = try buildLineSpans(std.testing.allocator, glyphs[0..count], &native, 0); + defer { + for (spans) |span| std.testing.allocator.free(@constCast(span.text)); + std.testing.allocator.free(spans); + } + var reconciled = try @import("reconcile.zig").reconcile(std.testing.allocator, &.{.{ .spans = spans }}, .{ .preserve_input_order = true }); + defer reconciled.deinit(); + try std.testing.expect(std.mem.indexOf(u8, reconciled.chunks[0].content, "TRANSFORMED") != null); +} + +test "word-continuity guard preserves corroborated two-column gutters" { + var glyphs: [54]GlyphSpan = undefined; + var count: usize = 0; + for ([_]f64{ 260, 240 }) |y| { + for ([_][]const u8{ "LEFTPARAGRAPH", "RIGHTPARAGRAPH" }, 0..) |word, column| { + const x: f64 = if (column == 0) 20 else 180; + for (word, 0..) |_, index| { + var glyph = testGlyph(word[index .. index + 1], x + @as(f64, @floatFromInt(index)) * 6, y, 6); + glyph.font_space_advance = 3; + glyphs[count] = glyph; + count += 1; + } + } + } + var native = try analyze(std.testing.allocator, glyphs[0..count], .{ .x0 = 0, .y0 = 0, .x1 = 300, .y1 = 300 }, "LEFTPARAGRAPH RIGHTPARAGRAPH LEFTPARAGRAPH RIGHTPARAGRAPH"); + defer native.deinit(); + try std.testing.expect(native.gutter != null); + try std.testing.expectEqualStrings("LEFTPARAGRAPH\nLEFTPARAGRAPH\n\nRIGHTPARAGRAPH\nRIGHTPARAGRAPH", native.text); +} + +test "central footer does not veto corroborated columns or split its own word" { + var glyphs: [56]GlyphSpan = undefined; + var count: usize = 0; + for ([_]f64{ 720, 700 }) |y| { + for ([_][]const u8{ "LEFTPARAGRAPH", "RIGHTPARAGRAPH" }, 0..) |word, column| { + const x: f64 = if (column == 0) 72 else 330; + for (word, 0..) |_, index| { + var glyph = testGlyph(word[index .. index + 1], x + @as(f64, @floatFromInt(index)) * 6, y, 6); + glyph.font_space_advance = 3; + glyphs[count] = glyph; + count += 1; + } + } + } + glyphs[count] = testGlyph("4", 300, 60, 6); + glyphs[count + 1] = testGlyph("2", 306, 60, 6); + var native = try analyze(std.testing.allocator, &glyphs, .{ .x0 = 0, .y0 = 0, .x1 = 612, .y1 = 792 }, "LEFTPARAGRAPH RIGHTPARAGRAPH LEFTPARAGRAPH RIGHTPARAGRAPH 42"); + defer native.deinit(); + try std.testing.expect(native.gutter != null); + try std.testing.expectEqualStrings("LEFTPARAGRAPH\nLEFTPARAGRAPH\n\nRIGHTPARAGRAPH\nRIGHTPARAGRAPH\n\n42", native.text); +} + +test "semantic ActualText spans retain exact whitespace through native layout" { + var glyph = testGlyph(" first\nsecond ", 72, 700, 30); + glyph.actual_text = true; + const glyphs = [_]GlyphSpan{glyph}; + var result = try analyze(std.testing.allocator, &glyphs, .{ .x0 = 0, .y0 = 0, .x1 = 300, .y1 = 800 }, glyph.text); + defer result.deinit(); + try std.testing.expectEqualStrings(glyph.text, result.text); + const spans = try buildLineSpans(std.testing.allocator, &glyphs, &result, 0); + defer { + for (spans) |span| std.testing.allocator.free(@constCast(span.text)); + std.testing.allocator.free(spans); + } + try std.testing.expectEqualStrings(glyph.text, spans[0].text); +} + +test "scoped provenance splits put synthetic separators outside exact span text" { + var first = testGlyph("A", 100, 700, 6); + first.content_scope = .{ .page_index = 0, .stream_object_num = 17, .invocation_index = 1 }; + var second = testGlyph("C", 108, 700, 6); + second.content_scope = .{ .page_index = 0, .stream_object_num = 17, .invocation_index = 2 }; + const glyphs = [_]GlyphSpan{ first, second }; + var native = try analyze(std.testing.allocator, &glyphs, .{ .x0 = 0, .y0 = 0, .x1 = 300, .y1 = 800 }, "AC"); + defer native.deinit(); + const spans = try buildLineSpans(std.testing.allocator, &glyphs, &native, 0); + defer { + for (spans) |span| std.testing.allocator.free(@constCast(span.text)); + std.testing.allocator.free(spans); + } + try std.testing.expectEqualStrings("A", spans[0].text); + try std.testing.expectEqualStrings("C", spans[1].text); + try std.testing.expectEqual(BoundaryKind.space, spans[1].leading_boundary.?); + var reconciled = try @import("reconcile.zig").reconcile(std.testing.allocator, &.{.{ .spans = spans }}, .{ .preserve_input_order = true }); + defer reconciled.deinit(); + try std.testing.expectEqualStrings("A C", reconciled.blocks[0].text); + try std.testing.expectEqual(@as(usize, 2), reconciled.spans[1].block_text_start); +} + +test "whitespace-only ActualText scopes retain one exact authored separator" { + var separator = testGlyph("\u{a0}", 106, 700, 4); + separator.actual_text = true; + separator.semantic_run_id = 1; + separator.semantic_replacement = true; + const glyphs = [_]GlyphSpan{ testGlyph("A", 100, 700, 6), separator, testGlyph("B", 110, 700, 6) }; + var native = try analyze(std.testing.allocator, &glyphs, .{ .x0 = 0, .y0 = 0, .x1 = 300, .y1 = 800 }, "A\u{a0}B"); + defer native.deinit(); + try std.testing.expectEqualStrings("A\u{a0}B", native.text); + const spans = try buildLineSpans(std.testing.allocator, &glyphs, &native, 0); + defer { + for (spans) |span| std.testing.allocator.free(@constCast(span.text)); + std.testing.allocator.free(spans); + } + try std.testing.expectEqualStrings("\u{a0}", spans[1].text); + var reconciled = try @import("reconcile.zig").reconcile(std.testing.allocator, &.{.{ .spans = spans }}, .{ .preserve_input_order = true }); + defer reconciled.deinit(); + try std.testing.expectEqualStrings("A\u{a0}B", reconciled.blocks[0].text); +} + +test "mathematical font switches retain explicit word spaces after segment trimming" { + // Derived from the checked-in mathematical-pi fixture: each comparison + // operator uses another font immediately after an explicit text space. + const tokens = [_][]const u8{ "BMI", " ", "<", "25 BMI", " ", ">", "30 IRP", " ", "≥", " upper limit", " ", "→", " muscle" }; + var glyphs: [tokens.len]GlyphSpan = undefined; + var x: f64 = 100; + for (tokens, 0..) |token, index| { + glyphs[index] = testGlyph(token, x, 700, 6); + glyphs[index].font_name = if (index == 2 or index == 5 or index == 8) "MathematicalPi-One" else if (index == 11) "Symbol" else "Body"; + x += 6; + } + const expected = "BMI <25 BMI >30 IRP ≥ upper limit → muscle"; + var native = try analyze(std.testing.allocator, &glyphs, .{ .x0 = 0, .y0 = 0, .x1 = 612, .y1 = 792 }, expected); + defer native.deinit(); + try std.testing.expectEqualStrings(expected, native.text); + const spans = try buildLineSpans(std.testing.allocator, &glyphs, &native, 0); + defer { + for (spans) |span| { + std.testing.allocator.free(@constCast(span.text)); + if (span.font.name) |name| std.testing.allocator.free(@constCast(name)); + } + std.testing.allocator.free(spans); + } + var reconciled = try @import("reconcile.zig").reconcile(std.testing.allocator, &.{.{ .spans = spans }}, .{ .preserve_input_order = true }); + defer reconciled.deinit(); + try std.testing.expectEqualStrings(expected, reconciled.blocks[0].text); + try std.testing.expectEqualStrings(expected, reconciled.chunks[0].content); +} + test "line spans preserve representative font and vertical provenance" { var glyph = testGlyph("日", 300, 700, 12); glyph.font_name = "FVertical"; glyph.writing_mode = 1; glyph.actual_text = true; glyph.mcid = 7; + glyph.paint = .{ .rendering_mode = 3, .paint_state = .non_painting, .clip_state = .outside, .clip_bbox = .{ .x0 = 0, .y0 = 0, .x1 = 20, .y1 = 20 } }; const glyphs = [_]GlyphSpan{glyph}; var result = try analyze(std.testing.allocator, &glyphs, .{ .x0 = 0, .y0 = 0, .x1 = 612, .y1 = 792 }, "日"); defer result.deinit(); @@ -1062,6 +1375,50 @@ test "line spans preserve representative font and vertical provenance" { try std.testing.expectEqual(@as(u8, 1), spans[0].writing_mode); try std.testing.expect(spans[0].actual_text); try std.testing.expectEqual(@as(?i32, 7), spans[0].mcid); + try std.testing.expect(std.meta.eql(glyph.paint, spans[0].paint)); +} + +test "line spans split equal-font glyphs at paint evidence changes" { + var visible = testGlyph("A", 72, 700, 6); + visible.paint = .{ .rendering_mode = 0, .paint_state = .painting, .clip_state = .inside, .clip_bbox = .{ .x0 = 70, .y0 = 690, .x1 = 100, .y1 = 720 } }; + var hidden = testGlyph("B", 78, 700, 6); + hidden.paint = visible.paint; + hidden.paint.rendering_mode = 3; + hidden.paint.paint_state = .non_painting; + const glyphs = [_]GlyphSpan{ visible, hidden }; + var result = try analyze(std.testing.allocator, &glyphs, .{ .x0 = 0, .y0 = 0, .x1 = 612, .y1 = 792 }, "AB"); + defer result.deinit(); + const spans = try buildLineSpans(std.testing.allocator, &glyphs, &result, 0); + defer { + for (spans) |span| { + std.testing.allocator.free(@constCast(span.text)); + if (span.font.name) |name| std.testing.allocator.free(@constCast(name)); + } + std.testing.allocator.free(spans); + } + try std.testing.expectEqual(@as(usize, 2), spans.len); + try std.testing.expectEqual(.painting, spans[0].paint.paint_state); + try std.testing.expectEqual(.non_painting, spans[1].paint.paint_state); +} + +test "line span aggregate clip evidence is conservative across separated outside glyphs" { + var left = testGlyph("A", 72, 700, 6); + left.paint = .{ .rendering_mode = 0, .paint_state = .painting, .clip_state = .outside, .clip_bbox = .{ .x0 = 79, .y0 = 690, .x1 = 81, .y1 = 720 } }; + var right = testGlyph("B", 82, 700, 6); + right.paint = left.paint; + const glyphs = [_]GlyphSpan{ left, right }; + var result = try analyze(std.testing.allocator, &glyphs, .{ .x0 = 0, .y0 = 0, .x1 = 612, .y1 = 792 }, "AB"); + defer result.deinit(); + const spans = try buildLineSpans(std.testing.allocator, &glyphs, &result, 0); + defer { + for (spans) |span| { + std.testing.allocator.free(@constCast(span.text)); + if (span.font.name) |name| std.testing.allocator.free(@constCast(name)); + } + std.testing.allocator.free(spans); + } + try std.testing.expectEqual(@as(usize, 1), spans.len); + try std.testing.expectEqual(.partial, spans[0].paint.clip_state); } test "line spans split mixed font and marked-content provenance" { @@ -1153,12 +1510,15 @@ test "quality gate reports extreme token and line rates" { } fn lineSpanAllocationProbe(allocator: std.mem.Allocator) !void { - const glyphs = [_]GlyphSpan{ + var glyphs = [_]GlyphSpan{ testGlyph("Hello", 72, 700, 26), testGlyph("world", 102, 700, 27), testGlyph("Second", 72, 682, 36), testGlyph("line", 112, 682, 18), }; + glyphs[1].font_name = "Alternate"; + glyphs[2].content_scope = .{ .page_index = 0, .stream_object_num = 17, .invocation_index = 1 }; + glyphs[3].semantic_run_id = 2; var result = try analyze( allocator, &glyphs, @@ -1169,7 +1529,10 @@ fn lineSpanAllocationProbe(allocator: std.mem.Allocator) !void { const spans = try buildLineSpans(allocator, &glyphs, &result, 0); defer { - for (spans) |span| allocator.free(@constCast(span.text)); + for (spans) |span| { + allocator.free(@constCast(span.text)); + if (span.font.name) |name| allocator.free(@constCast(name)); + } allocator.free(spans); } } diff --git a/src/ocr.zig b/src/ocr.zig index 1fab87b..ab396de 100644 --- a/src/ocr.zig +++ b/src/ocr.zig @@ -8,6 +8,7 @@ const layout = @import("layout.zig"); const complexity = @import("complexity.zig"); pub const types = @import("ocr/types.zig"); +pub const crop = @import("ocr/crop.zig"); pub const tesseract_cli = @import("ocr/tesseract_cli.zig"); pub const tesseract_c = @import("ocr/tesseract_c.zig"); pub const tesseract_tsv = @import("ocr/tesseract_tsv.zig"); @@ -97,6 +98,7 @@ fn elapsedMs(started_ns: i128) u64 { } test { + _ = crop; _ = tesseract_cli; _ = tesseract_c; _ = tesseract_tsv; diff --git a/src/ocr/crop.zig b/src/ocr/crop.zig new file mode 100644 index 0000000..124375c --- /dev/null +++ b/src/ocr/crop.zig @@ -0,0 +1,128 @@ +//! Pixel-aligned PDF crop geometry and bounded raster identity. +const std = @import("std"); +const layout = @import("../layout.zig"); +const runtime = @import("../runtime.zig"); + +pub const Geometry = struct { + pdf_bbox: layout.BBox, + x: u32, + y: u32, + width: u32, + height: u32, +}; + +pub fn geometry(page: layout.BBox, region: layout.BBox, padding: f64, dpi: u32) ?Geometry { + for ([_]f64{ page.x0, page.y0, page.x1, page.y1, region.x0, region.y0, region.x1, region.y1, padding }) |value| { + if (!std.math.isFinite(value)) return null; + } + if (dpi == 0 or padding < 0 or page.x1 <= page.x0 or page.y1 <= page.y0 or region.x1 <= region.x0 or region.y1 <= region.y0) return null; + const bbox = layout.BBox{ + .x0 = @max(page.x0, region.x0 - padding), + .y0 = @max(page.y0, region.y0 - padding), + .x1 = @min(page.x1, region.x1 + padding), + .y1 = @min(page.y1, region.y1 + padding), + }; + if (bbox.x1 <= bbox.x0 or bbox.y1 <= bbox.y0) return null; + const scale = @as(f64, @floatFromInt(dpi)) / 72.0; + const left = @floor((bbox.x0 - page.x0) * scale); + const top = @floor((page.y1 - bbox.y1) * scale); + const right = @ceil((bbox.x1 - page.x0) * scale); + const bottom = @ceil((page.y1 - bbox.y0) * scale); + const limit: f64 = @floatFromInt(std.math.maxInt(i32)); + if (right > limit or bottom > limit or right <= left or bottom <= top) return null; + return .{ + // The raster is rounded outward; use its actual pixel boundaries for + // OCR coordinates rather than stretching words into the requested box. + .pdf_bbox = .{ .x0 = page.x0 + left / scale, .y0 = page.y1 - bottom / scale, .x1 = page.x0 + right / scale, .y1 = page.y1 - top / scale }, + .x = @intFromFloat(left), + .y = @intFromFloat(top), + .width = @intFromFloat(right - left), + .height = @intFromFloat(bottom - top), + }; +} + +pub fn withinPixelLimit(width: u32, height: u32, max_pixels: usize) bool { + if (width == 0 or height == 0) return false; + return @as(u64, width) * @as(u64, height) <= max_pixels; +} + +pub const ImageIdentity = struct { width: u32, height: u32, sha256: [32]u8, byte_length: usize }; + +pub fn readIdentity(allocator: std.mem.Allocator, path: []const u8, max_bytes: usize, max_pixels: usize) !ImageIdentity { + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const file = try std.Io.Dir.cwd().openFile(io, path, .{}); + defer file.close(io); + const size = (try file.stat(io)).size; + if (size > max_bytes) return error.ImageByteLimitExceeded; + if (size < 24) return error.InvalidRasterImage; + var header: [24]u8 = undefined; + if (try file.readPositionalAll(io, &header, 0) != header.len) return error.InvalidRasterImage; + if (!std.mem.eql(u8, header[0..8], "\x89PNG\r\n\x1a\n") or !std.mem.eql(u8, header[12..16], "IHDR")) return error.InvalidRasterImage; + const width = std.mem.readInt(u32, header[16..20], .big); + const height = std.mem.readInt(u32, header[20..24], .big); + if (!withinPixelLimit(width, height, max_pixels)) return error.ImagePixelLimitExceeded; + const data = try allocator.alloc(u8, @intCast(size)); + defer allocator.free(data); + if (try file.readPositionalAll(io, data, 0) != data.len) return error.InvalidRasterImage; + // Check growth after the bounded read as well. Never allocate from an + // unvalidated file size or accept a changed file as the same artifact. + if ((try file.stat(io)).size != size or !std.mem.eql(u8, data[0..24], &header)) return error.InvalidRasterImage; + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(data, &digest, .{}); + return .{ .width = width, .height = height, .sha256 = digest, .byte_length = data.len }; +} + +test "OCR crops clamp and map nonzero page origins with outward pixel rounding" { + const crop = geometry(.{ .x0 = 10, .y0 = 20, .x1 = 110, .y1 = 120 }, .{ .x0 = 25.2, .y0 = 35.3, .x1 = 50.4, .y1 = 70.6 }, 0, 144).?; + try std.testing.expectEqual(@as(u32, 30), crop.x); + try std.testing.expectEqual(@as(u32, 98), crop.y); + try std.testing.expectEqual(@as(u32, 51), crop.width); + try std.testing.expectEqual(@as(u32, 72), crop.height); + try std.testing.expectEqual(@as(f64, 25), crop.pdf_bbox.x0); + try std.testing.expectEqual(@as(f64, 35), crop.pdf_bbox.y0); + const edge = geometry(.{ .x0 = 10, .y0 = 20, .x1 = 110, .y1 = 120 }, .{ .x0 = 5, .y0 = 110, .x1 = 25, .y1 = 125 }, 2, 72).?; + try std.testing.expectEqual(@as(u32, 0), edge.x); + try std.testing.expectEqual(@as(u32, 0), edge.y); + try std.testing.expectEqual(@as(u32, 17), edge.width); + try std.testing.expectEqual(@as(u32, 12), edge.height); +} + +test "OCR crop geometry rejects invalid bounds and pixel limits do not overflow" { + const page = layout.BBox{ .x0 = 0, .y0 = 0, .x1 = 100, .y1 = 100 }; + try std.testing.expect(geometry(page, page, 0, 0) == null); + try std.testing.expect(geometry(page, page, -1, 72) == null); + try std.testing.expect(geometry(page, .{ .x0 = 200, .y0 = 200, .x1 = 300, .y1 = 300 }, 0, 72) == null); + try std.testing.expect(geometry(page, .{ .x0 = std.math.nan(f64), .y0 = 0, .x1 = 1, .y1 = 1 }, 0, 72) == null); + try std.testing.expect(!withinPixelLimit(std.math.maxInt(u32), std.math.maxInt(u32), 100)); + try std.testing.expect(withinPixelLimit(10, 10, 100)); + try std.testing.expect(!withinPixelLimit(10, 10, 99)); +} + +fn imageAllocationProbe(allocator: std.mem.Allocator, path: []const u8) !void { + const image = try readIdentity(allocator, path, 24, 200); + try std.testing.expectEqual(@as(usize, 24), image.byte_length); +} + +test "bounded raster identity validates bytes pixels hashes and allocation cleanup" { + var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); + defer threaded.deinit(); + runtime.setIo(threaded.io()); + const path = try std.fmt.allocPrint(std.testing.allocator, ".pdf-parser-crop-identity-{x}.png", .{runtime.nanoTimestamp()}); + defer std.testing.allocator.free(path); + defer runtime.deleteFileCwd(path); + const header = "\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x0a\x00\x00\x00\x14"; + const file = try runtime.createFileCwd(path); + try runtime.writeAllFile(file, header); + runtime.closeFile(file); + const image = try readIdentity(std.testing.allocator, path, 24, 200); + var hash: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(header, &hash, .{}); + try std.testing.expectEqualSlices(u8, &hash, &image.sha256); + try std.testing.expectEqual(@as(u32, 10), image.width); + try std.testing.expectEqual(@as(u32, 20), image.height); + try std.testing.expectError(error.ImageByteLimitExceeded, readIdentity(std.testing.allocator, path, 23, 200)); + try std.testing.expectError(error.ImagePixelLimitExceeded, readIdentity(std.testing.allocator, path, 24, 199)); + try std.testing.checkAllAllocationFailures(std.testing.allocator, imageAllocationProbe, .{path}); +} diff --git a/src/ocr/types.zig b/src/ocr/types.zig index b29d3dc..8af2ee2 100644 --- a/src/ocr/types.zig +++ b/src/ocr/types.zig @@ -28,6 +28,12 @@ pub const DiagnosticCode = enum { rasterizer_timeout, rasterizer_output_limit, invalid_raster_image, + rasterizer_pixel_limit, + rasterizer_image_byte_limit, + rasterizer_rotation_unsupported, + invalid_crop_geometry, + ocr_region_limit, + ocr_attempt_limit, tesseract_unavailable, tesseract_failed, tesseract_timeout, @@ -80,6 +86,11 @@ pub const OcrConfig = struct { timeout_ms: u32 = 10_000, stdout_limit: usize = 16 * 1024 * 1024, stderr_limit: usize = 1024 * 1024, + max_image_pixels: usize = 40_000_000, + max_image_bytes: usize = 32 * 1024 * 1024, + max_regions_per_page: usize = 8, + max_attempts_per_page: usize = 16, + crop_padding_points: f64 = 2, }; pub const OcrInput = struct { @@ -88,6 +99,8 @@ pub const OcrInput = struct { image_path: []const u8, pixel_width: u32, pixel_height: u32, + image_sha256: ?[32]u8 = null, + image_byte_length: ?usize = null, }; pub const RasterizeOutcome = struct { diff --git a/src/paint.zig b/src/paint.zig new file mode 100644 index 0000000..7f8282a --- /dev/null +++ b/src/paint.zig @@ -0,0 +1,140 @@ +//! Conservative content-stream graphics evidence in PDF user space. +const std = @import("std"); +const layout = @import("layout.zig"); + +pub const identity: [6]f64 = .{ 1, 0, 0, 1, 0, 0 }; + +pub fn concat(outer: [6]f64, inner: [6]f64) [6]f64 { + return .{ + outer[0] * inner[0] + outer[2] * inner[1], + outer[1] * inner[0] + outer[3] * inner[1], + outer[0] * inner[2] + outer[2] * inner[3], + outer[1] * inner[2] + outer[3] * inner[3], + outer[0] * inner[4] + outer[2] * inner[5] + outer[4], + outer[1] * inner[4] + outer[3] * inner[5] + outer[5], + }; +} + +pub const State = struct { + ctm: [6]f64 = identity, + geometry_known: bool = true, + rendering_mode: ?u8 = 0, + clip_bbox: ?layout.BBox = null, + clip_unknown: bool = false, + + pub fn evidence(self: State, box: layout.BBox) layout.PaintEvidence { + const value: layout.PaintEvidence = .{ + .rendering_mode = self.rendering_mode, + .paint_state = if (self.rendering_mode) |mode| (if (mode == 3 or mode == 7) .non_painting else .painting) else .unknown, + .clip_state = if (self.clip_unknown or !self.geometry_known) .unknown else .unclipped, + .clip_bbox = if (self.clip_unknown or !self.geometry_known) null else self.clip_bbox, + }; + return value.forBBox(box); + } +}; + +pub const Tracker = struct { + state: State = .{}, + stack: std.ArrayList(State) = .empty, + path_rect: ?layout.BBox = null, + path_unknown: bool = false, + pending_clip: bool = false, + text_clip_pending: bool = false, + + pub fn deinit(self: *Tracker, allocator: std.mem.Allocator) void { + self.stack.deinit(allocator); + } + + pub fn save(self: *Tracker, allocator: std.mem.Allocator) !void { + try self.stack.append(allocator, self.state); + } + + pub fn restore(self: *Tracker) void { + if (self.stack.pop()) |state| { + self.state = state; + } else { + self.state.clip_unknown = true; + self.state.rendering_mode = null; + } + } + + pub fn rectangle(self: *Tracker, x: f64, y: f64, width: f64, height: f64) void { + const m = self.state.ctm; + const aligned = (m[1] == 0 and m[2] == 0) or (m[0] == 0 and m[3] == 0); + if (!aligned or !self.state.geometry_known or self.path_rect != null or self.path_unknown) { + self.path_unknown = true; + self.path_rect = null; + return; + } + const ax = m[0] * x + m[2] * y + m[4]; + const ay = m[1] * x + m[3] * y + m[5]; + const bx = m[0] * (x + width) + m[2] * (y + height) + m[4]; + const by = m[1] * (x + width) + m[3] * (y + height) + m[5]; + if (!std.math.isFinite(ax) or !std.math.isFinite(ay) or !std.math.isFinite(bx) or !std.math.isFinite(by)) { + self.path_unknown = true; + return; + } + self.path_rect = .{ .x0 = @min(ax, bx), .y0 = @min(ay, by), .x1 = @max(ax, bx), .y1 = @max(ay, by) }; + } + + pub fn endPath(self: *Tracker) void { + if (self.pending_clip) { + if (self.path_unknown or self.path_rect == null or self.state.clip_unknown) { + self.state.clip_unknown = true; + self.state.clip_bbox = null; + } else { + var next = self.path_rect.?; + if (self.state.clip_bbox) |old| { + next = .{ .x0 = @max(old.x0, next.x0), .y0 = @max(old.y0, next.y0), .x1 = @min(old.x1, next.x1), .y1 = @min(old.y1, next.y1) }; + // Keep disjoint intersection as an empty rectangle. + next.x1 = @max(next.x0, next.x1); + next.y1 = @max(next.y0, next.y1); + } + self.state.clip_bbox = next; + } + } + self.path_rect = null; + self.path_unknown = false; + self.pending_clip = false; + } + + pub fn shownText(self: *Tracker) void { + if (self.state.rendering_mode) |mode| { + if (mode >= 4) self.text_clip_pending = true; + } else { + self.text_clip_pending = true; + } + } + + pub fn endText(self: *Tracker) void { + if (self.text_clip_pending) { + self.state.clip_unknown = true; + self.state.clip_bbox = null; + } + self.text_clip_pending = false; + } +}; + +test "rectangular clips classify geometry and intersect conservatively" { + var tracker: Tracker = .{}; + tracker.rectangle(0, 0, 20, 20); + tracker.pending_clip = true; + try std.testing.expectEqual(.unclipped, tracker.state.evidence(.{}).clip_state); + tracker.endPath(); + try std.testing.expectEqual(.inside, tracker.state.evidence(.{ .x0 = 2, .y0 = 2, .x1 = 8, .y1 = 8 }).clip_state); + try std.testing.expectEqual(.outside, tracker.state.evidence(.{ .x0 = 30, .y0 = 2, .x1 = 38, .y1 = 8 }).clip_state); + try std.testing.expectEqual(.partial, tracker.state.evidence(.{ .x0 = 10, .y0 = 2, .x1 = 28, .y1 = 8 }).clip_state); + tracker.rectangle(30, 0, 20, 20); + tracker.pending_clip = true; + tracker.endPath(); + try std.testing.expectEqual(.outside, tracker.state.evidence(.{ .x0 = 25, .y0 = 2, .x1 = 35, .y1 = 8 }).clip_state); +} + +test "rotated arbitrary clips are unknown rather than their bounding rectangle" { + var tracker: Tracker = .{}; + tracker.state.ctm = .{ 1, 1, -1, 1, 0, 0 }; + tracker.rectangle(0, 0, 20, 20); + tracker.pending_clip = true; + tracker.endPath(); + try std.testing.expectEqual(.unknown, tracker.state.evidence(.{}).clip_state); +} diff --git a/src/reconcile.zig b/src/reconcile.zig index e532e3a..bd57019 100644 --- a/src/reconcile.zig +++ b/src/reconcile.zig @@ -501,7 +501,13 @@ fn buildBlocks( if (text.items.len > 0) { const previous = spans[index - 1].span; - if (sameLine(previous, span.span, options.same_line_threshold)) { + if (nativeBoundary(previous, span.span, options)) |boundary| { + switch (boundary) { + .join => {}, + .space => if (!endsInUnicodeWhitespace(text.items) and !startsInUnicodeWhitespace(span.span.text)) try text.append(allocator, ' '), + .line_break, .block_break, .region_break => if (text.items[text.items.len - 1] != '\n') try text.append(allocator, '\n'), + } + } else if (sameLine(previous, span.span, options.same_line_threshold)) { const gap = span.span.bbox.x0 - previous.bbox.x1; if (gap > @max(1, previous.font_size * 0.15)) try text.append(allocator, ' '); } else { @@ -784,8 +790,15 @@ fn mergeDuplicate(allocator: std.mem.Allocator, existing: *ReconciledSpan, candi fn shouldStartBlock(previous: ReconciledSpan, current: ReconciledSpan, options: ReconcileOptions) bool { if (previous.span.page_index != current.span.page_index) return true; - if (kindForSpan(previous.span) != kindForSpan(current.span)) return true; if (previous.span.block_id != null and current.span.block_id != null and previous.span.block_id.? != current.span.block_id.?) return true; + if (nativeBoundary(previous.span, current.span, options)) |boundary| { + switch (boundary) { + .join, .space => return false, + .block_break, .region_break => return true, + .line_break => {}, + } + } + if (kindForSpan(previous.span) != kindForSpan(current.span)) return true; if (sameLine(previous.span, current.span, options.same_line_threshold)) return false; @@ -794,6 +807,32 @@ fn shouldStartBlock(previous: ReconciledSpan, current: ReconciledSpan, options: return gap > font_size * options.block_gap_multiplier; } +fn nativeBoundary(previous: TextSpan, current: TextSpan, options: ReconcileOptions) ?layout.TextBoundary { + if (!options.preserve_input_order or previous.page_index != current.page_index or previous.source != current.source or + (current.source != .native_pdf and current.source != .embedded_ocr)) return null; + const boundary = current.leading_boundary orelse return null; + if ((boundary == .join or boundary == .space) and + (previous.line_id == null or current.line_id == null or previous.line_id.? != current.line_id.?)) return null; + return boundary; +} + +fn startsInUnicodeWhitespace(text: []const u8) bool { + if (text.len == 0) return false; + const len = std.unicode.utf8ByteSequenceLength(text[0]) catch return false; + if (len > text.len) return false; + return switch (std.unicode.utf8Decode(text[0..len]) catch return false) { + 0x0009...0x000d, 0x0020, 0x0085, 0x00a0, 0x1680, 0x2000...0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000 => true, + else => false, + }; +} + +fn endsInUnicodeWhitespace(text: []const u8) bool { + if (text.len == 0) return false; + var start = text.len - 1; + while (start > 0 and text[start] & 0xc0 == 0x80) start -= 1; + return startsInUnicodeWhitespace(text[start..]); +} + fn spanLessThan(_: void, a: ReconciledSpan, b: ReconciledSpan) bool { if (a.span.page_index != b.span.page_index) return a.span.page_index < b.span.page_index; const ay = @as(i64, @intFromFloat(a.span.bbox.y0 / 4)); @@ -1268,6 +1307,40 @@ test "reconciler emits specialist blocks and rag jsonl chunks" { try std.testing.expect(std.mem.indexOf(u8, jsonl, "\"embedding\":null") != null); } +test "native classified word gaps survive overlapping rotated segment bounds" { + var first = testSpan("AB", .native_pdf, 100, 50, 120, 75); + first.line_id = 0; + first.leading_boundary = .region_break; + var second = testSpan("CD", .native_pdf, 115, 50, 135, 75); + second.line_id = 0; + second.leading_boundary = .space; + var doc = try reconcile(std.testing.allocator, &.{.{ .spans = &.{ first, second } }}, .{ .preserve_input_order = true }); + defer doc.deinit(); + try std.testing.expectEqualStrings("AB CD", doc.blocks[0].text); + try std.testing.expectEqualStrings("AB CD", doc.chunks[0].content); + try std.testing.expectEqual(@as(usize, 3), doc.spans[1].block_text_start); +} + +test "native classified joins preserve superscripts and classified line breaks" { + var first = testSpan("F", .native_pdf, 100, 50, 112, 62); + first.line_id = 0; + first.leading_boundary = .region_break; + var superscript = testSpan("1", .native_pdf, 112, 57, 117, 65); + superscript.font_size = 8; + superscript.line_id = 0; + superscript.leading_boundary = .join; + var tail = testSpan(".25", .native_pdf, 118, 50, 136, 62); + tail.line_id = 0; + tail.leading_boundary = .join; + var next = testSpan("Next", .native_pdf, 120, 50, 150, 62); + next.line_id = 1; + next.leading_boundary = .line_break; + var doc = try reconcile(std.testing.allocator, &.{.{ .spans = &.{ first, superscript, tail, next } }}, .{ .preserve_input_order = true }); + defer doc.deinit(); + try std.testing.expectEqualStrings("F1.25\nNext", doc.blocks[0].text); + try std.testing.expectEqualStrings("F1.25\nNext", doc.chunks[0].content); +} + test "coordinate serializers include hocr alto and debug overlay" { const native = [_]TextSpan{ testSpan("Hello ", .native_pdf, 10, 700, 80, 712), diff --git a/src/root.zig b/src/root.zig index 4207032..cc7359f 100644 --- a/src/root.zig +++ b/src/root.zig @@ -10,6 +10,7 @@ //! 5. Explicit error budgets - caller controls tolerance const std = @import("std"); +const form_scope = @import("form_scope.zig"); const runtime = @import("runtime.zig"); const builtin = @import("builtin"); const native_os = builtin.target.os.tag; @@ -122,10 +123,14 @@ pub const NativePageGeometry = struct { allocator: std.mem.Allocator, spans: []TextSpan, glyphs: []GlyphSpan, + // Authoritative semantic projection, when marked content replaces glyph + // text. Raw glyphs remain available for diagnostic evidence. + semantic_glyphs: ?[]GlyphSpan = null, pub fn deinit(self: *NativePageGeometry) void { Document.freeTextSpans(self.allocator, self.spans); Document.freeGlyphSpans(self.allocator, self.glyphs); + if (self.semantic_glyphs) |glyphs| Document.freeGlyphSpans(self.allocator, glyphs); } }; @@ -932,7 +937,9 @@ pub const Document = struct { { var nw: NullWriter = .{}; - try extractContentStream(content, .{ .bounds = &collector }, &self.font_cache, page_num, scratch_allocator, &nw); + var traversal = form_scope.Traversal{ .page_index = @intCast(page_num) }; + const context = self.contentContext(page_num, scratch_allocator, &traversal); + try extractContentStreamContext(content, .{ .bounds = &collector }, &self.font_cache, page_num, scratch_allocator, &nw, &context); } try collector.flush(); @@ -974,14 +981,18 @@ pub const Document = struct { var collector = interpreter.SpanCollector.init(allocator, @intCast(page_num)); errdefer collector.deinit(); var null_writer: NullWriter = .{}; - try extractContentStream(content, .{ .bounds = &collector }, &self.font_cache, page_num, scratch_allocator, &null_writer); + var traversal = form_scope.Traversal{ .page_index = @intCast(page_num) }; + const context = self.contentContext(page_num, scratch_allocator, &traversal); + try extractContentStreamContext(content, .{ .bounds = &collector }, &self.font_cache, page_num, scratch_allocator, &null_writer, &context); try collector.flush(); const spans = try collector.toOwnedSlice(); errdefer Document.freeTextSpans(allocator, spans); + const semantic_glyphs = if (collector.actual_text_runs.items.len > 0) try collector.toOwnedSemanticGlyphSlice() else null; + errdefer if (semantic_glyphs) |glyphs| Document.freeGlyphSpans(allocator, glyphs); const glyphs = try collector.toOwnedGlyphSlice(); collector.deinit(); - return .{ .allocator = allocator, .spans = spans, .glyphs = glyphs }; + return .{ .allocator = allocator, .spans = spans, .glyphs = glyphs, .semantic_glyphs = semantic_glyphs }; } /// Glyph-first native layout for a page. The raw recall text is used only @@ -997,7 +1008,7 @@ pub const Document = struct { var geometry = try self.extractNativePageGeometryFromContent(page_num, allocator, scratch_allocator, content); defer geometry.deinit(); const page = self.pages.items[page_num]; - return native_layout.analyze(allocator, geometry.glyphs, .{ + return native_layout.analyze(allocator, geometry.semantic_glyphs orelse geometry.glyphs, .{ .x0 = page.media_box[0], .y0 = page.media_box[1], .x1 = page.media_box[2], @@ -1005,6 +1016,23 @@ pub const Document = struct { }, raw); } + fn contentContext(self: *Document, page_num: usize, scratch_allocator: std.mem.Allocator, traversal: *form_scope.Traversal) ExtractionContext { + return .{ + .parse_allocator = self.parsing_arena.allocator(), + .scratch_allocator = scratch_allocator, + .data = self.data, + .xref_table = &self.xref_table, + .object_cache = &self.object_cache, + .font_cache = &self.font_cache, + .page_num = page_num, + .depth = 0, + .security = self.securityHandler(), + .resources = self.pages.items[page_num].resources, + .traversal = traversal, + .scope = form_scope.Scope.page(@intCast(page_num)), + }; + } + /// Analyze page layout (columns, paragraphs, reading order) pub fn analyzePageLayout(self: *Document, page_num: usize, allocator: std.mem.Allocator) !LayoutResult { const spans = try self.extractTextWithBounds(page_num, allocator); @@ -1232,7 +1260,9 @@ pub const Document = struct { { var nw: NullWriter = .{}; - extractContentStream(content, .{ .structured = &extractor }, &self.font_cache, page_num, scratch_allocator, &nw) catch |err| { + var traversal = form_scope.Traversal{ .page_index = @intCast(page_num) }; + const context = self.contentContext(page_num, scratch_allocator, &traversal); + extractContentStreamContext(content, .{ .structured = &extractor }, &self.font_cache, page_num, scratch_allocator, &nw, &context) catch |err| { if (err == error.OutOfMemory) return err; if (full_context_text) |inventory| { full_context_text = null; @@ -1253,7 +1283,7 @@ pub const Document = struct { try result.ensureTotalCapacity(allocator, mcids.items.len * 50); for (mcids.items) |mcr| { - if (extractor.getTextForMcid(mcr.mcid)) |text| { + if (extractor.getTextForScopedMcid(mcr.stream_ref, mcr.mcid)) |text| { if (result.items.len > 0 and text.len > 0) { try result.append(allocator, ' '); } @@ -1483,7 +1513,9 @@ pub const Document = struct { fn extractTextStreamOrderFromContentInto(self: *Document, page_num: usize, scratch_allocator: std.mem.Allocator, content: []const u8, writer: anytype) !void { self.ensurePageFonts(page_num); - try extractTextFromContent(scratch_allocator, content, page_num, &self.font_cache, writer); + var traversal = form_scope.Traversal{ .page_index = @intCast(page_num) }; + const context = self.contentContext(page_num, scratch_allocator, &traversal); + try extractContentStreamContext(content, .{ .stream = .{ .resources = context.resources, .ctx = &context } }, &self.font_cache, page_num, scratch_allocator, writer, &context); } fn extractTextNativeLayoutFromContent( @@ -1498,7 +1530,7 @@ pub const Document = struct { var geometry = try self.extractNativePageGeometryFromContent(page_num, allocator, scratch_allocator, content); defer geometry.deinit(); const page = self.pages.items[page_num]; - var native_result = try native_layout.analyze(allocator, geometry.glyphs, .{ + var native_result = try native_layout.analyze(allocator, geometry.semantic_glyphs orelse geometry.glyphs, .{ .x0 = page.media_box[0], .y0 = page.media_box[1], .x1 = page.media_box[2], @@ -1549,6 +1581,7 @@ pub const Document = struct { content: []const u8, selection_metrics: ?*StructuredSelectionMetrics, ) ![]u8 { + self.ensurePageFonts(page_num); var output: std.ArrayList(u8) = .empty; errdefer output.deinit(allocator); try output.ensureTotalCapacity(allocator, 2048); @@ -2261,11 +2294,12 @@ pub const Document = struct { }; if (geometry) |*page_geometry| { defer page_geometry.deinit(); + const canonical_glyphs = page_geometry.semantic_glyphs orelse page_geometry.glyphs; const raw_or_empty: []const u8 = if (raw) |raw_text| raw_text else ""; const page = self.pages.items[page_idx]; var native_result = native_layout.analyze( allocator, - page_geometry.glyphs, + canonical_glyphs, .{ .x0 = page.media_box[0], .y0 = page.media_box[1], @@ -2286,7 +2320,7 @@ pub const Document = struct { defer result.deinit(); var reading_source = try buildGlyphSearchSource( allocator, - page_geometry.glyphs, + canonical_glyphs, result.ordered_glyph_indices, result.boundaries, ); @@ -2311,7 +2345,7 @@ pub const Document = struct { var content_source = try buildGlyphSearchSource( allocator, - page_geometry.glyphs, + canonical_glyphs, null, null, ); @@ -2486,7 +2520,7 @@ pub const Document = struct { try evidence.append(allocator, .{ .source_start = source_start, .source_end = text.items.len, - .glyph_index = glyph_index, + .glyph_index = if (glyph.semantic_replacement) null else glyph.raw_glyph_start orelse glyph_index, .bbox = glyph.bbox, }); } @@ -3176,17 +3210,46 @@ pub const Document = struct { } pub fn rasterizePageForOcrDetailed(self: *Document, allocator: std.mem.Allocator, page_idx: usize, config: ocr.OcrConfig) !ocr.RasterizeOutcome { + if (page_idx >= self.pages.items.len) return error.PageNotFound; + const page = self.pages.items[page_idx]; + return self.rasterizeOcrDetailed(allocator, page_idx, .{ .x0 = page.media_box[0], .y0 = page.media_box[1], .x1 = page.media_box[2], .y1 = page.media_box[3] }, config, false); + } + + /// Caller owns the ephemeral image path and must delete and free it after + /// recognition. Rotated pages are unsupported until coordinate mapping is + /// fixture-proven. Limits bound accepted raster pixels/bytes, not process RSS. + pub fn rasterizeOcrRegionDetailed(self: *Document, allocator: std.mem.Allocator, page_idx: usize, bbox: layout.BBox, config: ocr.OcrConfig) !ocr.RasterizeOutcome { + return self.rasterizeOcrDetailed(allocator, page_idx, bbox, config, true); + } + + fn rasterizeOcrDetailed(self: *Document, allocator: std.mem.Allocator, page_idx: usize, bbox: layout.BBox, config: ocr.OcrConfig, regional: bool) !ocr.RasterizeOutcome { if (page_idx >= self.pages.items.len) return error.PageNotFound; const source_path = self.source_path orelse return .{ .status = .unavailable, .diagnostic_code = .rasterizer_requires_file, }; + const page = self.pages.items[page_idx]; + if (@mod(page.rotation, 360) != 0) return .{ .status = .unavailable, .diagnostic_code = .rasterizer_rotation_unsupported }; + const crop = @import("ocr/crop.zig"); + const page_bbox = layout.BBox{ .x0 = page.media_box[0], .y0 = page.media_box[1], .x1 = page.media_box[2], .y1 = page.media_box[3] }; + const geometry = crop.geometry(page_bbox, bbox, if (regional) config.crop_padding_points else 0, config.dpi) orelse return .{ .status = .invalid_output, .diagnostic_code = .invalid_crop_geometry }; + if (!crop.withinPixelLimit(geometry.width, geometry.height, config.max_image_pixels)) return .{ .status = .invalid_output, .diagnostic_code = .rasterizer_pixel_limit }; + if (config.max_image_bytes < 24) return .{ .status = .invalid_output, .diagnostic_code = .rasterizer_image_byte_limit }; + const page_number = try std.fmt.allocPrint(allocator, "{}", .{page_idx + 1}); defer allocator.free(page_number); const dpi_arg = try std.fmt.allocPrint(allocator, "{}", .{config.dpi}); defer allocator.free(dpi_arg); + const x_arg = try std.fmt.allocPrint(allocator, "{}", .{geometry.x}); + defer allocator.free(x_arg); + const y_arg = try std.fmt.allocPrint(allocator, "{}", .{geometry.y}); + defer allocator.free(y_arg); + const width_arg = try std.fmt.allocPrint(allocator, "{}", .{geometry.width}); + defer allocator.free(width_arg); + const height_arg = try std.fmt.allocPrint(allocator, "{}", .{geometry.height}); + defer allocator.free(height_arg); const prefix = try std.fmt.allocPrint(allocator, ".pdf-parser-ocr-{x}-{x}", .{ page_idx, @@ -3213,6 +3276,14 @@ pub const Document = struct { page_number, "-l", page_number, + "-x", + x_arg, + "-y", + y_arg, + "-W", + width_arg, + "-H", + height_arg, source_path, prefix, }; @@ -3227,6 +3298,14 @@ pub const Document = struct { page_number, "-l", page_number, + "-x", + x_arg, + "-y", + y_arg, + "-W", + width_arg, + "-H", + height_arg, source_path, prefix, }; @@ -3276,30 +3355,33 @@ pub const Document = struct { }, } - const dimensions = readPngDimensions(allocator, image_path) catch |err| switch (err) { + const dimensions = crop.readIdentity(allocator, image_path, config.max_image_bytes, config.max_image_pixels) catch |err| switch (err) { error.OutOfMemory => return err, else => return .{ .status = .invalid_output, .stderr = result.stderr, .duration_ms = elapsedMilliseconds(started_ns), - .diagnostic_code = .invalid_raster_image, + .diagnostic_code = switch (err) { + error.ImageByteLimitExceeded => .rasterizer_image_byte_limit, + error.ImagePixelLimitExceeded => .rasterizer_pixel_limit, + else => .invalid_raster_image, + }, }, }; - const page = self.pages.items[page_idx]; + if (regional and (dimensions.width != geometry.width or dimensions.height != geometry.height)) return .{ .status = .invalid_output, .stderr = result.stderr, .duration_ms = elapsedMilliseconds(started_ns), .diagnostic_code = .invalid_raster_image }; keep_image_path = true; return .{ .status = .completed, .input = .{ .page_index = @intCast(page_idx), - .pdf_bbox = .{ - .x0 = page.media_box[0], - .y0 = page.media_box[1], - .x1 = page.media_box[2], - .y1 = page.media_box[3], - }, + // Preserve the established full-page OCR mapping. Regional + // crops map through their actual outward pixel boundaries. + .pdf_bbox = if (regional) geometry.pdf_bbox else page_bbox, .image_path = image_path, .pixel_width = dimensions.width, .pixel_height = dimensions.height, + .image_sha256 = dimensions.sha256, + .image_byte_length = dimensions.byte_length, }, .stderr = result.stderr, .duration_ms = elapsedMilliseconds(started_ns), @@ -3360,58 +3442,100 @@ pub const Document = struct { if (content.len == 0) return allocator.alloc(ImageInfo, 0); - const page = self.pages.items[page_idx]; - // Parse content stream for Do operators and track CTM var images: std.ArrayList(ImageInfo) = .empty; errdefer images.deinit(allocator); + var traversal = form_scope.Traversal{ .page_index = @intCast(page_idx) }; + const context = self.contentContext(page_idx, scratch_allocator, &traversal); + try self.collectPageImagesFromContent(page_idx, allocator, scratch_allocator, content, &context, @import("paint.zig").identity, &images); + return images.toOwnedSlice(allocator); + } + + fn collectPageImagesFromContent(self: *Document, page_idx: usize, allocator: std.mem.Allocator, scratch_allocator: std.mem.Allocator, content: []const u8, context: *const ExtractionContext, initial_ctm: [6]f64, images: *std.ArrayList(ImageInfo)) anyerror!void { + var page = self.pages.items[page_idx]; + page.resources = context.resources; var lexer = interpreter.ContentLexer.init(scratch_allocator, content); var operands: [128]interpreter.Operand = undefined; var operand_count: usize = 0; - // Simple CTM tracking (only handles cm and basic state) - var ctm: [6]f64 = .{ 1, 0, 0, 1, 0, 0 }; // identity + const paint_mod = @import("paint.zig"); + var ctm = initial_ctm; + var ctm_stack: std.ArrayList([6]f64) = .empty; + defer ctm_stack.deinit(allocator); while (try lexer.next()) |token| { if (pushOperand(&operands, &operand_count, token)) continue; const op = token.operator; if (op.len > 0) { - if (std.mem.eql(u8, op, "cm") and operand_count >= 6) { + if (std.mem.eql(u8, op, "q")) { + try ctm_stack.append(allocator, ctm); + } else if (std.mem.eql(u8, op, "Q")) { + if (ctm_stack.pop()) |saved| ctm = saved; + } else if (std.mem.eql(u8, op, "cm") and operand_count >= 6) { // Concatenate matrix const new: [6]f64 = .{ operands[0].number, operands[1].number, operands[2].number, operands[3].number, operands[4].number, operands[5].number, }; - ctm = multiplyMatrix(new, ctm); + ctm = paint_mod.concat(ctm, new); } else if (std.mem.eql(u8, op, "Do") and operand_count >= 1 and operands[0] == .name) { // Check if this XObject is an Image if (self.resolveXObjectImage(page, operands[0].name)) |img_info| { - // Transform unit square by CTM to get image rect - const w: f64 = @floatFromInt(img_info.width); - const h: f64 = @floatFromInt(img_info.height); - _ = h; - _ = w; + // Images paint a unit square; affine transforms can + // rotate, reflect or shear every corner independently. + const points = [_][2]f64{ + transformPoint(ctm, .{ 0, 0 }), + transformPoint(ctm, .{ 1, 0 }), + transformPoint(ctm, .{ 0, 1 }), + transformPoint(ctm, .{ 1, 1 }), + }; + var rect = [4]f64{ points[0][0], points[0][1], points[0][0], points[0][1] }; + for (points[1..]) |point| { + rect[0] = @min(rect[0], point[0]); + rect[1] = @min(rect[1], point[1]); + rect[2] = @max(rect[2], point[0]); + rect[3] = @max(rect[3], point[1]); + } + if (context.initial.graphics.clip_bbox) |clip| { + rect[0] = @max(rect[0], clip.x0); + rect[1] = @max(rect[1], clip.y0); + rect[2] = @min(rect[2], clip.x1); + rect[3] = @min(rect[3], clip.y1); + if (rect[0] >= rect[2] or rect[1] >= rect[3]) { + operand_count = 0; + continue; + } + } try images.append(allocator, .{ - .rect = .{ - ctm[4], // x0 - ctm[5], // y0 - ctm[4] + ctm[0], // x1 (x0 + scale_x) - ctm[5] + ctm[3], // y1 (y0 + scale_y) - }, + .rect = rect, .width = img_info.width, .height = img_info.height, }); + } else if (try resolveForm(context, operands[0].name)) |form| { + const traversal = context.traversal orelse return error.MissingFormTraversal; + const scope = try traversal.enter(.{ .object_num = form.reference.num, .generation = form.reference.gen }); + defer traversal.leave(); + var arena = std.heap.ArenaAllocator.init(scratch_allocator); + defer arena.deinit(); + const scratch = arena.allocator(); + const form_content = try decompress.decompressStream(scratch, form.stream.data, form.stream.dict.get("Filter"), form.stream.dict.get("DecodeParms")); + var child = context.*; + child.scratch_allocator = scratch; + child.scope = scope; + if (form.stream.dict.get("Resources")) |resource_object| child.resources = try resolveContextDict(context, resource_object); + var parent_state = context.initial.graphics; + parent_state.ctm = ctm; + child.initial.graphics = try formGraphicsState(parent_state, form.stream.dict); + try self.collectPageImagesFromContent(page_idx, allocator, scratch, form_content, &child, child.initial.graphics.ctm, images); } } } operand_count = 0; } - - return images.toOwnedSlice(allocator); } fn multiplyMatrix(a: [6]f64, b: [6]f64) [6]f64 { @@ -3845,7 +3969,7 @@ pub const Document = struct { /// Decode a PDF string that may be UTF-16BE (BOM-prefixed) into UTF-8. /// If the string starts with \xFE\xFF it's UTF-16BE; otherwise it's -/// PDFDocEncoding (≈ Latin-1 for the printable range). +/// PDFDocEncoding, including its punctuation, accents and ligatures. /// Caller owns the returned slice. pub fn decodePdfString(allocator: std.mem.Allocator, raw: []const u8) ![]u8 { // Check for UTF-16BE BOM @@ -3908,32 +4032,25 @@ pub fn decodePdfString(allocator: std.mem.Allocator, raw: []const u8) ![]u8 { return result.toOwnedSlice(allocator); } - // Not UTF-16BE — treat as PDFDocEncoding (Latin-1 compatible for ASCII). - // For bytes 128-255, encode as UTF-8. + // Preserve the byte fast path only where PDFDocEncoding is identity. var needs_encoding = false; - for (raw) |c| { - if (c >= 0x80) { + for (raw) |byte| { + if (encoding.pdf_doc_encoding[byte] != byte or byte >= 0x80 or byte == 0) { needs_encoding = true; break; } } - - if (!needs_encoding) { - return allocator.dupe(u8, raw); - } + if (!needs_encoding) return allocator.dupe(u8, raw); var result: std.ArrayList(u8) = .empty; errdefer result.deinit(allocator); - try result.ensureTotalCapacity(allocator, raw.len * 2); - - for (raw) |c| { - if (c < 0x80) { - try result.append(allocator, c); - } else { - // Latin-1 to UTF-8 - try result.append(allocator, 0xC0 | (c >> 6)); - try result.append(allocator, 0x80 | (c & 0x3F)); - } + for (raw) |byte| { + const mapped = encoding.pdf_doc_encoding[byte]; + // Zero is the table's undefined sentinel; an explicit NUL is retained. + const codepoint: u21 = if (mapped == 0 and byte != 0) 0xfffd else mapped; + var encoded: [4]u8 = undefined; + const length = try std.unicode.utf8Encode(codepoint, &encoded); + try result.appendSlice(allocator, encoded[0..length]); } return result.toOwnedSlice(allocator); @@ -3951,8 +4068,22 @@ const ExtractionContext = struct { depth: u8, // Recursion depth for nested Form XObjects security: ?*const encryption.SecurityHandler, selection_metrics: ?*StructuredSelectionMetrics = null, + resources: ?Object.Dict = null, + traversal: ?*form_scope.Traversal = null, + scope: ?form_scope.Scope = null, + initial: FormTextState = .{}, +}; - const MAX_DEPTH: u8 = 10; // Prevent infinite recursion +const FormTextState = struct { + graphics: @import("paint.zig").State = .{}, + font: ?*const encoding.FontEncoding = null, + font_name: ?[]const u8 = null, + font_size: f64 = 12, + leading: f64 = 0, + char_spacing: f64 = 0, + word_spacing: f64 = 0, + horizontal_scale: f64 = 100, + rise: f64 = 0, }; const TextSelection = enum { @@ -4010,6 +4141,7 @@ const ActualTextScope = struct { const PoppedActualText = struct { text: []u8, seen: bool, + suppressed_by_ancestor: bool, }; /// Try to buffer a token as an operand. Returns true if consumed (not an operator). @@ -4288,10 +4420,15 @@ fn extractTextFromContentFull( ctx: *const ExtractionContext, writer: anytype, ) !void { - try extractContentStream(content, .{ .stream = .{ + var traversal = form_scope.Traversal{ .page_index = @intCast(ctx.page_num) }; + var frame = ctx.*; + frame.resources = resources; + if (frame.traversal == null) frame.traversal = &traversal; + if (frame.scope == null) frame.scope = form_scope.Scope.page(@intCast(ctx.page_num)); + try extractContentStreamContext(content, .{ .stream = .{ .resources = resources, - .ctx = ctx, - } }, ctx.font_cache, ctx.page_num, ctx.scratch_allocator, writer); + .ctx = &frame, + } }, ctx.font_cache, ctx.page_num, ctx.scratch_allocator, writer, &frame); } /// Unified content stream extraction. All three extraction paths go through this. @@ -4303,25 +4440,65 @@ fn extractContentStream( allocator: std.mem.Allocator, writer: anytype, ) !void { + try extractContentStreamContext(content, mode, font_cache, page_num, allocator, writer, if (mode == .stream) mode.stream.ctx else null); +} + +fn extractContentStreamContext( + content: []const u8, + mode: ExtractionMode, + font_cache: *const std.StringHashMap(encoding.FontEncoding), + page_num: usize, + allocator: std.mem.Allocator, + writer: anytype, + context: ?*const ExtractionContext, +) anyerror!void { + const initial = if (context) |ctx| ctx.initial else FormTextState{}; var lexer = interpreter.ContentLexer.init(allocator, content); var operands: [128]interpreter.Operand = undefined; var operand_count: usize = 0; - var current_font: ?*const encoding.FontEncoding = null; + var current_font: ?*const encoding.FontEncoding = initial.font; + var current_font_name: ?[]const u8 = initial.font_name; var prev_x: f64 = 0; var prev_y: f64 = 0; var current_x: f64 = 0; var current_y: f64 = 0; var line_start_x: f64 = 0; var line_start_y: f64 = 0; - var font_size: f64 = 12; - var text_leading: f64 = 0; - var char_spacing: f64 = 0; - var word_spacing: f64 = 0; - var horizontal_scale: f64 = 100; - var text_rise: f64 = 0; + var font_size: f64 = initial.font_size; + var text_leading: f64 = initial.leading; + var char_spacing: f64 = initial.char_spacing; + var word_spacing: f64 = initial.word_spacing; + var horizontal_scale: f64 = initial.horizontal_scale; + var text_rise: f64 = initial.rise; var text_matrix: [6]f64 = .{ 1, 0, 0, 1, 0, 0 }; var in_text = false; + const paint_mod = @import("paint.zig"); + const inherited_actual_text = if (mode == .bounds) mode.bounds.current_actual_text else null; + var paint_tracker: paint_mod.Tracker = .{ .state = initial.graphics }; + defer paint_tracker.deinit(allocator); + const SavedTextState = struct { + font: ?*const encoding.FontEncoding, + font_name: ?[]const u8, + font_size: f64, + leading: f64, + char_spacing: f64, + word_spacing: f64, + horizontal_scale: f64, + rise: f64, + }; + var saved_text_states: std.ArrayList(SavedTextState) = .empty; + defer saved_text_states.deinit(allocator); + if (mode == .bounds) { + const collector = mode.bounds; + collector.setGraphicsState(paint_tracker.state); + if (context) |ctx| collector.setContentScope(ctx.scope); + collector.setFont(initial.font_name, font_size, if (current_font) |font| font.has_to_unicode else null); + collector.current_writing_mode = if (current_font) |font| font.wmode else 0; + collector.current_font_is_type3 = if (current_font) |font| font.is_type3 else false; + collector.current_font_space_width = if (current_font) |font| (if (!font.is_cid) font.widths.getWidth(' ') else 0) else 0; + collector.setTextState(.{ .char_spacing = char_spacing, .word_spacing = word_spacing, .horizontal_scale = horizontal_scale, .text_rise = text_rise }); + } // Track the font size of the last shown text for newline threshold. // Superscripts/subscripts switch to a smaller Tf *before* their Tm, so // using raw font_size would make the threshold too small and produce @@ -4346,6 +4523,148 @@ fn extractContentStream( // token is an operator const op = token.operator; + var named_properties: [4]interpreter.Operand = undefined; + if (std.mem.eql(u8, op, "BDC") and operand_count == 2 and operands[1] == .name) { + const ctx = context orelse return error.MissingMarkedContentResources; + const resources = ctx.resources orelse return error.MissingMarkedContentResources; + const properties = try resolveContextDict(ctx, resources.get("Properties") orelse return error.MissingMarkedContentProperty); + const property = try resolveContextDict(ctx, properties.get(operands[1].name) orelse return error.MissingMarkedContentProperty); + var count: usize = 0; + if (property.get("ActualText")) |value| { + named_properties[count] = .{ .name = "ActualText" }; + named_properties[count + 1] = switch (try resolveContextObject(ctx, value)) { + .string => |text| .{ .string = text }, + .hex_string => |text| .{ .hex_string = text }, + else => return error.InvalidMarkedContentProperty, + }; + count += 2; + } + if (property.get("MCID")) |value| { + const mcid = switch (try resolveContextObject(ctx, value)) { + .integer => |number| number, + else => return error.InvalidMarkedContentProperty, + }; + if (mcid < 0 or mcid > std.math.maxInt(i32)) return error.InvalidMarkedContentProperty; + named_properties[count] = .{ .name = "MCID" }; + named_properties[count + 1] = .{ .number = @floatFromInt(mcid) }; + count += 2; + } + operands[1] = .{ .array = named_properties[0..count] }; + } + const glyph_count_before = if (mode == .bounds) mode.bounds.glyphs.items.len else 0; + if (mode != .bounds) { + if (std.mem.eql(u8, op, "q")) { + try paint_tracker.save(allocator); + try saved_text_states.append(allocator, .{ + .font = current_font, + .font_name = current_font_name, + .font_size = font_size, + .leading = text_leading, + .char_spacing = char_spacing, + .word_spacing = word_spacing, + .horizontal_scale = horizontal_scale, + .rise = text_rise, + }); + } else if (std.mem.eql(u8, op, "Q")) { + paint_tracker.restore(); + if (saved_text_states.pop()) |saved| { + current_font = saved.font; + current_font_name = saved.font_name; + font_size = saved.font_size; + text_leading = saved.leading; + char_spacing = saved.char_spacing; + word_spacing = saved.word_spacing; + horizontal_scale = saved.horizontal_scale; + text_rise = saved.rise; + } + } else if (std.mem.eql(u8, op, "cm") and operand_count >= 6) { + var matrix: [6]f64 = undefined; + for (operands[0..6], &matrix) |operand, *value| { + value.* = operand.asNumber(); + if (operand != .number or !std.math.isFinite(value.*)) return error.InvalidFormMatrix; + } + paint_tracker.state.ctm = paint_mod.concat(paint_tracker.state.ctm, matrix); + } + } + if (mode == .bounds) { + const collector = mode.bounds; + if (std.mem.eql(u8, op, "q")) { + try collector.flush(); + try paint_tracker.save(allocator); + try saved_text_states.append(allocator, .{ + .font = current_font, + .font_name = collector.current_font_name, + .font_size = font_size, + .leading = text_leading, + .char_spacing = char_spacing, + .word_spacing = word_spacing, + .horizontal_scale = horizontal_scale, + .rise = text_rise, + }); + } else if (std.mem.eql(u8, op, "Q")) { + try collector.flush(); + paint_tracker.restore(); + if (saved_text_states.pop()) |saved| { + current_font = saved.font; + current_font_name = saved.font_name; + font_size = saved.font_size; + text_leading = saved.leading; + char_spacing = saved.char_spacing; + word_spacing = saved.word_spacing; + horizontal_scale = saved.horizontal_scale; + text_rise = saved.rise; + collector.setFont(saved.font_name, font_size, if (current_font) |font| font.has_to_unicode else null); + collector.current_writing_mode = if (current_font) |font| font.wmode else 0; + collector.current_font_is_type3 = if (current_font) |font| font.is_type3 else false; + collector.current_font_space_width = if (current_font) |font| (if (!font.is_cid) font.widths.getWidth(' ') else 0) else 0; + collector.setTextState(.{ .char_spacing = char_spacing, .word_spacing = word_spacing, .horizontal_scale = horizontal_scale, .text_rise = text_rise }); + } + } else if (std.mem.eql(u8, op, "cm") and operand_count >= 6) { + try collector.flush(); + var valid = true; + for (operands[0..6]) |operand| { + if (operand != .number or !std.math.isFinite(operand.asNumber())) valid = false; + } + const next_ctm = paint_mod.concat(paint_tracker.state.ctm, .{ + operands[0].asNumber(), operands[1].asNumber(), operands[2].asNumber(), + operands[3].asNumber(), operands[4].asNumber(), operands[5].asNumber(), + }); + for (next_ctm) |value| { + if (!std.math.isFinite(value)) valid = false; + } + if (valid) paint_tracker.state.ctm = next_ctm else paint_tracker.state.geometry_known = false; + } else if (std.mem.eql(u8, op, "cm")) { + try collector.flush(); + paint_tracker.state.geometry_known = false; + } else if (std.mem.eql(u8, op, "Tr") and operand_count >= 1) { + try collector.flush(); + const value = operands[0].asNumber(); + paint_tracker.state.rendering_mode = if (operands[0] == .number and std.math.isFinite(value) and value >= 0 and value <= 7 and @floor(value) == value) @intFromFloat(value) else null; + } else if (std.mem.eql(u8, op, "Tr")) { + try collector.flush(); + paint_tracker.state.rendering_mode = null; + } else if (std.mem.eql(u8, op, "re") and operand_count >= 4) { + var valid = true; + for (operands[0..4]) |operand| { + if (operand != .number or !std.math.isFinite(operand.asNumber())) valid = false; + } + if (valid) paint_tracker.rectangle(operands[0].asNumber(), operands[1].asNumber(), operands[2].asNumber(), operands[3].asNumber()) else paint_tracker.path_unknown = true; + } else if (std.mem.eql(u8, op, "re")) { + paint_tracker.path_unknown = true; + } else if (std.mem.eql(u8, op, "W") or std.mem.eql(u8, op, "W*")) { + paint_tracker.pending_clip = true; + } else if (std.mem.eql(u8, op, "m") or std.mem.eql(u8, op, "l") or std.mem.eql(u8, op, "c") or std.mem.eql(u8, op, "v") or std.mem.eql(u8, op, "y")) { + paint_tracker.path_unknown = true; + paint_tracker.path_rect = null; + } else if (std.mem.eql(u8, op, "n") or std.mem.eql(u8, op, "S") or std.mem.eql(u8, op, "s") or std.mem.eql(u8, op, "f") or std.mem.eql(u8, op, "F") or std.mem.eql(u8, op, "f*") or std.mem.eql(u8, op, "B") or std.mem.eql(u8, op, "B*") or std.mem.eql(u8, op, "b") or std.mem.eql(u8, op, "b*")) { + if (paint_tracker.pending_clip) try collector.flush(); + paint_tracker.endPath(); + } else if (std.mem.eql(u8, op, "ET")) { + try collector.flush(); + paint_tracker.endText(); + } + collector.setGraphicsState(paint_tracker.state); + } if (op.len > 0) switch (op[0]) { 'B' => switch (mode) { .stream => { @@ -4357,6 +4676,7 @@ fn extractContentStream( current_y = 0; line_start_x = 0; line_start_y = 0; + text_matrix = .{ 1, 0, 0, 1, 0, 0 }; } else if (std.mem.eql(u8, op, "BDC")) { try pushActualTextScope(allocator, &actual_text_scopes, &actual_text_depth, operands[0..operand_count], if (operand_count >= 2) 1 else 0); } else if (std.mem.eql(u8, op, "BMC")) { @@ -4376,12 +4696,12 @@ fn extractContentStream( } else if (std.mem.eql(u8, op, "BDC")) { try pushActualTextScope(allocator, &actual_text_scopes, &actual_text_depth, operands[0..operand_count], if (operand_count >= 2) 1 else 0); try collector.flush(); - try collector.setActualText(currentActualText(actual_text_scopes[0..actual_text_depth])); collector.setMcid(extractMcidFromOperands(operands[0..operand_count], if (operand_count >= 2) 1 else 0)); + try collector.setActualText(effectiveActualText(actual_text_scopes[0..actual_text_depth], inherited_actual_text)); } else if (std.mem.eql(u8, op, "BMC")) { try pushEmptyActualTextScope(&actual_text_scopes, &actual_text_depth); try collector.flush(); - try collector.setActualText(currentActualText(actual_text_scopes[0..actual_text_depth])); + try collector.setActualText(effectiveActualText(actual_text_scopes[0..actual_text_depth], inherited_actual_text)); collector.setMcid(null); } }, @@ -4411,7 +4731,7 @@ fn extractContentStream( } else if (std.mem.eql(u8, op, "EMC")) { if (popActualTextScope(allocator, &actual_text_scopes, &actual_text_depth)) |actual| { defer allocator.free(actual.text); - if (actual.seen) try writer.writeAll(actual.text); + if (actual.seen and !actual.suppressed_by_ancestor) try writer.writeAll(actual.text); } } }, @@ -4424,7 +4744,7 @@ fn extractContentStream( if (popActualTextScope(allocator, &actual_text_scopes, &actual_text_depth)) |actual| { allocator.free(actual.text); } - try collector.setActualText(currentActualText(actual_text_scopes[0..actual_text_depth])); + try collector.setActualText(effectiveActualText(actual_text_scopes[0..actual_text_depth], inherited_actual_text)); collector.setMcid(null); } }, @@ -4434,19 +4754,50 @@ fn extractContentStream( } else if (std.mem.eql(u8, op, "EMC")) { if (popActualTextScope(allocator, &actual_text_scopes, &actual_text_depth)) |actual| { defer allocator.free(actual.text); - if (actual.seen) try extractor.addText(actual.text); + if (actual.seen and !actual.suppressed_by_ancestor) try extractor.addText(actual.text); } extractor.endMarkedContent(); } }, }, - 'D' => switch (mode) { - .stream => |s| if (op.len == 2 and op[1] == 'o') { - if (operand_count >= 1 and operands[0] == .name) { - try handleDoOperator(operands[0].name, s.resources, s.ctx, writer); - } - }, - .bounds, .structured => {}, + 'D' => if (std.mem.eql(u8, op, "Do") and operand_count >= 1 and operands[0] == .name) { + const replaced = if (mode == .bounds) false else markActualTextSeen(actual_text_scopes[0..actual_text_depth]); + if (!replaced) { + const state = FormTextState{ + .graphics = paint_tracker.state, + .font = current_font, + .font_name = current_font_name, + .font_size = font_size, + .leading = text_leading, + .char_spacing = char_spacing, + .word_spacing = word_spacing, + .horizontal_scale = horizontal_scale, + .rise = text_rise, + }; + if (mode == .bounds) { + const collector = mode.bounds; + try collector.flush(); + const saved_scope = collector.current_scope; + const saved_mcid = collector.current_mcid; + const saved_matrix = collector.current_text_matrix; + const saved_x = collector.current_x; + const saved_y = collector.current_y; + defer { + collector.setContentScope(saved_scope); + collector.setMcid(saved_mcid); + collector.setGraphicsState(paint_tracker.state); + collector.setFont(current_font_name, font_size, if (current_font) |font| font.has_to_unicode else null); + collector.current_writing_mode = if (current_font) |font| font.wmode else 0; + collector.current_font_is_type3 = if (current_font) |font| font.is_type3 else false; + collector.current_font_space_width = if (current_font) |font| (if (!font.is_cid) font.widths.getWidth(' ') else 0) else 0; + collector.setTextState(.{ .char_spacing = char_spacing, .word_spacing = word_spacing, .horizontal_scale = horizontal_scale, .text_rise = text_rise }); + collector.setTextMatrix(saved_matrix); + collector.setPosition(saved_x, saved_y); + } + try handleFormOperator(operands[0].name, context, mode, state, writer); + try collector.flush(); + } else try handleFormOperator(operands[0].name, context, mode, state, writer); + } }, 'T' => if (op.len == 2) switch (op[1]) { 'c' => if (operand_count >= 1) { @@ -4486,7 +4837,9 @@ fn extractContentStream( }); }, 'f' => if (operand_count >= 2) { + if (mode == .bounds) try mode.bounds.flush(); const font_name = if (operands[0] == .name) operands[0].name else null; + current_font_name = font_name; if (operands[0] == .name) { current_font = lookupFont(font_cache, &key_buf, page_num, operands[0].name); } @@ -4495,6 +4848,8 @@ fn extractContentStream( const has_to_unicode = if (current_font) |font| font.has_to_unicode else null; mode.bounds.setFont(font_name, font_size, has_to_unicode); mode.bounds.current_writing_mode = if (current_font) |font| font.wmode else 0; + mode.bounds.current_font_is_type3 = if (current_font) |font| font.is_type3 else false; + mode.bounds.current_font_space_width = if (current_font) |font| (if (!font.is_cid) font.widths.getWidth(' ') else 0) else 0; } }, 'L' => if (in_text and operand_count >= 1) { @@ -4516,11 +4871,11 @@ fn extractContentStream( // superscript font doesn't shrink the line-break threshold // below the displacement used for super/subscript positioning. const ref_size = @max(font_size, last_text_font_size); - if (@abs(displacement) > ref_size * 0.7 and prev_y != 0) { + if (@abs(displacement) > ref_size * 0.7 and prev_y != 0 and currentActualText(actual_text_scopes[0..actual_text_depth]) == null) { try writer.writeByte('\n'); } - current_x = line_start_x + tx; - current_y = line_start_y + ty; + current_x = line_start_x + text_matrix[0] * tx + text_matrix[2] * ty; + current_y = line_start_y + text_matrix[1] * tx + text_matrix[3] * ty; line_start_x = current_x; line_start_y = current_y; prev_y = current_y; @@ -4528,8 +4883,8 @@ fn extractContentStream( text_matrix[5] = current_y; }, .bounds => |collector| { - current_x = line_start_x + tx; - current_y = line_start_y + ty; + current_x = line_start_x + text_matrix[0] * tx + text_matrix[2] * ty; + current_y = line_start_y + text_matrix[1] * tx + text_matrix[3] * ty; line_start_x = current_x; line_start_y = current_y; text_matrix[4] = current_x; @@ -4554,7 +4909,7 @@ fn extractContentStream( const new_pos = if (wmode == 1) tx else ty; const prev_pos = if (wmode == 1) prev_x else prev_y; const ref_size = @max(font_size, last_text_font_size); - if (@abs(new_pos - prev_pos) > ref_size * 0.7 and prev_pos != 0) { + if (@abs(new_pos - prev_pos) > ref_size * 0.7 and prev_pos != 0 and currentActualText(actual_text_scopes[0..actual_text_depth]) == null) { try writer.writeByte('\n'); } current_x = tx; @@ -4593,12 +4948,13 @@ fn extractContentStream( } }, '*' => switch (mode) { - .stream => if (in_text) try writer.writeByte('\n'), + .stream => if (in_text and currentActualText(actual_text_scopes[0..actual_text_depth]) == null) try writer.writeByte('\n'), .bounds => |collector| if (in_text) { try collector.flush(); const leading = if (text_leading != 0) text_leading else font_size; - current_x = line_start_x; - current_y = line_start_y - leading; + current_x = line_start_x - text_matrix[2] * leading; + current_y = line_start_y - text_matrix[3] * leading; + line_start_x = current_x; line_start_y = current_y; text_matrix[4] = current_x; text_matrix[5] = current_y; @@ -4664,7 +5020,7 @@ fn extractContentStream( } switch (mode) { .stream => { - try writer.writeByte('\n'); + if (currentActualText(actual_text_scopes[0..actual_text_depth]) == null) try writer.writeByte('\n'); if (markActualTextSeen(actual_text_scopes[0..actual_text_depth])) { // ActualText replaces this glyph run at EMC. } else { @@ -4675,8 +5031,9 @@ fn extractContentStream( .bounds => |collector| { try collector.flush(); const leading = if (text_leading != 0) text_leading else font_size; - current_x = line_start_x; - current_y = line_start_y - leading; + current_x = line_start_x - text_matrix[2] * leading; + current_y = line_start_y - text_matrix[3] * leading; + line_start_x = current_x; line_start_y = current_y; text_matrix[4] = current_x; text_matrix[5] = current_y; @@ -4698,9 +5055,11 @@ fn extractContentStream( operand_count = 0; continue; } + word_spacing = operands[0].asNumber(); + char_spacing = operands[1].asNumber(); switch (mode) { .stream => { - try writer.writeByte('\n'); + if (currentActualText(actual_text_scopes[0..actual_text_depth]) == null) try writer.writeByte('\n'); if (markActualTextSeen(actual_text_scopes[0..actual_text_depth])) { // ActualText replaces this glyph run at EMC. } else { @@ -4710,9 +5069,11 @@ fn extractContentStream( }, .bounds => |collector| { try collector.flush(); + collector.setTextState(.{ .char_spacing = char_spacing, .word_spacing = word_spacing, .horizontal_scale = horizontal_scale, .text_rise = text_rise }); const leading = if (text_leading != 0) text_leading else font_size; - current_x = line_start_x; - current_y = line_start_y - leading; + current_x = line_start_x - text_matrix[2] * leading; + current_y = line_start_y - text_matrix[3] * leading; + line_start_x = current_x; line_start_y = current_y; text_matrix[4] = current_x; text_matrix[5] = current_y; @@ -4732,6 +5093,7 @@ fn extractContentStream( else => {}, }; + if (mode == .bounds and mode.bounds.glyphs.items.len > glyph_count_before and !mode.bounds.current_font_is_type3) paint_tracker.shownText(); operand_count = 0; } @@ -4739,7 +5101,7 @@ fn extractContentStream( switch (mode) { .bounds => |collector| { try collector.flush(); - try collector.setActualText(null); + try collector.setActualText(inherited_actual_text); }, else => {}, } @@ -4747,7 +5109,7 @@ fn extractContentStream( } fn pushEmptyActualTextScope(scopes: *[32]ActualTextScope, depth: *usize) !void { - if (depth.* >= scopes.len) return; + if (depth.* >= scopes.len) return error.NestingTooDeep; scopes[depth.*] = .{}; depth.* += 1; } @@ -4759,7 +5121,7 @@ fn pushActualTextScope( operands: []const interpreter.Operand, property_start: usize, ) !void { - if (depth.* >= scopes.len) return; + if (depth.* >= scopes.len) return error.NestingTooDeep; var scope: ActualTextScope = .{}; if (try extractActualTextFromOperands(allocator, operands, property_start)) |text| { scope.text = text; @@ -4774,7 +5136,7 @@ fn popActualTextScope(allocator: std.mem.Allocator, scopes: *[32]ActualTextScope const scope = scopes[depth.*]; scopes[depth.*] = .{}; if (scope.text) |text| { - return .{ .text = text, .seen = scope.seen }; + return .{ .text = text, .seen = scope.seen, .suppressed_by_ancestor = currentActualText(scopes[0..depth.*]) != null }; } _ = allocator; return null; @@ -4790,24 +5152,25 @@ fn cleanupActualTextScopes(allocator: std.mem.Allocator, scopes: []ActualTextSco } fn currentActualText(scopes: []const ActualTextScope) ?[]const u8 { - var index = scopes.len; - while (index > 0) { - index -= 1; - if (scopes[index].text) |text| return text; - } + // Deterministic extraction policy: an enclosing replacement owns the + // complete lexical scope; descendants cannot emit competing replacements. + for (scopes) |scope| if (scope.text) |text| return text; return null; } +fn effectiveActualText(scopes: []const ActualTextScope, inherited: ?[]const u8) ?[]const u8 { + return inherited orelse currentActualText(scopes); +} + fn markActualTextSeen(scopes: []ActualTextScope) bool { - var index = scopes.len; - while (index > 0) { - index -= 1; - if (scopes[index].text != null) { - scopes[index].seen = true; - return true; + var found = false; + for (scopes) |*scope| { + if (scope.text != null) { + scope.seen = true; + found = true; } } - return false; + return found; } fn extractActualTextFromOperands( @@ -4850,85 +5213,155 @@ fn decodeActualTextOperand(allocator: std.mem.Allocator, operand: interpreter.Op }; } -/// Handle Do operator - extract text from Form XObjects -fn handleDoOperator( - xobject_name: []const u8, - resources: ?Object.Dict, - maybe_ctx: ?*const ExtractionContext, - writer: anytype, -) anyerror!void { - // No context means XObject resolution is unavailable; skip silently - const ctx = maybe_ctx orelse return; +fn resolveContextObject(ctx: *const ExtractionContext, object: Object) !Object { + return switch (object) { + .reference => |reference| try pagetree.resolveRefWithSecurity(ctx.parse_allocator, ctx.data, ctx.xref_table, reference, @constCast(ctx.object_cache), ctx.security), + else => object, + }; +} - // Check recursion depth - if (ctx.depth >= ExtractionContext.MAX_DEPTH) return; +fn resolveContextDict(ctx: *const ExtractionContext, object: Object) !Object.Dict { + return switch (try resolveContextObject(ctx, object)) { + .dict => |dict| dict, + else => error.InvalidFormResources, + }; +} - // Get XObject dictionary from resources - const res = resources orelse return; - const xobjects_obj = res.get("XObject") orelse return; +const ResolvedForm = struct { reference: ObjRef, stream: Object.Stream }; - // Resolve XObject dictionary if it's a reference - const xobjects = switch (xobjects_obj) { - .dict => |d| d, - .reference => |ref| blk: { - const resolved = pagetree.resolveRefWithSecurity(ctx.parse_allocator, ctx.data, ctx.xref_table, ref, @constCast(ctx.object_cache), ctx.security) catch return; - break :blk switch (resolved) { - .dict => |d| d, - else => return, - }; - }, - else => return, +fn resolveForm(ctx: *const ExtractionContext, name: []const u8) !?ResolvedForm { + const resources = ctx.resources orelse return null; + const xobjects = try resolveContextDict(ctx, resources.get("XObject") orelse return null); + const object = xobjects.get(name) orelse return error.MissingFormXObject; + const resolved = try resolveContextObject(ctx, object); + const resolved_stream = switch (resolved) { + .stream => |value| value, + else => return error.InvalidFormXObject, + }; + if (!std.mem.eql(u8, resolved_stream.dict.getName("Subtype") orelse "", "Form")) return null; + const reference = switch (object) { + .reference => |reference| reference, + else => return error.InvalidFormReference, }; + return .{ .reference = reference, .stream = resolved_stream }; +} - // Look up the specific XObject - const xobj = xobjects.get(xobject_name) orelse return; - const xobj_resolved = switch (xobj) { - .stream => |s| s, - .reference => |ref| blk: { - const resolved = pagetree.resolveRefWithSecurity(ctx.parse_allocator, ctx.data, ctx.xref_table, ref, @constCast(ctx.object_cache), ctx.security) catch return; - break :blk switch (resolved) { - .stream => |s| s, - else => return, - }; - }, - else => return, +fn formMatrix(dict: Object.Dict) ![6]f64 { + const object = dict.get("Matrix") orelse return @import("paint.zig").identity; + const array = switch (object) { + .array => |array| array, + else => return error.InvalidFormMatrix, }; + if (array.len != 6) return error.InvalidFormMatrix; + var matrix: [6]f64 = undefined; + for (array, &matrix) |value, *component| { + component.* = switch (value) { + .integer => |number| @floatFromInt(number), + .real => |number| number, + else => return error.InvalidFormMatrix, + }; + if (!std.math.isFinite(component.*)) return error.InvalidFormMatrix; + } + return matrix; +} - // Check if it's a Form XObject - const subtype = xobj_resolved.dict.getName("Subtype") orelse return; - if (!std.mem.eql(u8, subtype, "Form")) return; +fn formBBox(dict: Object.Dict) !layout.BBox { + const object = dict.get("BBox") orelse return error.InvalidFormBBox; + const array = switch (object) { + .array => |array| array, + else => return error.InvalidFormBBox, + }; + if (array.len != 4) return error.InvalidFormBBox; + var box: [4]f64 = undefined; + for (array, &box) |value, *component| { + component.* = switch (value) { + .integer => |number| @floatFromInt(number), + .real => |number| number, + else => return error.InvalidFormBBox, + }; + if (!std.math.isFinite(component.*)) return error.InvalidFormBBox; + } + if (box[0] > box[2] or box[1] > box[3]) return error.InvalidFormBBox; + return .{ .x0 = box[0], .y0 = box[1], .x1 = box[2], .y1 = box[3] }; +} - // Decompress the Form XObject content - const filter = xobj_resolved.dict.get("Filter"); - const params = xobj_resolved.dict.get("DecodeParms"); - const form_content = decompress.decompressStream(ctx.scratch_allocator, xobj_resolved.data, filter, params) catch return; - defer ctx.scratch_allocator.free(form_content); - if (ctx.selection_metrics) |metrics| metrics.form_xobjects_decoded += 1; +fn loadFormFonts(allocator: std.mem.Allocator, ctx: *const ExtractionContext, resources: Object.Dict, fonts: *std.StringHashMap(encoding.FontEncoding)) !void { + const font_dict = try resolveContextDict(ctx, resources.get("Font") orelse return); + for (font_dict.entries) |entry| { + const dict = try resolveContextDict(ctx, entry.value); + const Resolver = struct { + context: *const ExtractionContext, + failure: ?anyerror = null, + fn resolve(raw_ctx: *const anyopaque, object: Object) Object { + const resolver: *@This() = @ptrCast(@alignCast(@constCast(raw_ctx))); + return resolveContextObject(resolver.context, object) catch |err| { + resolver.failure = err; + return .{ .null = {} }; + }; + } + }; + var resolver = Resolver{ .context = ctx }; + var font = try encoding.parseFontEncoding(allocator, dict, Resolver.resolve, &resolver); + if (resolver.failure) |err| { + font.deinit(); + return err; + } + font.font_object_num = if (entry.value == .reference) entry.value.reference.num else null; + const key = try std.fmt.allocPrint(allocator, "{d}:{s}", .{ ctx.page_num, entry.key }); + try fonts.put(key, font); + } +} - // Get Form XObject's own resources (may inherit from parent) - const form_resources = xobj_resolved.dict.getDict("Resources") orelse resources; - - // Recursively extract text with increased depth - const child_ctx = ExtractionContext{ - .parse_allocator = ctx.parse_allocator, - .scratch_allocator = ctx.scratch_allocator, - .data = ctx.data, - .xref_table = ctx.xref_table, - .object_cache = ctx.object_cache, - .font_cache = ctx.font_cache, - .page_num = ctx.page_num, - .depth = ctx.depth + 1, - .security = ctx.security, - .selection_metrics = ctx.selection_metrics, - }; +fn formGraphicsState(state: @import("paint.zig").State, dict: Object.Dict) !@import("paint.zig").State { + const paint_mod = @import("paint.zig"); + var clip = paint_mod.Tracker{ .state = state }; + clip.state.ctm = paint_mod.concat(state.ctm, try formMatrix(dict)); + const bbox = try formBBox(dict); + clip.rectangle(bbox.x0, bbox.y0, bbox.x1 - bbox.x0, bbox.y1 - bbox.y0); + clip.pending_clip = true; + clip.endPath(); + return clip.state; +} - extractContentStream(form_content, .{ .stream = .{ - .resources = form_resources, - .ctx = &child_ctx, - } }, ctx.font_cache, ctx.page_num, ctx.scratch_allocator, writer) catch |err| { - if (err == error.OutOfMemory) return err; - // Domain errors (corrupt/unsupported stream): skip silently +/// Shared Form resolution, resources and graphics framing for every text lane. +fn handleFormOperator(name: []const u8, maybe_ctx: ?*const ExtractionContext, mode: ExtractionMode, state: FormTextState, writer: anytype) anyerror!void { + const ctx = maybe_ctx orelse return; + const form = (try resolveForm(ctx, name)) orelse return; + const traversal = ctx.traversal orelse return error.MissingFormTraversal; + const scope = try traversal.enter(.{ .object_num = form.reference.num, .generation = form.reference.gen }); + defer traversal.leave(); + var arena = std.heap.ArenaAllocator.init(ctx.scratch_allocator); + defer arena.deinit(); + const scratch = arena.allocator(); + const content = try decompress.decompressStream(scratch, form.stream.data, form.stream.dict.get("Filter"), form.stream.dict.get("DecodeParms")); + if (ctx.selection_metrics) |metrics| metrics.form_xobjects_decoded += 1; + var child = ctx.*; + child.scratch_allocator = scratch; + child.scope = scope; + child.depth += 1; + child.initial = state; + child.initial.graphics = try formGraphicsState(state.graphics, form.stream.dict); + var fonts = std.StringHashMap(encoding.FontEncoding).init(scratch); + if (form.stream.dict.get("Resources")) |resource_object| { + child.resources = try resolveContextDict(ctx, resource_object); + try loadFormFonts(scratch, ctx, child.resources.?, &fonts); + child.font_cache = &fonts; + } + const child_mode: ExtractionMode = switch (mode) { + .stream => .{ .stream = .{ .resources = child.resources, .ctx = &child } }, + else => mode, }; + if (mode == .structured) { + const saved_scope = mode.structured.stream_scope; + defer mode.structured.setStreamScope(saved_scope); + mode.structured.setStreamScope(form.reference); + try extractContentStreamContext(content, child_mode, child.font_cache, child.page_num, scratch, writer, &child); + } else { + try extractContentStreamContext(content, child_mode, child.font_cache, child.page_num, scratch, writer, &child); + // Permissively parsed Forms may omit ET. Font names still borrow the + // child content, so own the pending span before releasing its arena. + if (mode == .bounds) try mode.bounds.flush(); + } } fn writeTextWithFont(operand: interpreter.Operand, font: ?*const encoding.FontEncoding, writer: anytype) !void { @@ -5234,6 +5667,502 @@ test "ActualText preserves glyph-derived bbox in bounds mode" { try std.testing.expectEqual(@as(?i32, 3), spans[0].mcid); } +fn collectActualTextTest(allocator: std.mem.Allocator, content: []const u8) !interpreter.SpanCollector { + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + var fonts = std.StringHashMap(encoding.FontEncoding).init(allocator); + defer fonts.deinit(); + var collector = interpreter.SpanCollector.init(allocator, 0); + errdefer collector.deinit(); + var writer: NullWriter = .{}; + try extractContentStream(content, .{ .bounds = &collector }, &fonts, 0, scratch.allocator(), &writer); + try collector.flush(); + return collector; +} + +test "ActualText scope projects once across text font and paint runs while retaining raw evidence" { + const allocator = std.testing.allocator; + var collector = try collectActualTextTest(allocator, "BT /F1 10 Tf 10 20 Td /Span << /ActualText (semantic words) /MCID 4 >> BDC (raw) Tj /F2 15 Tf [(glyphs) -300 (more)] TJ 3 Tr (ink) Tj EMC ET"); + defer collector.deinit(); + const projected = try collector.toOwnedSemanticGlyphSlice(); + defer Document.freeGlyphSpans(allocator, projected); + try std.testing.expectEqual(@as(usize, 1), collector.actual_text_runs.items.len); + try std.testing.expectEqual(@as(usize, 1), collector.spans.items.len); + try std.testing.expectEqual(@as(usize, 1), projected.len); + try std.testing.expectEqualStrings("semantic words", projected[0].text); + try std.testing.expectEqualStrings("semantic words", collector.spans.items[0].text); + try std.testing.expectEqual(@as(?i32, 4), projected[0].mcid); + try std.testing.expectEqual(@as(u8, 'r'), collector.glyphs.items[0].text[0]); + try std.testing.expectEqual(encoding.MappingSource.simple_encoding, collector.glyphs.items[0].mapping_source); + try std.testing.expectEqual(encoding.MappingSource.actual_text, projected[0].mapping_source); + try std.testing.expect(projected[0].semantic_replacement); + try std.testing.expectEqual(@as(u32, @intCast(collector.glyphs.items.len)), projected[0].raw_glyph_count); + try std.testing.expectEqual(.unknown, projected[0].paint.paint_state); + try std.testing.expectEqual(@as(?[]const u8, null), projected[0].font_name); + var union_bbox = collector.glyphs.items[0].bbox; + for (collector.glyphs.items[1..]) |glyph| { + union_bbox.x0 = @min(union_bbox.x0, glyph.bbox.x0); + union_bbox.y0 = @min(union_bbox.y0, glyph.bbox.y0); + union_bbox.x1 = @max(union_bbox.x1, glyph.bbox.x1); + union_bbox.y1 = @max(union_bbox.y1, glyph.bbox.y1); + } + try std.testing.expectEqual(union_bbox, projected[0].bbox); + try std.testing.expect(projected[0].text.ptr != collector.actual_text_runs.items[0].text.ptr); +} + +test "ActualText empty replacements suppress raw ranges and nested outer scope owns replacement" { + const allocator = std.testing.allocator; + const cases = [_]struct { content: []const u8, expected: []const u8, projected_count: usize }{ + .{ .content = "BT /F1 10 Tf /Span << /ActualText () >> BDC (hidden) Tj EMC (shown) Tj ET", .expected = "shown", .projected_count = 5 }, + .{ .content = "BT /F1 10 Tf /Span << /ActualText (Outer) >> BDC (a) Tj /Span << /ActualText (Inner) >> BDC (b) Tj EMC (c) Tj EMC ET", .expected = "Outer", .projected_count = 1 }, + .{ .content = "BT /F1 10 Tf /Span << /ActualText () >> BDC (a) Tj /Span << /ActualText (Inner) >> BDC (b) Tj EMC (c) Tj EMC ET", .expected = "", .projected_count = 0 }, + }; + for (cases) |case| { + var collector = try collectActualTextTest(allocator, case.content); + defer collector.deinit(); + const projected = try collector.toOwnedSemanticGlyphSlice(); + defer Document.freeGlyphSpans(allocator, projected); + try std.testing.expectEqual(case.projected_count, projected.len); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(allocator); + for (projected) |glyph| try text.appendSlice(allocator, glyph.text); + try std.testing.expectEqualStrings(case.expected, text.items); + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + var fonts = std.StringHashMap(encoding.FontEncoding).init(allocator); + defer fonts.deinit(); + text.clearRetainingCapacity(); + try extractContentStream(case.content, .{ .stream = .{ .resources = null, .ctx = null } }, &fonts, 0, scratch.allocator(), runtime.arrayListWriter(&text, allocator)); + try std.testing.expectEqualStrings(case.expected, text.items); + } +} + +test "ActualText Unicode and multiline replacement stays one authoritative scope" { + const allocator = std.testing.allocator; + var collector = try collectActualTextTest(allocator, "BT /F1 10 Tf /Span << /ActualText >> BDC (x) Tj 20 TL T* (y) Tj EMC ET"); + defer collector.deinit(); + const projected = try collector.toOwnedSemanticGlyphSlice(); + defer Document.freeGlyphSpans(allocator, projected); + try std.testing.expectEqual(@as(usize, 1), projected.len); + try std.testing.expectEqualStrings("e\u{301} 😀\nB", projected[0].text); + try std.testing.expectEqualStrings("e\u{301} 😀\nB", collector.spans.items[0].text); +} + +test "ActualText child content cannot close an inherited enclosing replacement" { + const allocator = std.testing.allocator; + var collector = interpreter.SpanCollector.init(allocator, 0); + defer collector.deinit(); + try collector.setActualText("Outer"); + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + var fonts = std.StringHashMap(encoding.FontEncoding).init(allocator); + defer fonts.deinit(); + var writer: NullWriter = .{}; + try extractContentStream("BT /F1 10 Tf /Span << /ActualText (Inner) >> BDC (raw) Tj EMC ET", .{ .bounds = &collector }, &fonts, 0, scratch.allocator(), &writer); + try std.testing.expectEqual(@as(usize, 0), collector.actual_text_runs.items.len); + try std.testing.expectEqualStrings("Outer", collector.current_actual_text.?); + try collector.setActualText(null); + const projected = try collector.toOwnedSemanticGlyphSlice(); + defer Document.freeGlyphSpans(allocator, projected); + try std.testing.expectEqual(@as(usize, 1), projected.len); + try std.testing.expectEqualStrings("Outer", projected[0].text); +} + +test "ActualText identical sibling replacements retain distinct scope runs" { + const allocator = std.testing.allocator; + var collector = try collectActualTextTest(allocator, "BT /F1 10 Tf /Span << /ActualText (X) >> BDC (a) Tj EMC /Span << /ActualText (X) >> BDC (b) Tj EMC ET"); + defer collector.deinit(); + const projected = try collector.toOwnedSemanticGlyphSlice(); + defer Document.freeGlyphSpans(allocator, projected); + try std.testing.expectEqual(@as(usize, 2), collector.actual_text_runs.items.len); + try std.testing.expectEqual(@as(usize, 2), projected.len); + try std.testing.expectEqualStrings("X", projected[0].text); + try std.testing.expectEqualStrings("X", projected[1].text); + try std.testing.expectEqual(@as(?u32, 0), projected[0].raw_glyph_start); + try std.testing.expectEqual(@as(?u32, 1), projected[1].raw_glyph_start); + try std.testing.expectEqual(@as(?u32, 0), projected[0].semantic_run_id); + try std.testing.expectEqual(@as(?u32, 1), projected[1].semantic_run_id); +} + +fn actualTextProjectionAllocationProbe(allocator: std.mem.Allocator) !void { + var collector = try collectActualTextTest(allocator, "BT /F1 10 Tf /Span << /ActualText (long semantic replacement) >> BDC (a) Tj /F2 12 Tf [(b) -300 (c)] TJ EMC /Span << /ActualText () >> BDC (d) Tj EMC (e) Tj ET"); + defer collector.deinit(); + const projected = try collector.toOwnedSemanticGlyphSlice(); + defer Document.freeGlyphSpans(allocator, projected); + try std.testing.expectEqual(@as(usize, 2), projected.len); + try std.testing.expectEqualStrings("long semantic replacement", projected[0].text); + try std.testing.expectEqualStrings("e", projected[1].text); +} + +test "ActualText semantic projection ownership survives every allocation failure" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, actualTextProjectionAllocationProbe, .{}); +} + +test "text line translations follow the linear text matrix" { + var collector = try collectActualTextTest(std.testing.allocator, "BT /F1 10 Tf 0 1 -1 0 100 100 Tm (A) Tj 0 -20 Td (B) Tj 20 TL T* (C) Tj ET"); + defer collector.deinit(); + try std.testing.expectEqual(@as(f64, 100), collector.glyphs.items[0].text_matrix[4]); + try std.testing.expectEqual(@as(f64, 120), collector.glyphs.items[1].text_matrix[4]); + try std.testing.expectEqual(@as(f64, 140), collector.glyphs.items[2].text_matrix[4]); + for (collector.glyphs.items) |glyph| try std.testing.expectEqual(@as(f64, 100), glyph.text_matrix[5]); +} + +fn collectPaintTestGeometry(content: []const u8) !NativePageGeometry { + const allocator = std.testing.allocator; + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + var fonts = std.StringHashMap(encoding.FontEncoding).init(allocator); + defer fonts.deinit(); + var collector = interpreter.SpanCollector.init(allocator, 0); + defer collector.deinit(); + var writer: NullWriter = .{}; + try extractContentStream(content, .{ .bounds = &collector }, &fonts, 0, scratch.allocator(), &writer); + try collector.flush(); + const spans = try collector.toOwnedSlice(); + errdefer Document.freeTextSpans(allocator, spans); + return .{ .allocator = allocator, .spans = spans, .glyphs = try collector.toOwnedGlyphSlice() }; +} + +fn formTraversalPdf(allocator: std.mem.Allocator, page_content: []const u8, form_content: []const u8, form_extra: []const u8) ![]u8 { + return formTraversalPdfWithPageFont(allocator, page_content, form_content, form_extra, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); +} + +fn formTraversalPdfWithPageFont(allocator: std.mem.Allocator, page_content: []const u8, form_content: []const u8, form_extra: []const u8, page_font: []const u8) ![]u8 { + const page_stream = try std.fmt.allocPrint(allocator, "<< /Length {} >>\nstream\n{s}\nendstream", .{ page_content.len, page_content }); + defer allocator.free(page_stream); + const form_stream = try std.fmt.allocPrint(allocator, "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] {s} /Length {} >>\nstream\n{s}\nendstream", .{ form_extra, form_content.len, form_content }); + defer allocator.free(form_stream); + const objects = [_][]const u8{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources << /Font << /F1 4 0 R /F2 8 0 R >> /XObject << /Fm 6 0 R /Im 10 0 R >> /Properties << /P1 << /ActualText (Page named) /MCID 0 >> >> >> /Contents 5 0 R >>", + page_font, + page_stream, + form_stream, + "<< /Font << /F1 8 0 R /F2 4 0 R >> /XObject << /Fm 6 0 R /Im 10 0 R >> /Properties << /P1 9 0 R >> >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /BaseEncoding /WinAnsiEncoding /Differences [65 /B] >> >>", + "<< /ActualText (Form named) /MCID 0 >>", + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>\nstream\n\x00\x00\x00\nendstream", + }; + var pdf: std.ArrayList(u8) = .empty; + errdefer pdf.deinit(allocator); + var writer = runtime.arrayListWriter(&pdf, allocator); + try writer.writeAll("%PDF-1.4\n"); + var offsets: [objects.len]usize = undefined; + for (objects, 0..) |object, index| { + offsets[index] = pdf.items.len; + try writer.print("{} 0 obj\n{s}\nendobj\n", .{ index + 1, object }); + } + const xref_offset = pdf.items.len; + try writer.print("xref\n0 {}\n0000000000 65535 f \n", .{objects.len + 1}); + for (offsets) |offset| try writer.print("{d:0>10} 00000 n \n", .{offset}); + try writer.print("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", .{ objects.len + 1, xref_offset }); + return pdf.toOwnedSlice(allocator); +} + +test "Form traversal resolves scoped named ActualText and preserves inherited page resources" { + const allocator = std.testing.allocator; + for ([_][]const u8{ "", "/Resources 7 0 R" }) |resources| { + const bytes = try formTraversalPdf(allocator, "BT /F1 12 Tf 20 280 Td /Span /P1 BDC (A) Tj EMC ET /Fm Do", "BT /F1 12 Tf 5 8 Td /Span /P1 BDC (A) Tj EMC ET", resources); + defer allocator.free(bytes); + const doc = try Document.openFromMemory(allocator, bytes, ErrorConfig.default()); + defer doc.close(); + const raw = try doc.extractTextRawRecall(0, allocator); + defer allocator.free(raw); + const expected = if (resources.len == 0) "Page namedPage named" else "Page namedForm named"; + try std.testing.expectEqualStrings(expected, raw); + var geometry = try doc.extractNativePageGeometry(0, allocator); + defer geometry.deinit(); + try std.testing.expectEqual(@as(usize, 2), geometry.spans.len); + try std.testing.expectEqualStrings("Page named", geometry.spans[0].text); + try std.testing.expectEqualStrings(if (resources.len == 0) "Page named" else "Form named", geometry.spans[1].text); + try std.testing.expectEqual(@as(?u32, 6), geometry.glyphs[1].content_scope.?.stream_object_num); + try std.testing.expectEqualStrings(if (resources.len == 0) "A" else "B", geometry.glyphs[1].text); + } +} + +test "Form traversal cycles and unresolved properties fail all observable native lanes" { + const allocator = std.testing.allocator; + for ([_]struct { content: []const u8, failure: anyerror }{ + .{ .content = "/Fm Do", .failure = error.FormCycleDetected }, + .{ .content = "BT /F1 12 Tf /Span /Missing BDC (A) Tj EMC ET", .failure = error.MissingMarkedContentProperty }, + }) |case| { + const bytes = try formTraversalPdf(allocator, "/Fm Do", case.content, "/Resources 7 0 R"); + defer allocator.free(bytes); + const doc = try Document.openFromMemory(allocator, bytes, ErrorConfig.default()); + defer doc.close(); + try std.testing.expectError(case.failure, doc.extractNativePageGeometry(0, allocator)); + try std.testing.expectError(case.failure, doc.extractTextRawRecall(0, allocator)); + try std.testing.expectError(case.failure, doc.extractTextFast(0, allocator)); + } +} + +fn formGeometryAllocationProbe(allocator: std.mem.Allocator, doc: *Document, content: []const u8) !void { + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + var geometry = try doc.extractNativePageGeometryFromContent(0, allocator, scratch.allocator(), content); + defer geometry.deinit(); + try std.testing.expectEqual(@as(usize, 2), geometry.spans.len); +} + +test "Form traversal resource frames free partial geometry on every allocation failure" { + const allocator = std.testing.allocator; + const bytes = try formTraversalPdf(allocator, "q 1 0 0 1 20 50 cm /Fm Do Q q 1 0 0 1 100 50 cm /Fm Do Q", "BT /F1 12 Tf 5 8 Td (A) Tj ET", "/Resources 7 0 R /Matrix [2 0 0 2 10 20]"); + defer allocator.free(bytes); + const doc = try Document.openFromMemory(allocator, bytes, ErrorConfig.default()); + defer doc.close(); + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + const content = try doc.getPageContentForScratch(0, scratch.allocator()); + var warmup = try doc.extractNativePageGeometryFromContent(0, allocator, scratch.allocator(), content); + warmup.deinit(); + try std.testing.checkAllAllocationFailures(allocator, formGeometryAllocationProbe, .{ doc, content }); +} + +test "Form traversal invocation cap cannot publish extra placements" { + const allocator = std.testing.allocator; + const bytes = try formTraversalPdf(allocator, "/Fm Do /Fm Do /Fm Do", "BT /F1 12 Tf (A) Tj ET", ""); + defer allocator.free(bytes); + const doc = try Document.openFromMemory(allocator, bytes, ErrorConfig.default()); + defer doc.close(); + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + doc.ensurePageFonts(0); + const content = try doc.getPageContentForScratch(0, scratch.allocator()); + var traversal = form_scope.Traversal{ .page_index = 0, .max_invocations = 2 }; + const context = doc.contentContext(0, scratch.allocator(), &traversal); + var collector = interpreter.SpanCollector.init(allocator, 0); + defer collector.deinit(); + var writer: NullWriter = .{}; + try std.testing.expectError(error.FormInvocationLimitExceeded, extractContentStreamContext(content, .{ .bounds = &collector }, &doc.font_cache, 0, scratch.allocator(), &writer, &context)); + try std.testing.expectEqual(@as(usize, 2), collector.glyphs.items.len); + try std.testing.expectEqual(@as(usize, 0), traversal.depth); +} + +test "Form traversal restores q Q fonts in recall geometry and structured modes" { + const allocator = std.testing.allocator; + const bytes = try formTraversalPdf(allocator, "/Fm Do", "BT /F1 12 Tf /Span << /MCID 0 >> BDC (A) Tj q /F2 12 Tf (A) Tj Q (A) Tj EMC ET", "/Resources 7 0 R"); + defer allocator.free(bytes); + const doc = try Document.openFromMemory(allocator, bytes, ErrorConfig.default()); + defer doc.close(); + const raw = try doc.extractTextRawRecall(0, allocator); + defer allocator.free(raw); + try std.testing.expectEqualStrings("BAB", raw); + var geometry = try doc.extractNativePageGeometry(0, allocator); + defer geometry.deinit(); + for (geometry.glyphs, "BAB") |glyph, expected| try std.testing.expectEqual(expected, glyph.text[0]); + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + const content = try doc.getPageContentForScratch(0, scratch.allocator()); + var traversal = form_scope.Traversal{ .page_index = 0 }; + const context = doc.contentContext(0, scratch.allocator(), &traversal); + var extractor = structtree.MarkedContentExtractor.init(allocator); + defer extractor.deinit(); + var writer: NullWriter = .{}; + try extractContentStreamContext(content, .{ .structured = &extractor }, &doc.font_cache, 0, scratch.allocator(), &writer, &context); + try std.testing.expectEqualStrings("BAB", extractor.getTextForScopedMcid(.{ .num = 6, .gen = 0 }, 0).?); + try std.testing.expect(extractor.getTextForMcid(0) == null); +} + +test "Form traversal cold recall resolves encoded page fonts and inherited Form resources" { + const allocator = std.testing.allocator; + const bytes = try formTraversalPdfWithPageFont(allocator, "BT /F1 12 Tf (A) Tj ET q BT /F2 12 Tf (A) Tj ET Q BT (A) Tj ET /Fm Do", "BT /F1 12 Tf (A) Tj ET", "", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /BaseEncoding /WinAnsiEncoding /Differences [65 /Z] >> >>"); + defer allocator.free(bytes); + const doc = try Document.openFromMemory(allocator, bytes, ErrorConfig.default()); + defer doc.close(); + try std.testing.expectEqual(@as(u32, 0), doc.font_cache.count()); + const cold = try doc.extractTextRawRecall(0, allocator); + defer allocator.free(cold); + try std.testing.expectEqualStrings("ZBZZ", cold); + var geometry = try doc.extractNativePageGeometry(0, allocator); + defer geometry.deinit(); + for (geometry.glyphs, "ZBZZ") |glyph, expected| try std.testing.expectEqual(expected, glyph.text[0]); + const warm = try doc.extractTextRawRecall(0, allocator); + defer allocator.free(warm); + try std.testing.expectEqualStrings(cold, warm); +} + +test "Form traversal image inventory applies nested placement matrix and Form bbox" { + const allocator = std.testing.allocator; + const bytes = try formTraversalPdf(allocator, "q 1 0 0 1 20 50 cm /Fm Do Q", "q 60 0 0 80 10 10 cm /Im Do Q q 60 0 0 80 200 200 cm /Im Do Q", "/Resources 7 0 R /Matrix [2 0 0 2 10 20]"); + defer allocator.free(bytes); + const doc = try Document.openFromMemory(allocator, bytes, ErrorConfig.default()); + defer doc.close(); + const images = try doc.getPageImages(0, allocator); + defer Document.freeImages(allocator, images); + try std.testing.expectEqual(@as(usize, 1), images.len); + try std.testing.expectEqualSlices(f64, &.{ 50, 90, 170, 250 }, &images[0].rect); +} + +test "Form traversal owns pending span metadata before an unterminated child frame exits" { + const allocator = std.testing.allocator; + const bytes = try formTraversalPdf(allocator, "/Fm Do", "BT /F1 12 Tf (A) Tj", "/Resources 7 0 R"); + defer allocator.free(bytes); + const doc = try Document.openFromMemory(allocator, bytes, ErrorConfig.default()); + defer doc.close(); + var geometry = try doc.extractNativePageGeometry(0, allocator); + defer geometry.deinit(); + try std.testing.expectEqual(@as(usize, 1), geometry.spans.len); + try std.testing.expectEqualStrings("B", geometry.spans[0].text); + try std.testing.expectEqualStrings("F1", geometry.spans[0].font.name.?); + try std.testing.expectEqual(@as(?u32, 6), geometry.spans[0].content_scope.?.stream_object_num); +} + +test "native paint modes preserve raw hidden text and restore q Q state" { + var geometry = try collectPaintTestGeometry("BT /Ordinary 10 Tf (A) Tj q 3 Tr /Ordinary 20 Tf (B) Tj Q (C) Tj 7 Tr (D) Tj ET"); + defer geometry.deinit(); + try std.testing.expectEqual(@as(usize, 4), geometry.spans.len); + const expected_modes = [_]u8{ 0, 3, 0, 7 }; + for (geometry.spans, expected_modes, "ABCD") |span, mode, byte| { + try std.testing.expectEqual(@as(?u8, mode), span.paint.rendering_mode); + const expected_state: @TypeOf(span.paint.paint_state) = if (mode == 3 or mode == 7) .non_painting else .painting; + try std.testing.expectEqual(expected_state, span.paint.paint_state); + try std.testing.expectEqual(layout.SourceKind.native_pdf, span.source); + try std.testing.expectEqual(byte, span.text[0]); + } + try std.testing.expectEqual(@as(f64, 10), geometry.spans[2].font_size); + for (geometry.glyphs, geometry.spans) |glyph, span| try std.testing.expect(std.meta.eql(glyph.paint, span.paint)); +} + +test "native rectangular clip evidence retains inside outside and partial text" { + const cases = [_]struct { x: u8, expected: @TypeOf((layout.PaintEvidence{}).clip_state) }{ + .{ .x = 5, .expected = .inside }, + .{ .x = 30, .expected = .outside }, + .{ .x = 18, .expected = .partial }, + }; + for (cases) |case| { + const content = try std.fmt.allocPrint(std.testing.allocator, "0 0 20 40 re W n BT /Ordinary 10 Tf 1 0 0 1 {d} 10 Tm (A) Tj ET", .{case.x}); + defer std.testing.allocator.free(content); + var geometry = try collectPaintTestGeometry(content); + defer geometry.deinit(); + try std.testing.expectEqual(@as(usize, 1), geometry.spans.len); + try std.testing.expectEqualStrings("A", geometry.spans[0].text); + try std.testing.expectEqual(case.expected, geometry.spans[0].paint.clip_state); + try std.testing.expectEqual(@as(f64, 20), geometry.spans[0].paint.clip_bbox.?.x1); + } +} + +test "native transformed clip and glyph geometry share PDF user space" { + var geometry = try collectPaintTestGeometry("2 0 0 2 100 200 cm 0 0 40 40 re W* n BT /Ordinary 10 Tf 1 0 0 1 5 10 Tm (A) Tj ET"); + defer geometry.deinit(); + const span = geometry.spans[0]; + try std.testing.expectEqual(.inside, span.paint.clip_state); + try std.testing.expectEqual(@as(f64, 110), span.bbox.x0); + try std.testing.expectEqual(@as(f64, 100), span.paint.clip_bbox.?.x0); + try std.testing.expectEqual(@as(f64, 280), span.paint.clip_bbox.?.y1); + try std.testing.expectEqual(@as(f64, 2), geometry.glyphs[0].text_matrix[0]); +} + +test "native clip state changes only at path end and restores across q Q" { + var geometry = try collectPaintTestGeometry("q 0 0 20 40 re W BT /Ordinary 10 Tf 1 0 0 1 30 10 Tm (A) Tj ET n BT /Ordinary 10 Tf 1 0 0 1 30 10 Tm (B) Tj ET Q BT /Ordinary 10 Tf 1 0 0 1 30 10 Tm (C) Tj ET"); + defer geometry.deinit(); + try std.testing.expectEqual(@as(usize, 3), geometry.spans.len); + try std.testing.expectEqual(.unclipped, geometry.spans[0].paint.clip_state); + try std.testing.expectEqual(.outside, geometry.spans[1].paint.clip_state); + try std.testing.expectEqual(.unclipped, geometry.spans[2].paint.clip_state); +} + +test "native arbitrary and text clips preserve uncertain state" { + const cases = [_][]const u8{ + "0 0 m 20 0 l 20 20 l h W n BT /Ordinary 10 Tf (A) Tj ET", + "1 1 -1 1 0 0 cm 0 0 20 40 re W n BT /Ordinary 10 Tf (A) Tj ET", + "BT /Ordinary 10 Tf 7 Tr (X) Tj ET BT 0 Tr (A) Tj ET", + "0 0 20 40 re W n /Invalid 0 0 1 0 0 cm BT /Ordinary 10 Tf (A) Tj ET", + }; + for (cases) |content| { + var geometry = try collectPaintTestGeometry(content); + defer geometry.deinit(); + const span = geometry.spans[geometry.spans.len - 1]; + try std.testing.expectEqual(.unknown, span.paint.clip_state); + try std.testing.expectEqual(@as(?layout.BBox, null), span.paint.clip_bbox); + try std.testing.expectEqualStrings("A", span.text); + } +} + +test "native Type3 rendering modes remain uncertain and do not fabricate text clipping" { + const allocator = std.testing.allocator; + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + var fonts = std.StringHashMap(encoding.FontEncoding).init(allocator); + defer fonts.deinit(); + var type3 = encoding.FontEncoding.init(allocator); + type3.is_type3 = true; + try fonts.put("0:FType3", type3); + defer fonts.getPtr("0:FType3").?.deinit(); + var collector = interpreter.SpanCollector.init(allocator, 0); + defer collector.deinit(); + var writer: NullWriter = .{}; + try extractContentStream("BT 3 Tr /Ordinary 10 Tf (A) Tj /FType3 10 Tf 0 Tr (B) Tj 7 Tr (C) Tj ET BT 0 Tr /Ordinary 10 Tf (D) Tj ET", .{ .bounds = &collector }, &fonts, 0, scratch.allocator(), &writer); + try collector.flush(); + const spans = collector.getSpans(); + try std.testing.expectEqual(@as(usize, 4), spans.len); + try std.testing.expectEqual(.non_painting, spans[0].paint.paint_state); + try std.testing.expectEqual(.unknown, spans[1].paint.paint_state); + try std.testing.expectEqual(.non_painting, spans[2].paint.paint_state); + try std.testing.expectEqual(@as(?u8, 7), spans[2].paint.rendering_mode); + try std.testing.expectEqual(.painting, spans[3].paint.paint_state); + try std.testing.expectEqual(.unclipped, spans[3].paint.clip_state); +} + +fn paintCollectorAllocationProbe(allocator: std.mem.Allocator) !void { + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + var fonts = std.StringHashMap(encoding.FontEncoding).init(allocator); + defer fonts.deinit(); + var collector = interpreter.SpanCollector.init(allocator, 0); + defer collector.deinit(); + var writer: NullWriter = .{}; + try extractContentStream("q 0 0 100 100 re W n BT /Ordinary 10 Tf (A) Tj q 3 Tr (B) Tj Q (C) Tj ET Q", .{ .bounds = &collector }, &fonts, 0, scratch.allocator(), &writer); + try collector.flush(); +} + +test "native paint state ownership is safe across every allocation failure" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, paintCollectorAllocationProbe, .{}); +} + +test "image placement restores independent q Q transformations" { + const allocator = std.testing.allocator; + const data = try @import("testpdf.zig").generateImagePdf(allocator); + defer allocator.free(data); + const doc = try Document.openFromMemory(allocator, data, ErrorConfig.default()); + defer doc.close(); + const images = try doc.getPageImagesFromContent(0, allocator, allocator, "q 40 0 0 40 20 20 cm /Im1 Do Q q 40 0 0 40 120 20 cm /Im1 Do Q"); + defer Document.freeImages(allocator, images); + try std.testing.expectEqual(@as(usize, 2), images.len); + try std.testing.expectEqual([4]f64{ 20, 20, 60, 60 }, images[0].rect); + try std.testing.expectEqual([4]f64{ 120, 20, 160, 60 }, images[1].rect); +} + +test "image placement uses all affine corners and nested concatenation" { + const allocator = std.testing.allocator; + const data = try @import("testpdf.zig").generateImagePdf(allocator); + defer allocator.free(data); + const doc = try Document.openFromMemory(allocator, data, ErrorConfig.default()); + defer doc.close(); + const images = try doc.getPageImagesFromContent(0, allocator, allocator, "q 0 40 -20 0 100 20 cm /Im1 Do Q q 2 0 0 3 10 20 cm q 0 40 -20 0 100 20 cm /Im1 Do Q Q q 40 20 -10 30 50 60 cm /Im1 Do Q q -40 0 0 -20 80 60 cm /Im1 Do Q"); + defer Document.freeImages(allocator, images); + try std.testing.expectEqual(@as(usize, 4), images.len); + try std.testing.expectEqual([4]f64{ 80, 20, 100, 60 }, images[0].rect); + try std.testing.expectEqual([4]f64{ 170, 80, 210, 200 }, images[1].rect); + try std.testing.expectEqual([4]f64{ 40, 60, 90, 110 }, images[2].rect); + try std.testing.expectEqual([4]f64{ 40, 40, 80, 60 }, images[3].rect); +} + +fn imagePlacementAllocationProbe(allocator: std.mem.Allocator, doc: *Document) !void { + const images = try doc.getPageImagesFromContent(0, allocator, allocator, "q 40 0 0 40 20 20 cm /Im1 Do Q q 40 0 0 40 120 20 cm /Im1 Do Q"); + defer Document.freeImages(allocator, images); + try std.testing.expectEqual(@as(usize, 2), images.len); +} + +test "image placement stack ownership is safe across every allocation failure" { + const allocator = std.testing.allocator; + const data = try @import("testpdf.zig").generateImagePdf(allocator); + defer allocator.free(data); + const doc = try Document.openFromMemory(allocator, data, ErrorConfig.default()); + defer doc.close(); + try std.testing.checkAllAllocationFailures(allocator, imagePlacementAllocationProbe, .{doc}); +} + test "page marked content order exposes borrowed structure sequence" { const allocator = std.testing.allocator; const testpdf = @import("testpdf.zig"); @@ -5659,3 +6588,139 @@ test "formula crop geometry is padded clamped and top-left mapped" { try std.testing.expectEqual(@as(u32, 16), clamped.width); try std.testing.expectEqual(@as(u32, 14), clamped.height); } + +fn regionalOcrAllocationProbe(allocator: std.mem.Allocator, doc: *Document, bbox: layout.BBox) !void { + var outcome = try doc.rasterizeOcrRegionDetailed(allocator, 0, bbox, .{ .rasterizer_executable = "./definitely-missing-crop-rasterizer" }); + defer outcome.deinit(allocator); + try std.testing.expectEqual(ocr.AttemptStatus.unavailable, outcome.status); +} + +test "regional OCR preflight preserves unsupported and bounded outcomes" { + const allocator = std.testing.allocator; + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + runtime.setIo(threaded.io()); + const bytes = try @import("testpdf.zig").generateMinimalPdf(allocator, "OCR bounds"); + defer allocator.free(bytes); + const doc = try Document.openFromMemory(allocator, bytes, ErrorConfig.default()); + defer doc.close(); + const bbox = layout.BBox{ .x0 = 10, .y0 = 10, .x1 = 20, .y1 = 30 }; + var memory = try doc.rasterizeOcrRegionDetailed(allocator, 0, bbox, .{}); + defer memory.deinit(allocator); + try std.testing.expectEqual(ocr.DiagnosticCode.rasterizer_requires_file, memory.diagnostic_code.?); + doc.source_path = try allocator.dupe(u8, "unused.pdf"); + doc.pages.items[0].rotation = 90; + var rotated = try doc.rasterizeOcrRegionDetailed(allocator, 0, bbox, .{}); + defer rotated.deinit(allocator); + try std.testing.expectEqual(ocr.DiagnosticCode.rasterizer_rotation_unsupported, rotated.diagnostic_code.?); + doc.pages.items[0].rotation = 0; + var pixels = try doc.rasterizeOcrRegionDetailed(allocator, 0, bbox, .{ .max_image_pixels = 1 }); + defer pixels.deinit(allocator); + try std.testing.expectEqual(ocr.DiagnosticCode.rasterizer_pixel_limit, pixels.diagnostic_code.?); + var image_bytes = try doc.rasterizeOcrRegionDetailed(allocator, 0, bbox, .{ .max_image_bytes = 1 }); + defer image_bytes.deinit(allocator); + try std.testing.expectEqual(ocr.DiagnosticCode.rasterizer_image_byte_limit, image_bytes.diagnostic_code.?); + var invalid = try doc.rasterizeOcrRegionDetailed(allocator, 0, .{ .x0 = 20, .y0 = 30, .x1 = 10, .y1 = 10 }, .{}); + defer invalid.deinit(allocator); + try std.testing.expectEqual(ocr.DiagnosticCode.invalid_crop_geometry, invalid.diagnostic_code.?); + var unavailable = try doc.rasterizeOcrRegionDetailed(allocator, 0, bbox, .{ .rasterizer_executable = "./definitely-missing-crop-rasterizer" }); + defer unavailable.deinit(allocator); + try std.testing.expectEqual(ocr.AttemptStatus.unavailable, unavailable.status); + try std.testing.expectEqual(ocr.DiagnosticCode.rasterizer_unavailable, unavailable.diagnostic_code.?); + try std.testing.checkAllAllocationFailures(allocator, regionalOcrAllocationProbe, .{ doc, bbox }); +} + +test "regional OCR fake rasterizer validates crop identity and cleans rejected images" { + if (builtin.target.os.tag == .windows) return; + const allocator = std.testing.allocator; + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + runtime.setIo(threaded.io()); + const bytes = try @import("testpdf.zig").generateMinimalPdf(allocator, "OCR fake crop"); + defer allocator.free(bytes); + const doc = try Document.openFromMemory(allocator, bytes, ErrorConfig.default()); + defer doc.close(); + doc.source_path = try allocator.dupe(u8, "unused.pdf"); + const script_path = try std.fmt.allocPrint(allocator, ".pdf-parser-crop-raster-{x}.sh", .{runtime.nanoTimestamp()}); + defer allocator.free(script_path); + defer runtime.deleteFileCwd(script_path); + const log_path = try std.fmt.allocPrint(allocator, "{s}.log", .{script_path}); + defer allocator.free(log_path); + defer runtime.deleteFileCwd(log_path); + const script = try std.fmt.allocPrint(allocator, "#!/bin/sh\nlast=''\nfor arg do last=\"$arg\"; done\nprintf '%s' \"$last.png\" > '{s}'\nprintf '\\211PNG\\r\\n\\032\\n\\000\\000\\000\\rIHDR\\000\\000\\000\\012\\000\\000\\000\\024' > \"$last.png\"\n", .{log_path}); + defer allocator.free(script); + const file = try runtime.createFileCwd(script_path); + try runtime.writeAllFile(file, script); + runtime.closeFile(file); + try std.testing.expectEqual(@as(u8, 0), try runtime.runIgnored(&.{ "chmod", "+x", script_path })); + const executable = try std.fmt.allocPrint(allocator, "./{s}", .{script_path}); + defer allocator.free(executable); + const bbox = layout.BBox{ .x0 = 10, .y0 = 10, .x1 = 20, .y1 = 30 }; + var success = try doc.rasterizeOcrRegionDetailed(allocator, 0, bbox, .{ .rasterizer_executable = executable, .dpi = 72, .crop_padding_points = 0 }); + defer success.deinit(allocator); + try std.testing.expectEqual(ocr.AttemptStatus.completed, success.status); + const input = success.input.?; + defer allocator.free(@constCast(input.image_path)); + runtime.deleteFileCwd(input.image_path); + try std.testing.expectEqual(@as(usize, 24), input.image_byte_length.?); + try std.testing.expect(input.image_sha256 != null); + { + const original_media_box = doc.pages.items[0].media_box; + defer doc.pages.items[0].media_box = original_media_box; + doc.pages.items[0].media_box = .{ 0, 0, 9.8, 19.8 }; + const fractional_page = layout.BBox{ .x0 = 0, .y0 = 0, .x1 = 9.8, .y1 = 19.8 }; + var whole = try doc.rasterizePageForOcrDetailed(allocator, 0, .{ .rasterizer_executable = executable, .dpi = 72 }); + defer whole.deinit(allocator); + try std.testing.expectEqual(ocr.AttemptStatus.completed, whole.status); + const whole_input = whole.input.?; + defer allocator.free(@constCast(whole_input.image_path)); + defer runtime.deleteFileCwd(whole_input.image_path); + try std.testing.expectEqual(fractional_page, whole_input.pdf_bbox); + try std.testing.expectEqual(@as(u32, 10), whole_input.pixel_width); + try std.testing.expectEqual(@as(u32, 20), whole_input.pixel_height); + + var fractional_crop = try doc.rasterizeOcrRegionDetailed(allocator, 0, fractional_page, .{ .rasterizer_executable = executable, .dpi = 72, .crop_padding_points = 0 }); + defer fractional_crop.deinit(allocator); + try std.testing.expectEqual(ocr.AttemptStatus.completed, fractional_crop.status); + const crop_input = fractional_crop.input.?; + defer allocator.free(@constCast(crop_input.image_path)); + defer runtime.deleteFileCwd(crop_input.image_path); + try std.testing.expectEqual(@as(f64, 10), crop_input.pdf_bbox.x1); + try std.testing.expectApproxEqAbs(@as(f64, -0.2), crop_input.pdf_bbox.y0, 0.000001); + } + var oversized = try doc.rasterizeOcrRegionDetailed(allocator, 0, bbox, .{ .rasterizer_executable = executable, .dpi = 72, .crop_padding_points = 0, .max_image_bytes = 23 }); + defer oversized.deinit(allocator); + try std.testing.expectEqual(ocr.DiagnosticCode.rasterizer_image_byte_limit, oversized.diagnostic_code.?); + // A valid preflight with a mismatched raster size fails after the child + // exits, and the generated file is deleted before returning the outcome. + var wrong_size = try doc.rasterizeOcrRegionDetailed(allocator, 0, bbox, .{ .rasterizer_executable = executable, .dpi = 144, .crop_padding_points = 0 }); + defer wrong_size.deinit(allocator); + try std.testing.expectEqual(ocr.DiagnosticCode.invalid_raster_image, wrong_size.diagnostic_code.?); + const rejected_path = try runtime.readFileAllocAlignedCwd(allocator, log_path, .fromByteUnits(1)); + defer allocator.free(rejected_path); + try std.testing.expectError(error.FileNotFound, runtime.fileSizeCwd(rejected_path)); + + const larger_script = try std.fmt.allocPrint(allocator, "{s}printf X >> \"$last.png\"\n", .{script}); + defer allocator.free(larger_script); + const larger_file = try runtime.createFileCwd(script_path); + try runtime.writeAllFile(larger_file, larger_script); + runtime.closeFile(larger_file); + var bytes_limit = try doc.rasterizeOcrRegionDetailed(allocator, 0, bbox, .{ .rasterizer_executable = executable, .dpi = 72, .crop_padding_points = 0, .max_image_bytes = 24 }); + defer bytes_limit.deinit(allocator); + try std.testing.expectEqual(ocr.DiagnosticCode.rasterizer_image_byte_limit, bytes_limit.diagnostic_code.?); + const large_path = try runtime.readFileAllocAlignedCwd(allocator, log_path, .fromByteUnits(1)); + defer allocator.free(large_path); + try std.testing.expectError(error.FileNotFound, runtime.fileSizeCwd(large_path)); + + const timeout_script = try std.fmt.allocPrint(allocator, "{s}while :; do :; done\n", .{script}); + defer allocator.free(timeout_script); + const timeout_file = try runtime.createFileCwd(script_path); + try runtime.writeAllFile(timeout_file, timeout_script); + runtime.closeFile(timeout_file); + var timed_out = try doc.rasterizeOcrRegionDetailed(allocator, 0, bbox, .{ .rasterizer_executable = executable, .dpi = 72, .crop_padding_points = 0, .timeout_ms = 30 }); + defer timed_out.deinit(allocator); + try std.testing.expectEqual(ocr.AttemptStatus.timeout, timed_out.status); + const timeout_path = try runtime.readFileAllocAlignedCwd(allocator, log_path, .fromByteUnits(1)); + defer allocator.free(timeout_path); + try std.testing.expectError(error.FileNotFound, runtime.fileSizeCwd(timeout_path)); +} diff --git a/src/schema.zig b/src/schema.zig index 52897c1..7929eb7 100644 --- a/src/schema.zig +++ b/src/schema.zig @@ -767,6 +767,13 @@ fn hashSpans(hasher: anytype, result: anytype) void { span.span.bbox.y1, }); hashText(hasher, span.span.text); + hashPrint(hasher, "{s}|{s}|", .{ @tagName(span.span.paint.paint_state), @tagName(span.span.paint.clip_state) }); + if (span.span.paint.rendering_mode) |mode| hashPrint(hasher, "{}|", .{mode}) else hashText(hasher, "unknown-mode|"); + if (span.span.paint.clip_bbox) |bbox| hashPrint(hasher, "{d:.6},{d:.6},{d:.6},{d:.6}|", .{ bbox.x0, bbox.y0, bbox.x1, bbox.y1 }); + if (span.span.content_scope) |scope| { + hashPrint(hasher, "scope:{},{},{}|", .{ scope.page_index, scope.stream_generation, scope.invocation_index }); + if (scope.stream_object_num) |number| hashPrint(hasher, "stream:{}|", .{number}) else hashText(hasher, "page-stream|"); + } else hashText(hasher, "unknown-scope|"); hashPrint(hasher, "{s}|{s}|{d:.3};", .{ sourceKindName(span.span.source), sourceKindName(span.chosen_source), span.confidence }); } } @@ -884,6 +891,11 @@ fn hashOcrAttempts(hasher: anytype, result: anytype) void { attempt.selected, }); if (attempt.diagnostic_code) |code| hashText(hasher, @tagName(code)); + if (attempt.region_index) |region| hashPrint(hasher, "region:{}|", .{region}) else hashText(hasher, "page|"); + if (attempt.crop_bbox) |bbox| hashPrint(hasher, "{d:.6},{d:.6},{d:.6},{d:.6}|", .{ bbox.x0, bbox.y0, bbox.x1, bbox.y1 }); + if (attempt.crop_image_sha256) |digest| hasher.update(&digest); + if (attempt.crop_image_byte_length) |length| hashPrint(hasher, "{}|", .{length}); + hashPrint(hasher, "budgets:{},{},{},{}|", .{ attempt.max_image_pixels, attempt.max_image_bytes, attempt.max_regions_per_page, attempt.max_attempts_per_page }); } for (result.formula_attempts) |attempt| { hashPrint(hasher, "formula|{d}|{d}|{s}|", .{ attempt.page_index, attempt.region_index, @tagName(attempt.status) }); @@ -1183,6 +1195,8 @@ fn writeSpanRecord(writer: anytype, result: anytype, document_id: []const u8, so try writeOptionalU32(writer, span.span.line_id); try writer.writeAll(",\"mcid\":"); try writeOptionalI32(writer, span.span.mcid); + try writePaint(writer, span.span.paint); + try specialist_protocol.writeContentScope(writer, span.span.content_scope); try writeTextView(writer, "span", span_index, "reconciled_span", span.span.text); try writeProvenancePrefix(writer, .{ .document_id = document_id, @@ -1982,13 +1996,18 @@ fn writeOwningBlockIds(writer: anytype, result: anytype, page_index: u32, bbox: } fn writeCoverage(writer: anytype, assessment: coverage.Assessment) !void { - try writer.print(",\"coverage\":{{\"status\":\"{s}\",\"completeness\":\"{s}\",\"native_span_count\":{},\"recognized_span_count\":{},\"requested_recovery\":{},\"recovery_state\":\"{s}\",\"unresolved_region_count\":{}}}", .{ - @tagName(assessment.status), @tagName(assessment.completeness), assessment.native_span_count, - assessment.recognized_span_count, assessment.requested_recovery, @tagName(assessment.recovery_state), - assessment.unresolved_region_count, + try writer.print(",\"coverage\":{{\"status\":\"{s}\",\"completeness\":\"{s}\",\"native_span_count\":{},\"recognized_span_count\":{},\"requested_recovery\":{},\"recovery_state\":\"{s}\",\"unresolved_region_count\":{},\"non_painting_span_count\":{},\"clipped_out_span_count\":{},\"uncertain_paint_span_count\":{}}}", .{ + @tagName(assessment.status), @tagName(assessment.completeness), assessment.native_span_count, + assessment.recognized_span_count, assessment.requested_recovery, @tagName(assessment.recovery_state), + assessment.unresolved_region_count, assessment.non_painting_span_count, assessment.clipped_out_span_count, + assessment.uncertain_paint_span_count, }); } +fn writePaint(writer: anytype, paint: layout.PaintEvidence) !void { + try specialist_protocol.writePaintEvidence(writer, paint); +} + fn collectCellSpanIds(allocator: std.mem.Allocator, span_ids: *std.ArrayList(u32), result: anytype, cell: layout.TableCell, offsets: RecordOffsets) !void { span_ids.clearRetainingCapacity(); if (offsets.span_lookup) |lookup| { diff --git a/src/specialist_protocol.zig b/src/specialist_protocol.zig index d271a77..c851f7b 100644 --- a/src/specialist_protocol.zig +++ b/src/specialist_protocol.zig @@ -8,9 +8,10 @@ const std = @import("std"); const complexity = @import("complexity.zig"); const layout = @import("layout.zig"); +const reconcile = @import("reconcile.zig"); const runtime = @import("runtime.zig"); -pub const schema_version = "0.15.0"; +pub const schema_version = "0.17.0"; pub const SpecialistKind = enum { ocr, @@ -48,6 +49,8 @@ pub const RenderContext = struct { document_id: []const u8 = "document", source_id: ?[]const u8 = null, input_sha256: ?[]const u8 = null, + span_base: u32 = 0, + block_base: u32 = 0, }; pub const StreamMeta = struct { @@ -201,6 +204,9 @@ pub fn countResponses(result: anytype) usize { for (result.page_routes) |route| { if (route.route.needs_ocr) count += 1; } + for (result.region_routes) |route| { + if (route.route.needs_ocr) count += 1; + } count += result.formula_attempts.len; return count; } @@ -213,7 +219,14 @@ pub fn countResults(result: anytype) usize { var count: usize = 0; for (result.page_routes) |route| { if (!route.route.needs_ocr) continue; - if (selectedAttemptIndex(result, route.page_index)) |attempt_index| { + if (selectedAttemptIndex(result, route.page_index, null)) |attempt_index| { + const attempt = result.ocr_attempts[attempt_index]; + if (attempt.status == .completed and countFreshOcrSpans(result, route.page_index, route.bbox, null) > 0) count += 1; + } + } + for (result.region_routes) |route| { + if (!route.route.needs_ocr) continue; + if (selectedAttemptIndex(result, route.page_index, route.region_index)) |attempt_index| { const attempt = result.ocr_attempts[attempt_index]; if (attempt.status == .completed and countFreshOcrSpans(result, route.page_index, route.bbox, null) > 0) count += 1; } @@ -413,14 +426,14 @@ pub fn writeEntityRequestForBlock( ) !void { try writeRecordHeader(writer, "specialist_request"); try writeDocumentFields(writer, context); - try writer.print(",\"request_id\":\"specialist-request-entity-block-{d}\",\"request_index\":{}", .{ block.id, request_index }); + try writer.print(",\"request_id\":\"specialist-request-entity-block-{d}\",\"request_index\":{}", .{ context.block_base + block.id, request_index }); try writer.print(",\"page_index\":{},\"region_index\":null,\"bbox\":", .{block.page_index}); try writeBBoxJson(writer, block.bbox); try writer.writeAll(",\"route\":\"entity_extract\",\"route_reasons\":[],\"signals\":null,\"requested_kind\":\"entity\",\"requested_outputs\":[\"entity\"]"); try writer.writeAll(",\"span_ids\":[],\"spans\":[],\"block_ids\":["); - try writer.print("\"block-{d}\"", .{block.id}); + try writer.print("\"block-{d}\"", .{context.block_base + block.id}); try writer.writeAll("],\"blocks\":["); - try writeBlockContext(writer, block, block.id); + try writeBlockContext(writer, block, context.block_base + block.id); try writer.writeAll("],\"ruling_lines\":[],\"crop_image_path\":null,\"debug_asset_ids\":[]"); try writeProvenance(writer, context, "specialist-request-entity", block.page_index, block.bbox, "lifecycle", 1.0); try writer.writeByte('}'); @@ -447,6 +460,11 @@ fn writeRequests( } for (result.region_routes) |route| { if (!matchesPage(page_filter, route.page_index)) continue; + if (routeNeedsKindOrReason(route.route, .ocr, route.reason_mask)) { + try writeRecordSeparator(writer, jsonl, wrote); + try writeRequestRecord(writer, result, context, .ocr, request_index, route.page_index, route.region_index, route.bbox, route.route, route.reason_mask, route.signals, null, event_index, span_lookup, block_lookup); + request_index += 1; + } if (primaryRegionKind(route.route, route.reason_mask)) |kind| { try writeRecordSeparator(writer, jsonl, wrote); try writeRequestRecord(writer, result, context, kind, request_index, route.page_index, route.region_index, route.bbox, route.route, route.reason_mask, route.signals, null, event_index, span_lookup, block_lookup); @@ -502,10 +520,15 @@ fn writeOcrResponses( wrote: *bool, span_lookup: ?*const SpanPageLookup, ) !void { - for (result.page_routes, 0..) |route, index| { + for (result.page_routes) |route| { + if (!matchesPage(page_filter, route.page_index) or !route.route.needs_ocr) continue; + try writeRecordSeparator(writer, jsonl, wrote); + try writeOcrResponseRecord(writer, result, context, route, event_index, span_lookup); + } + for (result.region_routes) |route| { if (!matchesPage(page_filter, route.page_index) or !route.route.needs_ocr) continue; try writeRecordSeparator(writer, jsonl, wrote); - try writeOcrResponseRecord(writer, result, context, @intCast(index), route, event_index, span_lookup); + try writeOcrResponseRecord(writer, result, context, route, event_index, span_lookup); } } @@ -519,14 +542,21 @@ fn writeOcrResults( wrote: *bool, span_lookup: ?*const SpanPageLookup, ) !void { - for (result.page_routes, 0..) |route, index| { + for (result.page_routes) |route| { if (!matchesPage(page_filter, route.page_index) or !route.route.needs_ocr) continue; - const attempt_index = selectedAttemptIndex(result, route.page_index) orelse continue; + const attempt_index = selectedAttemptIndex(result, route.page_index, null) orelse continue; const attempt = result.ocr_attempts[attempt_index]; if (attempt.status != .completed) continue; if (countFreshOcrSpans(result, route.page_index, route.bbox, span_lookup) == 0) continue; try writeRecordSeparator(writer, jsonl, wrote); - try writeOcrResultRecord(writer, result, context, @intCast(index), route, event_index, span_lookup); + try writeOcrResultRecord(writer, result, context, route, event_index, span_lookup); + } + for (result.region_routes) |route| { + if (!matchesPage(page_filter, route.page_index) or !route.route.needs_ocr) continue; + const attempt_index = selectedAttemptIndex(result, route.page_index, route.region_index) orelse continue; + if (result.ocr_attempts[attempt_index].status != .completed or countFreshOcrSpans(result, route.page_index, route.bbox, span_lookup) == 0) continue; + try writeRecordSeparator(writer, jsonl, wrote); + try writeOcrResultRecord(writer, result, context, route, event_index, span_lookup); } } @@ -651,7 +681,7 @@ fn writeFormulaResults( const bbox = formulaAttemptBBox(result, attempt) orelse layout.BBox{}; const formula_span_count = countFormulaModelSpans(result, attempt.page_index, bbox); try writer.print("\",\"specialist_kind\":\"formula\",\"status\":\"completed\",\"source_kind\":\"formula_model\",\"selected_attempt_id\":\"specialist-attempt-formula-region-{d}-0\",\"confidence\":{d:.3},\"artifact_counts\":{{\"spans\":{},\"tables\":0,\"blocks\":0,\"formulas\":{},\"entities\":0}},\"span_ids\":", .{ attempt.region_index, formulaAttemptConfidence(result, attempt.request_id), formula_span_count, artifact_count }); - try writeSpanIdArray(writer, result, attempt.page_index, bbox, .formula_model, null); + try writeSpanIdArray(writer, context, result, attempt.page_index, bbox, .formula_model, null); try writer.writeAll(",\"table_ids\":[],\"block_ids\":[],\"formula_ids\":["); var first = true; for (result.formula_artifacts) |artifact| { @@ -789,13 +819,13 @@ fn writeRequestRecord( try writer.writeAll("\",\"requested_outputs\":"); try writeRequestedOutputs(writer, kind); try writer.writeAll(",\"span_ids\":"); - try writeSpanIdArray(writer, result, page_index, bbox, .all, span_lookup); + try writeSpanIdArray(writer, context, result, page_index, bbox, .all, span_lookup); try writer.writeAll(",\"spans\":"); - try writeSpanContextArray(writer, result, page_index, bbox, .all, span_lookup); + try writeSpanContextArray(writer, context, result, page_index, bbox, .all, span_lookup); try writer.writeAll(",\"block_ids\":"); - try writeBlockIdArray(writer, result, page_index, bbox, block_lookup); + try writeBlockIdArray(writer, context, result, page_index, bbox, block_lookup); try writer.writeAll(",\"blocks\":"); - try writeBlockContextArray(writer, result, page_index, bbox, block_lookup); + try writeBlockContextArray(writer, context, result, page_index, bbox, block_lookup); try writer.writeAll(",\"ruling_lines\":[],\"crop_image_path\":"); if (crop_image_path) |path| { try writer.writeByte('"'); @@ -817,12 +847,12 @@ fn writeOcrAttemptRecord(writer: anytype, context: RenderContext, attempt: anyty index.* += 1; } try writeDocumentFields(writer, context); - try writer.print(",\"page_index\":{},\"attempt_id\":\"specialist-attempt-ocr-page-{d}-{d}\",\"request_id\":", .{ - attempt.page_index, - attempt.page_index, - attempt.attempt_index, - }); - try writeRequestId(writer, .ocr, attempt.page_index, null); + try writer.print(",\"page_index\":{},\"region_index\":", .{attempt.page_index}); + try writeOptionalIndex(writer, attempt.region_index); + try writer.writeAll(",\"attempt_id\":"); + try writeOcrAttemptId(writer, attempt); + try writer.writeAll(",\"request_id\":"); + try writeRequestId(writer, .ocr, attempt.page_index, attempt.region_index); try writer.writeAll(",\"specialist_id\":\""); try writer.writeAll(@tagName(attempt.backend)); try writer.writeAll("\",\"specialist_kind\":\"ocr\",\"attempt_index\":"); @@ -849,7 +879,10 @@ fn writeOcrAttemptRecord(writer: anytype, context: RenderContext, attempt: anyty try writer.print("{}", .{attempt.grayscale}); try writer.writeAll(",\"timeout_ms\":"); try writer.print("{}", .{attempt.timeout_ms}); - try writer.writeAll("},\"execution\":{\"duration_ms\":"); + try writer.print(",\"max_image_pixels\":{},\"max_image_bytes\":{},\"max_regions_per_page\":{},\"max_attempts_per_page\":{}", .{ attempt.max_image_pixels, attempt.max_image_bytes, attempt.max_regions_per_page, attempt.max_attempts_per_page }); + try writer.writeAll("},\"crop\":"); + try writeOcrCrop(writer, attempt); + try writer.writeAll(",\"execution\":{\"duration_ms\":"); try writer.print("{}", .{attempt.duration_ms}); try writer.writeAll(",\"exit_code\":"); if (attempt.exit_code) |code| try writer.print("{}", .{code}) else try writer.writeAll("null"); @@ -875,9 +908,45 @@ fn writeOcrAttemptRecord(writer: anytype, context: RenderContext, attempt: anyty try writer.writeByte('}'); } -fn writeOcrResponseRecord(writer: anytype, result: anytype, context: RenderContext, response_index: u32, route: anytype, event_index: ?*u64, span_lookup: ?*const SpanPageLookup) !void { - const response_attempt_index = lastAttemptIndex(result, route.page_index); - const selected_index = selectedAttemptIndex(result, route.page_index); +fn routeRegionIndex(route: anytype) ?u32 { + return if (@hasField(@TypeOf(route), "region_index")) route.region_index else null; +} + +fn writeOptionalIndex(writer: anytype, index: ?u32) !void { + if (index) |value| try writer.print("{}", .{value}) else try writer.writeAll("null"); +} + +fn writeOcrAttemptId(writer: anytype, attempt: anytype) !void { + if (attempt.region_index) |region| { + try writer.print("\"specialist-attempt-ocr-page-{d}-region-{d}-{d}\"", .{ attempt.page_index, region, attempt.attempt_index }); + } else try writer.print("\"specialist-attempt-ocr-page-{d}-{d}\"", .{ attempt.page_index, attempt.attempt_index }); +} + +fn writeOcrScopedId(writer: anytype, prefix: []const u8, page_index: u32, region_index: ?u32) !void { + try writer.print("\"specialist-{s}-ocr-page-{d}", .{ prefix, page_index }); + if (region_index) |region| try writer.print("-region-{d}", .{region}); + try writer.writeByte('"'); +} + +fn writeOcrCrop(writer: anytype, attempt: anytype) !void { + if (attempt.crop_bbox) |bbox| { + try writer.writeAll("{\"bbox\":"); + try writeBBoxJson(writer, bbox); + try writer.print(",\"dpi\":{},\"pixel_width\":{},\"pixel_height\":{},\"image_sha256\":", .{ attempt.dpi, attempt.pixel_width, attempt.pixel_height }); + if (attempt.crop_image_sha256) |digest| { + const hex = std.fmt.bytesToHex(digest, .lower); + try writer.print("\"{s}\"", .{hex}); + } else try writer.writeAll("null"); + try writer.writeAll(",\"image_byte_length\":"); + if (attempt.crop_image_byte_length) |length| try writer.print("{}", .{length}) else try writer.writeAll("null"); + try writer.writeByte('}'); + } else try writer.writeAll("null"); +} + +fn writeOcrResponseRecord(writer: anytype, result: anytype, context: RenderContext, route: anytype, event_index: ?*u64, span_lookup: ?*const SpanPageLookup) !void { + const region_index = routeRegionIndex(route); + const response_attempt_index = lastAttemptIndex(result, route.page_index, region_index); + const selected_index = selectedAttemptIndex(result, route.page_index, region_index); const confidence = if (response_attempt_index) |index| result.ocr_attempts[index].mean_confidence else averageFreshOcrConfidence(result, route.page_index, route.bbox, span_lookup); const status = if (response_attempt_index) |index| responseStatusName(result.ocr_attempts[index].status) else "not_invoked"; try writeRecordHeader(writer, "specialist_response"); @@ -886,23 +955,25 @@ fn writeOcrResponseRecord(writer: anytype, result: anytype, context: RenderConte index.* += 1; } try writeDocumentFields(writer, context); - try writer.print(",\"page_index\":{}", .{route.page_index}); - try writer.writeAll(",\"response_id\":\"specialist-response-ocr-"); - try writer.print("{d}\",\"request_id\":", .{response_index}); - try writeRequestId(writer, .ocr, route.page_index, null); + try writer.print(",\"page_index\":{},\"region_index\":", .{route.page_index}); + try writeOptionalIndex(writer, region_index); + try writer.writeAll(",\"response_id\":"); + try writeOcrScopedId(writer, "response", route.page_index, region_index); + try writer.writeAll(",\"request_id\":"); + try writeRequestId(writer, .ocr, route.page_index, region_index); try writer.writeAll(",\"specialist_id\":\""); if (response_attempt_index) |index| try writer.writeAll(@tagName(result.ocr_attempts[index].backend)) else try writer.writeAll("none"); try writer.writeAll("\",\"specialist_kind\":\"ocr\",\"status\":\""); try writer.writeAll(status); - try writer.print("\",\"confidence\":{d:.3},\"attempt_count\":{},\"selected_attempt_id\":", .{ confidence, attemptCountForPage(result, route.page_index) }); + try writer.print("\",\"confidence\":{d:.3},\"attempt_count\":{},\"selected_attempt_id\":", .{ confidence, attemptCountForScope(result, route.page_index, region_index) }); if (selected_index) |index| { const attempt = result.ocr_attempts[index]; - try writer.print("\"specialist-attempt-ocr-page-{d}-{d}\"", .{ attempt.page_index, attempt.attempt_index }); + try writeOcrAttemptId(writer, attempt); } else { try writer.writeAll("null"); } try writer.writeAll(",\"spans\":"); - try writeSpanContextArray(writer, result, route.page_index, route.bbox, .fresh_ocr, span_lookup); + try writeSpanContextArray(writer, context, result, route.page_index, route.bbox, .fresh_ocr, span_lookup); try writer.writeAll(",\"tables\":[],\"blocks\":[],\"formulas\":[],\"entities\":[],\"debug_assets\":[],\"warnings\":["); if (response_attempt_index) |index| { const attempt = result.ocr_attempts[index]; @@ -922,9 +993,10 @@ fn writeOcrResponseRecord(writer: anytype, result: anytype, context: RenderConte try writer.writeByte('}'); } -fn writeOcrResultRecord(writer: anytype, result: anytype, context: RenderContext, result_index: u32, route: anytype, event_index: ?*u64, span_lookup: ?*const SpanPageLookup) !void { +fn writeOcrResultRecord(writer: anytype, result: anytype, context: RenderContext, route: anytype, event_index: ?*u64, span_lookup: ?*const SpanPageLookup) !void { const confidence = averageFreshOcrConfidence(result, route.page_index, route.bbox, span_lookup); - const selected_index = selectedAttemptIndex(result, route.page_index) orelse return; + const region_index = routeRegionIndex(route); + const selected_index = selectedAttemptIndex(result, route.page_index, region_index) orelse return; const attempt = result.ocr_attempts[selected_index]; try writeRecordHeader(writer, "specialist_result"); if (event_index) |index| { @@ -932,40 +1004,44 @@ fn writeOcrResultRecord(writer: anytype, result: anytype, context: RenderContext index.* += 1; } try writeDocumentFields(writer, context); - try writer.print(",\"page_index\":{},\"specialist_result_id\":\"specialist-result-ocr-{d}\",\"request_id\":", .{ route.page_index, result_index }); - try writeRequestId(writer, .ocr, route.page_index, null); + try writer.print(",\"page_index\":{},\"region_index\":", .{route.page_index}); + try writeOptionalIndex(writer, region_index); + try writer.writeAll(",\"specialist_result_id\":"); + try writeOcrScopedId(writer, "result", route.page_index, region_index); + try writer.writeAll(",\"request_id\":"); + try writeRequestId(writer, .ocr, route.page_index, region_index); try writer.writeAll(",\"specialist_id\":\""); try writer.writeAll(@tagName(attempt.backend)); - try writer.writeAll("\",\"specialist_kind\":\"ocr\",\"status\":\"completed\",\"source_kind\":\"fresh_ocr\",\"selected_attempt_id\":\""); - try writer.print("specialist-attempt-ocr-page-{d}-{d}\"", .{ attempt.page_index, attempt.attempt_index }); + try writer.writeAll("\",\"specialist_kind\":\"ocr\",\"status\":\"completed\",\"source_kind\":\"fresh_ocr\",\"selected_attempt_id\":"); + try writeOcrAttemptId(writer, attempt); try writer.print(",\"confidence\":{d:.3},\"artifact_counts\":{{\"spans\":{},\"tables\":0,\"blocks\":0,\"formulas\":0,\"entities\":0}}", .{ confidence, countFreshOcrSpans(result, route.page_index, route.bbox, span_lookup) }); try writer.writeAll(",\"span_ids\":"); - try writeSpanIdArray(writer, result, route.page_index, route.bbox, .fresh_ocr, span_lookup); + try writeSpanIdArray(writer, context, result, route.page_index, route.bbox, .fresh_ocr, span_lookup); try writer.writeAll(",\"table_ids\":[],\"block_ids\":[],\"formula_ids\":[],\"entity_ids\":[]"); try writeProvenance(writer, context, "specialist-result-ocr", route.page_index, route.bbox, "fresh_ocr", confidence); try writer.writeByte('}'); } -fn selectedAttemptIndex(result: anytype, page_index: u32) ?usize { +fn selectedAttemptIndex(result: anytype, page_index: u32, region_index: ?u32) ?usize { for (result.ocr_attempts, 0..) |attempt, index| { - if (attempt.page_index != page_index) continue; + if (attempt.page_index != page_index or attempt.region_index != region_index) continue; if (attempt.selected) return index; } return null; } -fn lastAttemptIndex(result: anytype, page_index: u32) ?usize { +fn lastAttemptIndex(result: anytype, page_index: u32, region_index: ?u32) ?usize { var last_index: ?usize = null; for (result.ocr_attempts, 0..) |attempt, index| { - if (attempt.page_index == page_index) last_index = index; + if (attempt.page_index == page_index and attempt.region_index == region_index) last_index = index; } return last_index; } -fn attemptCountForPage(result: anytype, page_index: u32) usize { +fn attemptCountForScope(result: anytype, page_index: u32, region_index: ?u32) usize { var count: usize = 0; for (result.ocr_attempts) |attempt| { - if (attempt.page_index == page_index) count += 1; + if (attempt.page_index == page_index and attempt.region_index == region_index) count += 1; } return count; } @@ -988,6 +1064,12 @@ fn diagnosticMessage(code: anytype) []const u8 { .rasterizer_failed => "OCR rasterizer exited unsuccessfully", .rasterizer_timeout => "OCR rasterizer timed out", .rasterizer_output_limit => "OCR rasterizer exceeded its output limit", + .rasterizer_pixel_limit => "OCR raster image exceeded its pixel limit", + .rasterizer_image_byte_limit => "OCR raster image exceeded its byte limit", + .rasterizer_rotation_unsupported => "Regional OCR does not support this page rotation", + .invalid_crop_geometry => "Regional OCR crop geometry is invalid", + .ocr_region_limit => "OCR region count exceeded its page limit", + .ocr_attempt_limit => "OCR attempt count exceeded its page limit", .invalid_raster_image => "OCR rasterizer produced an invalid image", .tesseract_unavailable => "Tesseract executable is unavailable", .tesseract_failed => "Tesseract exited unsuccessfully", @@ -1013,7 +1095,7 @@ fn writeDiagnosticRecord(writer: anytype, code: anytype, stderr_excerpt: []const } fn requestKindCount(route: complexity.RouteDecision, reason_mask: u32) usize { - return if (primaryRegionKind(route, reason_mask) == null) 0 else 1; + return @as(usize, @intFromBool(routeNeedsKindOrReason(route, .ocr, reason_mask))) + @as(usize, @intFromBool(primaryRegionKind(route, reason_mask) != null)); } fn primaryRequestKind(route: complexity.RouteDecision, reason_mask: u32) ?SpecialistKind { @@ -1048,7 +1130,7 @@ fn routeNeedsKind(route: complexity.RouteDecision, kind: SpecialistKind) bool { fn routeNeedsKindOrReason(route: complexity.RouteDecision, kind: SpecialistKind, reason_mask: u32) bool { if (routeNeedsKind(route, kind)) return true; return switch (kind) { - .ocr => hasReason(reason_mask, 9) or hasReason(reason_mask, 2), + .ocr => hasReason(reason_mask, 9), .table => hasReason(reason_mask, 11) or hasReason(reason_mask, 7), .formula => hasReason(reason_mask, 12) or hasReason(reason_mask, 8), .layout => hasReason(reason_mask, 10) or hasReason(reason_mask, 6), @@ -1186,59 +1268,79 @@ fn writeRequestedOutputs(writer: anytype, kind: SpecialistKind) !void { const SpanFilter = enum { all, fresh_ocr, formula_model }; -fn writeSpanIdArray(writer: anytype, result: anytype, page_index: u32, bbox: layout.BBox, filter: SpanFilter, span_lookup: ?*const SpanPageLookup) !void { +fn writeSpanIdArray(writer: anytype, context: RenderContext, result: anytype, page_index: u32, bbox: layout.BBox, filter: SpanFilter, span_lookup: ?*const SpanPageLookup) !void { try writer.writeByte('['); var first = true; const range = spanScanRange(result, page_index, span_lookup); for (result.reconciled.spans[range.start..range.end], range.start..) |span, index| { if (!includeSpan(span, page_index, bbox, filter)) continue; if (!first) try writer.writeByte(','); - try writer.print("\"span-{d}\"", .{index}); + try writer.print("\"span-{d}\"", .{context.span_base + @as(u32, @intCast(index))}); first = false; } try writer.writeByte(']'); } -fn writeSpanContextArray(writer: anytype, result: anytype, page_index: u32, bbox: layout.BBox, filter: SpanFilter, span_lookup: ?*const SpanPageLookup) !void { +pub fn writePaintEvidence(writer: anytype, paint: layout.PaintEvidence) !void { + try writer.writeAll(",\"paint\":{\"rendering_mode\":"); + if (paint.rendering_mode) |mode| try writer.print("{}", .{mode}) else try writer.writeAll("null"); + try writer.print(",\"paint_state\":\"{s}\",\"clip_state\":\"{s}\",\"clip_bbox\":", .{ @tagName(paint.paint_state), @tagName(paint.clip_state) }); + if (paint.clip_bbox) |bbox| try writeBBoxJson(writer, bbox) else try writer.writeAll("null"); + try writer.writeByte('}'); +} + +pub fn writeContentScope(writer: anytype, scope: ?@import("form_scope.zig").Scope) !void { + try writer.writeAll(",\"content_scope\":"); + if (scope) |value| { + try writer.print("{{\"page_index\":{},\"stream_object_num\":", .{value.page_index}); + if (value.stream_object_num) |number| try writer.print("{}", .{number}) else try writer.writeAll("null"); + try writer.print(",\"stream_generation\":{},\"invocation_index\":{}}}", .{ value.stream_generation, value.invocation_index }); + } else try writer.writeAll("null"); +} + +fn writeSpanContextArray(writer: anytype, context: RenderContext, result: anytype, page_index: u32, bbox: layout.BBox, filter: SpanFilter, span_lookup: ?*const SpanPageLookup) !void { try writer.writeByte('['); var first = true; const range = spanScanRange(result, page_index, span_lookup); for (result.reconciled.spans[range.start..range.end], range.start..) |span, index| { if (!includeSpan(span, page_index, bbox, filter)) continue; if (!first) try writer.writeByte(','); - try writer.print("{{\"span_id\":\"span-{d}\",\"text\":\"", .{index}); + try writer.print("{{\"span_id\":\"span-{d}\",\"text\":\"", .{context.span_base + @as(u32, @intCast(index))}); try writeJsonEscaped(writer, span.span.text); try writer.writeAll("\",\"bbox\":"); try writeBBoxJson(writer, span.span.bbox); try writer.writeAll(",\"source_kind\":\""); try writer.writeAll(sourceKindName(span.chosen_source)); - try writer.print("\",\"confidence\":{d:.3}}}", .{span.confidence}); + try writer.print("\",\"confidence\":{d:.3}", .{span.confidence}); + try writePaintEvidence(writer, span.span.paint); + try writeContentScope(writer, span.span.content_scope); + try writer.writeByte('}'); first = false; } try writer.writeByte(']'); } -fn writeBlockIdArray(writer: anytype, result: anytype, page_index: u32, bbox: layout.BBox, block_lookup: ?*const BlockPageLookup) !void { +fn writeBlockIdArray(writer: anytype, context: RenderContext, result: anytype, page_index: u32, bbox: layout.BBox, block_lookup: ?*const BlockPageLookup) !void { try writer.writeByte('['); var first = true; const range = blockScanRange(result, page_index, block_lookup); for (result.reconciled.blocks[range.start..range.end]) |block| { if (block.page_index != page_index or !boxesIntersect(block.bbox, bbox)) continue; if (!first) try writer.writeByte(','); - try writer.print("\"block-{d}\"", .{block.id}); + try writer.print("\"block-{d}\"", .{context.block_base + block.id}); first = false; } try writer.writeByte(']'); } -fn writeBlockContextArray(writer: anytype, result: anytype, page_index: u32, bbox: layout.BBox, block_lookup: ?*const BlockPageLookup) !void { +fn writeBlockContextArray(writer: anytype, context: RenderContext, result: anytype, page_index: u32, bbox: layout.BBox, block_lookup: ?*const BlockPageLookup) !void { try writer.writeByte('['); var first = true; const range = blockScanRange(result, page_index, block_lookup); for (result.reconciled.blocks[range.start..range.end]) |block| { if (block.page_index != page_index or !boxesIntersect(block.bbox, bbox)) continue; if (!first) try writer.writeByte(','); - try writeBlockContext(writer, block, block.id); + try writeBlockContext(writer, block, context.block_base + block.id); first = false; } try writer.writeByte(']'); @@ -1290,7 +1392,7 @@ fn includeSpan(span: anytype, page_index: u32, bbox: layout.BBox, filter: SpanFi if (span.span.page_index != page_index or !boxesIntersect(span.span.bbox, bbox)) return false; return switch (filter) { .all => true, - .fresh_ocr => span.chosen_source == .fresh_ocr or span.span.source == .fresh_ocr, + .fresh_ocr => reconcile.hasSource(span.source_mask, .fresh_ocr), .formula_model => span.chosen_source == .formula_model or span.span.source == .formula_model, }; } diff --git a/src/stream.zig b/src/stream.zig index 8f92c02..9b29c02 100644 --- a/src/stream.zig +++ b/src/stream.zig @@ -234,6 +234,8 @@ fn writePageArtifacts( .document_id = document_id, .source_id = source_id, .input_sha256 = input_sha256, + .span_base = offsets.span_base, + .block_base = offsets.block_base, }; counts.specialist_requests = try specialist_protocol.writePageRequestsJsonl(writer, result, specialist_context, page_index, event_index); counts.specialist_attempts = try specialist_protocol.writePageAttemptsJsonl(writer, result, specialist_context, page_index, event_index); diff --git a/src/structtree.zig b/src/structtree.zig index 12518c7..5ff3984 100644 --- a/src/structtree.zig +++ b/src/structtree.zig @@ -367,23 +367,29 @@ pub const TaggedContent = struct { /// Extract text from content stream with MCID tracking pub const MarkedContentExtractor = struct { + pub const Key = struct { stream_ref: ?ObjRef = null, mcid: i32 }; allocator: std.mem.Allocator, - /// Collected content by MCID - content_by_mcid: std.AutoHashMap(i32, std.ArrayList(u8)), + /// MCIDs are local to a page or Form content stream, not globally unique. + content_by_mcid: std.AutoHashMap(Key, std.ArrayList(u8)), /// Current MCID stack - mcid_stack: std.ArrayList(i32), + mcid_stack: std.ArrayList(Key), /// Current tag stack tag_stack: std.ArrayList([]const u8), + stream_scope: ?ObjRef = null, pub fn init(allocator: std.mem.Allocator) MarkedContentExtractor { return .{ .allocator = allocator, - .content_by_mcid = std.AutoHashMap(i32, std.ArrayList(u8)).init(allocator), + .content_by_mcid = std.AutoHashMap(Key, std.ArrayList(u8)).init(allocator), .mcid_stack = .empty, .tag_stack = .empty, }; } + pub fn setStreamScope(self: *MarkedContentExtractor, stream_ref: ?ObjRef) void { + self.stream_scope = stream_ref; + } + pub fn deinit(self: *MarkedContentExtractor) void { var it = self.content_by_mcid.iterator(); while (it.next()) |entry| { @@ -397,16 +403,16 @@ pub const MarkedContentExtractor = struct { /// Called when BDC (begin marked content) is encountered pub fn beginMarkedContent(self: *MarkedContentExtractor, tag: []const u8, mcid: ?i32) !void { try self.tag_stack.append(self.allocator, tag); - if (mcid) |m| { - try self.mcid_stack.append(self.allocator, m); + errdefer _ = self.tag_stack.pop(); + const key = Key{ .stream_ref = self.stream_scope, .mcid = mcid orelse -1 }; + try self.mcid_stack.append(self.allocator, key); + errdefer _ = self.mcid_stack.pop(); + if (mcid != null) { // Ensure we have a buffer for this MCID - const gop = try self.content_by_mcid.getOrPut(m); + const gop = try self.content_by_mcid.getOrPut(key); if (!gop.found_existing) { gop.value_ptr.* = .empty; } - } else { - // No MCID, push sentinel - try self.mcid_stack.append(self.allocator, -1); } } @@ -422,11 +428,15 @@ pub const MarkedContentExtractor = struct { /// Get current MCID (innermost) pub fn currentMcid(self: *const MarkedContentExtractor) ?i32 { + return if (self.currentKey()) |key| key.mcid else null; + } + + fn currentKey(self: *const MarkedContentExtractor) ?Key { // Find innermost non-sentinel MCID var i = self.mcid_stack.items.len; while (i > 0) { i -= 1; - if (self.mcid_stack.items[i] >= 0) { + if (self.mcid_stack.items[i].mcid >= 0) { return self.mcid_stack.items[i]; } } @@ -435,8 +445,8 @@ pub const MarkedContentExtractor = struct { /// Add text to current MCID pub fn addText(self: *MarkedContentExtractor, text: []const u8) !void { - if (self.currentMcid()) |mcid| { - if (self.content_by_mcid.getPtr(mcid)) |buf| { + if (self.currentKey()) |key| { + if (self.content_by_mcid.getPtr(key)) |buf| { try buf.appendSlice(self.allocator, text); } } @@ -444,9 +454,56 @@ pub const MarkedContentExtractor = struct { /// Get text for an MCID pub fn getTextForMcid(self: *const MarkedContentExtractor, mcid: i32) ?[]const u8 { - if (self.content_by_mcid.get(mcid)) |buf| { + return self.getTextForScopedMcid(null, mcid); + } + + pub fn getTextForScopedMcid(self: *const MarkedContentExtractor, stream_ref: ?ObjRef, mcid: i32) ?[]const u8 { + if (self.content_by_mcid.get(.{ .stream_ref = stream_ref, .mcid = mcid })) |buf| { return buf.items; } return null; } }; + +test "marked content scopes isolate equal MCIDs in page and different Forms" { + var extractor = MarkedContentExtractor.init(std.testing.allocator); + defer extractor.deinit(); + try extractor.beginMarkedContent("Span", 0); + try extractor.addText("PAGE"); + extractor.endMarkedContent(); + const first = ObjRef{ .num = 11, .gen = 0 }; + const second = ObjRef{ .num = 12, .gen = 0 }; + extractor.setStreamScope(first); + try extractor.beginMarkedContent("Span", 0); + try extractor.addText("FIRST"); + extractor.endMarkedContent(); + extractor.setStreamScope(second); + try extractor.beginMarkedContent("Span", 0); + try extractor.addText("SECOND"); + extractor.endMarkedContent(); + try std.testing.expectEqualStrings("PAGE", extractor.getTextForMcid(0).?); + try std.testing.expectEqualStrings("FIRST", extractor.getTextForScopedMcid(first, 0).?); + try std.testing.expectEqualStrings("SECOND", extractor.getTextForScopedMcid(second, 0).?); + try std.testing.expect(extractor.getTextForScopedMcid(.{ .num = 11, .gen = 1 }, 0) == null); +} + +fn scopedMarkedContentAllocationProbe(allocator: std.mem.Allocator) !void { + var extractor = MarkedContentExtractor.init(allocator); + defer extractor.deinit(); + extractor.setStreamScope(.{ .num = 11, .gen = 0 }); + extractor.beginMarkedContent("Span", 0) catch |err| { + std.debug.assert(extractor.tag_stack.items.len == 0); + std.debug.assert(extractor.mcid_stack.items.len == 0); + return err; + }; + try extractor.addText("FIRST"); + extractor.endMarkedContent(); + extractor.setStreamScope(.{ .num = 12, .gen = 0 }); + try extractor.beginMarkedContent("Span", 0); + try extractor.addText("SECOND"); + extractor.endMarkedContent(); +} + +test "scoped marked content rolls back and frees every allocation failure" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, scopedMarkedContentAllocationProbe, .{}); +} diff --git a/src/visual_assets.zig b/src/visual_assets.zig index e226ee1..36a07ac 100644 --- a/src/visual_assets.zig +++ b/src/visual_assets.zig @@ -312,6 +312,8 @@ fn writeGlyphTraceSpan(writer: anytype, span: layout.TextSpan, span_index: usize } else { try writer.writeAll("null"); } + try @import("specialist_protocol.zig").writePaintEvidence(writer, span.paint); + try @import("specialist_protocol.zig").writeContentScope(writer, span.content_scope); try writer.writeAll("}\n"); char_index.* += 1; From f739dfd77d67ed8565b10144fcdc234129a0c0f4 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Sat, 10 Oct 2026 19:20:47 -0400 Subject: [PATCH 2/4] Qualify Form scope after coincident text reconciliation --- docs/host-adapters.md | 4 +++- docs/output-schema.md | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/host-adapters.md b/docs/host-adapters.md index 62cc14a..cf0cd7b 100644 --- a/docs/host-adapters.md +++ b/docs/host-adapters.md @@ -182,7 +182,9 @@ publishing authority. Schema `0.17.x` adds nullable span `content_scope`. A known scope carries its page, PDF stream object reference and per-page Form invocation index. Repeated Form placements remain distinguishable even when they reuse local font names or -MCIDs. The adapter rejects out-of-page or malformed scopes and inconsistent +MCIDs. Coincident identical text may still merge during reconciliation: public +scope identifies the retained occurrence, while raw glyph inventory distinguishes +the contributing invocations. The adapter rejects out-of-page or malformed scopes and inconsistent specialist context. A locator's `sourceSpanId` resolves to the retained span and its scope; null scope explicitly withholds that attribution. diff --git a/docs/output-schema.md b/docs/output-schema.md index df712fb..66c149a 100644 --- a/docs/output-schema.md +++ b/docs/output-schema.md @@ -451,6 +451,9 @@ A Form scope carries its PDF object number/generation and a positive invocation index assigned in traversal order on that page. Repeated placements of the same Form share the object reference but have distinct invocation indices. Scope is bound to the input identity and page; it does not establish logical reading order. +Reconciliation may merge coincident identical text, retaining the chosen span's +scope and a duplicate count. Raw glyph inventory preserves invocation identity; +public spans do not enumerate every contributing invocation scope. MCIDs are interpreted within their content stream, not as page-global identities. Fallback/OCR and reconstructed table text may have null scope. Specialist span context must agree with the corresponding public span's scope. From 6ec6c31ea5e067ba3b0834b5047b76adceb844a7 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Sat, 10 Oct 2026 19:24:18 -0400 Subject: [PATCH 3/4] Install OCR tools for public adapter CI regressions --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6082e7..30488fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,12 @@ jobs: node-version: "22" - name: Test public JavaScript adapters against current CLI - run: node --test integrations/javascript/*.test.mjs + run: | + sudo apt-get update + sudo apt-get install -y tesseract-ocr tesseract-ocr-eng poppler-utils + tesseract --version + pdftoppm -v + node --test integrations/javascript/*.test.mjs python: name: Python ${{ matrix.python-version }} bindings and wheel From 7a157ddc26d151dba56ea6636006c3d3c8a493d6 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Sat, 10 Oct 2026 19:32:04 -0400 Subject: [PATCH 4/4] Repair aggregate resource scope and mixed specialist request links --- integrations/javascript/fidelity.test.mjs | 11 +++ src/root.zig | 69 ++++++++++++---- src/specialist_protocol.zig | 95 +++++++++++++++++++---- 3 files changed, 143 insertions(+), 32 deletions(-) diff --git a/integrations/javascript/fidelity.test.mjs b/integrations/javascript/fidelity.test.mjs index 7cc31b8..9d504aa 100644 --- a/integrations/javascript/fidelity.test.mjs +++ b/integrations/javascript/fidelity.test.mjs @@ -91,6 +91,17 @@ test('named marked-content properties retain semantic text and MCID through publ }); }); +test('aggregate fast CLI resolves named properties using each page resource scope', { skip: !available }, async t => { + const directory = await mkdtemp(join(tmpdir(), 'pdf-parser-fast-properties-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const input = join(directory, 'input.pdf'); + await writeFile(input, textPdf('BT /F1 12 Tf 20 250 Td /Span /Replacement BDC (WRONG) Tj EMC ET', + '/Properties << /Replacement << /ActualText (Named replacement) /MCID 7 >> >>')); + const text = execFileSync(binaryPath, ['extract', input, '--fast'], { encoding: 'utf8', timeout: 5000 }); + assert.equal(text.split('Named replacement').length - 1, 2); + assert.ok(!text.includes('WRONG')); +}); + test('PDFDocEncoding replacement text survives both public adapters', { skip: !available }, async () => { const bytes = textPdf('BT /F1 12 Tf 20 250 Td /Span << /ActualText <18808d8e9394a07f9fad> >> BDC (WRONG) Tj EMC ET'); const expected = '˘•“”fifl€���'; diff --git a/src/root.zig b/src/root.zig index cc7359f..dc98811 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1720,8 +1720,7 @@ pub const Document = struct { ) catch continue; if (content.len == 0) continue; - self.ensurePageFonts(page_num); - try extractTextFromContent(scratch_allocator, content, page_num, &self.font_cache, runtime.arrayListWriter(&result, allocator)); + try self.extractTextStreamOrderFromContentInto(page_num, scratch_allocator, content, runtime.arrayListWriter(&result, allocator)); } } @@ -4398,21 +4397,6 @@ fn lookupFont( return font_cache.getPtr(key); } -/// Extract text from content stream using pre-resolved fonts -fn extractTextFromContent( - allocator: std.mem.Allocator, - content: []const u8, - page_num: usize, - font_cache: *const std.StringHashMap(encoding.FontEncoding), - writer: anytype, -) !void { - // Simple path without Form XObject support - null ctx skips Do handling - try extractContentStream(content, .{ .stream = .{ - .resources = null, - .ctx = null, - } }, font_cache, page_num, allocator, writer); -} - /// Extract text with full context (supports Form XObjects) fn extractTextFromContentFull( content: []const u8, @@ -5871,6 +5855,12 @@ test "Form traversal resolves scoped named ActualText and preserves inherited pa defer allocator.free(raw); const expected = if (resources.len == 0) "Page namedPage named" else "Page namedForm named"; try std.testing.expectEqualStrings(expected, raw); + const fast_page = try doc.extractTextFast(0, allocator); + defer allocator.free(fast_page); + try std.testing.expectEqualStrings(expected, fast_page); + const fast_all = try doc.extractAllTextFast(allocator); + defer allocator.free(fast_all); + try std.testing.expectEqualStrings(expected, fast_all); var geometry = try doc.extractNativePageGeometry(0, allocator); defer geometry.deinit(); try std.testing.expectEqual(@as(usize, 2), geometry.spans.len); @@ -5894,9 +5884,54 @@ test "Form traversal cycles and unresolved properties fail all observable native try std.testing.expectError(case.failure, doc.extractNativePageGeometry(0, allocator)); try std.testing.expectError(case.failure, doc.extractTextRawRecall(0, allocator)); try std.testing.expectError(case.failure, doc.extractTextFast(0, allocator)); + try std.testing.expectError(case.failure, doc.extractAllTextFast(allocator)); } } +test "aggregate fast extraction resolves named properties separately on each page" { + const allocator = std.testing.allocator; + const content = "BT /F1 12 Tf 20 280 Td /Tag /Property BDC (WRONGVISIBLE) Tj EMC ET"; + const stream_object = try std.fmt.allocPrint(allocator, "<< /Length {} >>\nstream\n{s}\nendstream", .{ content.len, content }); + defer allocator.free(stream_object); + const objects = [_][]const u8{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources << /Font << /F1 5 0 R >> /Properties << /Property << /ActualText (Page one) /MCID 0 >> >> >> /Contents 6 0 R >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources 8 0 R /Contents 7 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + stream_object, + stream_object, + "<< /Font << /F1 5 0 R >> /Properties << /Property 9 0 R >> >>", + "<< /ActualText (Page two) /MCID 0 >>", + }; + var pdf: std.ArrayList(u8) = .empty; + defer pdf.deinit(allocator); + var writer = runtime.arrayListWriter(&pdf, allocator); + try writer.writeAll("%PDF-1.4\n"); + var offsets: [objects.len]usize = undefined; + for (objects, 0..) |object, index| { + offsets[index] = pdf.items.len; + try writer.print("{} 0 obj\n{s}\nendobj\n", .{ index + 1, object }); + } + const xref_offset = pdf.items.len; + try writer.print("xref\n0 {}\n0000000000 65535 f \n", .{objects.len + 1}); + for (offsets) |offset| try writer.print("{d:0>10} 00000 n \n", .{offset}); + try writer.print("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", .{ objects.len + 1, xref_offset }); + const doc = try Document.openFromMemory(allocator, pdf.items, ErrorConfig.default()); + defer doc.close(); + for ([_][]const u8{ "Page one", "Page two" }, 0..) |expected, page_num| { + const page = try doc.extractTextFast(page_num, allocator); + defer allocator.free(page); + try std.testing.expectEqualStrings(expected, page); + } + const aggregate = try doc.extractAllTextFast(allocator); + defer allocator.free(aggregate); + try std.testing.expectEqualStrings("Page one\x0cPage two", aggregate); + const through_mode = try doc.extractAllTextWithMode(allocator, .fast); + defer allocator.free(through_mode); + try std.testing.expectEqualStrings(aggregate, through_mode); +} + fn formGeometryAllocationProbe(allocator: std.mem.Allocator, doc: *Document, content: []const u8) !void { var scratch = std.heap.ArenaAllocator.init(allocator); defer scratch.deinit(); diff --git a/src/specialist_protocol.zig b/src/specialist_protocol.zig index c851f7b..9c98aea 100644 --- a/src/specialist_protocol.zig +++ b/src/specialist_protocol.zig @@ -190,7 +190,8 @@ pub fn countRequests(result: anytype) usize { count += requestKindCount(route.route, route.reason_mask); } for (result.trace_records, 0..) |record, trace_index| { - if (requestKindForScope(record.route, record.reason_mask, record.region_index)) |kind| { + for (requestKindsForScope(record.route, record.reason_mask, record.region_index)) |maybe_kind| { + const kind = maybe_kind orelse continue; if (requestAlreadyCovered(result, kind, record.page_index, record.region_index)) continue; if (traceRequestSeenEarlier(result, trace_index, kind, record.page_index, record.region_index)) continue; count += 1; @@ -411,8 +412,9 @@ pub fn writeRouteTraceSpecialistFields(writer: anytype, route: anytype, page_ind pub fn writeRouteTraceSpecialistFieldsWithReason(writer: anytype, route: anytype, reason_mask: u32, page_index: u32, region_index: ?u32) !void { try writer.writeAll(",\"specialist_request_ids\":["); var first = true; - if (requestKindForScope(route, reason_mask, region_index)) |kind| - try writeRequestIdValue(writer, kind, page_index, region_index, &first); + for (requestKindsForScope(route, reason_mask, region_index)) |maybe_kind| { + if (maybe_kind) |kind| try writeRequestIdValue(writer, kind, page_index, region_index, &first); + } try writer.writeAll("],\"specialist_status\":\""); try writer.writeAll(if (first) "none" else "requested"); try writer.writeByte('"'); @@ -473,7 +475,8 @@ fn writeRequests( } for (result.trace_records, 0..) |record, trace_index| { if (!matchesPage(page_filter, record.page_index)) continue; - if (requestKindForScope(record.route, record.reason_mask, record.region_index)) |kind| { + for (requestKindsForScope(record.route, record.reason_mask, record.region_index)) |maybe_kind| { + const kind = maybe_kind orelse continue; if (requestAlreadyCovered(result, kind, record.page_index, record.region_index)) continue; if (traceRequestSeenEarlier(result, trace_index, kind, record.page_index, record.region_index)) continue; const bbox = traceBBox(result, record) orelse layout.BBox{ .x0 = 0, .y0 = 0, .x1 = 0, .y1 = 0 }; @@ -487,7 +490,8 @@ fn writeRequests( fn traceRequestSeenEarlier(result: anytype, end_index: usize, kind: SpecialistKind, page_index: u32, region_index: ?u32) bool { for (result.trace_records[0..end_index]) |record| { if (record.page_index != page_index or record.region_index != region_index) continue; - if (requestKindForScope(record.route, record.reason_mask, record.region_index)) |prior_kind| { + for (requestKindsForScope(record.route, record.reason_mask, record.region_index)) |maybe_kind| { + const prior_kind = maybe_kind orelse continue; if (prior_kind == kind and !requestAlreadyCovered(result, kind, page_index, region_index)) return true; } } @@ -1098,16 +1102,11 @@ fn requestKindCount(route: complexity.RouteDecision, reason_mask: u32) usize { return @as(usize, @intFromBool(routeNeedsKindOrReason(route, .ocr, reason_mask))) + @as(usize, @intFromBool(primaryRegionKind(route, reason_mask) != null)); } -fn primaryRequestKind(route: complexity.RouteDecision, reason_mask: u32) ?SpecialistKind { - if (routeNeedsKindOrReason(route, .ocr, reason_mask)) return .ocr; - return primaryRegionKind(route, reason_mask); -} - -fn requestKindForScope(route: complexity.RouteDecision, reason_mask: u32, region_index: ?u32) ?SpecialistKind { - if (region_index == null) { - return if (routeNeedsKindOrReason(route, .ocr, reason_mask)) .ocr else null; - } - return primaryRequestKind(route, reason_mask); +fn requestKindsForScope(route: complexity.RouteDecision, reason_mask: u32, region_index: ?u32) [2]?SpecialistKind { + return .{ + if (routeNeedsKindOrReason(route, .ocr, reason_mask)) .ocr else null, + if (region_index != null) primaryRegionKind(route, reason_mask) else null, + }; } fn primaryRegionKind(route: complexity.RouteDecision, reason_mask: u32) ?SpecialistKind { @@ -1576,6 +1575,29 @@ test "specialist route metadata records stable ids" { try std.testing.expect(std.mem.indexOf(u8, output.items, "specialist-request-formula-region-7") == null); } +test "mixed OCR and specialist route metadata names both emitted requests" { + for ([_]complexity.RouteDecision{ + .{ .needs_ocr = true, .needs_table_model = true }, + .{ .needs_ocr = true, .needs_formula_model = true }, + .{ .needs_ocr = true, .needs_layout_model = true }, + }, [_][]const u8{ "table", "formula", "layout" }) |route, kind| { + var output: std.ArrayList(u8) = .empty; + defer output.deinit(std.testing.allocator); + const writer = runtime.arrayListWriter(&output, std.testing.allocator); + try writer.writeAll("{\"route\":true"); + try writeRouteTraceSpecialistFields(writer, route, 2, 7); + try writer.writeByte('}'); + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, output.items, .{}); + defer parsed.deinit(); + const ids = parsed.value.object.get("specialist_request_ids").?.array.items; + try std.testing.expectEqual(@as(usize, 2), ids.len); + try std.testing.expectEqualStrings("specialist-request-ocr-region-7", ids[0].string); + const second = try std.fmt.allocPrint(std.testing.allocator, "specialist-request-{s}-region-7", .{kind}); + defer std.testing.allocator.free(second); + try std.testing.expectEqualStrings(second, ids[1].string); + } +} + test "entity request writer exposes entity protocol shape" { var output: std.ArrayList(u8) = .empty; defer output.deinit(std.testing.allocator); @@ -1605,6 +1627,49 @@ test "region routing consolidates table formula and layout to one request" { try std.testing.expectEqual(SpecialistKind.table, primaryRegionKind(route, 0).?); } +test "mixed trace fallback emits each request once with matching count and links" { + const Route = struct { + page_index: u32, + region_index: u32 = 7, + bbox: layout.BBox, + route: complexity.RouteDecision, + reason_mask: u32 = 0, + signals: complexity.SignalScores = .{}, + }; + const Trace = struct { + page_index: u32, + region_index: ?u32, + route: complexity.RouteDecision, + reason_mask: u32 = 0, + }; + const route = complexity.RouteDecision{ .needs_ocr = true, .needs_table_model = true }; + const traces = [_]Trace{ + .{ .page_index = 2, .region_index = 7, .route = route }, + .{ .page_index = 2, .region_index = 7, .route = route }, + }; + const result = .{ + .page_routes = @as([]const Route, &.{}), + .region_routes = @as([]const Route, &.{}), + .trace_records = &traces, + .reconciled = .{ + .spans = @as([]const reconcile.ReconciledSpan, &.{}), + .blocks = @as([]const reconcile.ReconciledBlock, &.{}), + }, + }; + try std.testing.expectEqual(@as(usize, 2), countRequests(&result)); + var output: std.ArrayList(u8) = .empty; + defer output.deinit(std.testing.allocator); + try writeRequestsArray(runtime.arrayListWriter(&output, std.testing.allocator), &result, .{}); + const json = try std.fmt.allocPrint(std.testing.allocator, "[{s}]", .{output.items}); + defer std.testing.allocator.free(json); + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const requests = parsed.value.array.items; + try std.testing.expectEqual(@as(usize, 2), requests.len); + try std.testing.expectEqualStrings("specialist-request-ocr-region-7", requests[0].object.get("request_id").?.string); + try std.testing.expectEqualStrings("specialist-request-table-region-7", requests[1].object.get("request_id").?.string); +} + test "page specialist request writer scopes requests and spans to page" { const PageRoute = struct { page_index: u32,