Design

This document records why the engine was chosen, the result model afpdf targets, the extraction rules the article contract implies, and how changes are regression-tested. The principles in the README take precedence; this document makes them concrete.

1. Engine choice

Corpus

This is a historical manual comparison used to choose the initial engine, not a benchmark run by the current test suite. The full external corpus and mutated files are not checked in; timings and failures below describe only that comparison and should not be generalized to current upstream releases.

The comparison used real documents from the first consumer: Chinese legislation and regulatory PDFs downloaded from government sites. The set covered:

Results

ConcernPDFium (baseline)pdf_oxide 0.3LiteParse 2.16 (PDFium fork)
Reading orderCorrect line orderDefault output moves line ends and centered headings to the page endPlain text correct; Markdown merges headings into body and joins provisions
Paragraph joinsMostly correct; headings glued to body; indented article markers missedInserts spaces inside Chinese words at joinsInserts spaces inside Chinese words; no cross-page continuation
Page furnitureRemoved in this setPage numbers kept; header option quadratic and ineffectiveHalf-width numbers removed; full-width — 56 — kept; Markdown only
TablesFlattened to lines2 of 8 tables found, columns lost; ruled 6-row table split into 14 rowsRuled tables good, with cell boxes; unruled table body put in one cell; spaces inserted inside numbers
ImagesBitmaps and boxes available, not placedBoxes available; all images emitted at page endPlaced in reading order; decorative and repeated images filtered
Hidden textExtracts text clipped out of view—Drops text hidden by clipping paths
Page statusWhole-document onlyPer page, correct on this setPer page; “too little text” reason misfires on sparse legal pages
Unmapped fontsLost 22 characters (第 123 号 became 123)No lossSame loss as PDFium
Other text loss——Lost a whole line beside a tall math-font text box
RobustnessMalformed files fail fast with an errorNo crash, but one mutated file ran past 60 s; empty text on a truncated file without errorOne mutated file aborted the process (≈1e18-byte allocation); one hung past 60 s
Speed56 pages in ≈56 ms≈3× slower4–155 ms per file

Decision

Use PDFium through pdfium-render. Implement the article layer here. The alternatives’ strengths become requirements (section 3), not dependencies. A second engine may be used only as an isolated cross-check, for example to recover characters PDFium cannot map, and never as a silent fallback.

2. Two result layers

Extraction has schema_version: 1, basic, and an optional article. The SDK returns typed Rust structures; the CLI frames them as AFDATA results. No engine-specific objects cross the SDK or worker boundary.

BasicDocument contains metadata, total page count, selected pages, optional encoded images, and extraction diagnostics (pages processed and failed, the page cap, clipped and unmapped glyph counts, image failures). Each page retains:

The basic layer represents PDFium observations, not a complete PDF object dump. Native extraction cannot recover wording from missing font maps or scans. Unmapped counts and original glyph values make that limitation visible.

organize(&basic, &ProcessingOptions) performs no I/O. It derives page text-layer status, reading order, heading/paragraph/provision/list/table/figure/footnote blocks, source spans, and removed furniture, plus its own diagnostics (pages reflowed without geometry, repetition detection skipped). It carries nothing the basic layer already holds: a page’s engine text is raw_text on the basic page only, and extraction diagnostics are not copied. Article::text() flattens text blocks in reading order; it invents no OCR text or image placeholder language. Figures reference image IDs; caller protocols can define their own markers.

Image objects are enumerated at the page’s top level. Bitmaps drawn inside form XObjects are neither listed as image metadata nor extracted.

All coordinates use PDF points and a bottom-left origin. Glyphs and lines preserve style/geometry evidence; article blocks contain source regions, not an exact glyph-to-block alignment map. Cross-page paragraphs carry multiple page spans. Headers and footers remain separately inspectable.

3. Configurable organization

ExtractOptions.processing = None selects basic extraction. Otherwise the same pure organizer is invoked by the native SDK, CLI worker, and isolated SDK. Defaults enable these independent options:

OptionEffect when disabled
remove_page_furnitureKeep page numbers and repeated running headers/footers
merge_paragraphsKeep visual line boundaries and disable cross-page joins
merge_across_pagesKeep paragraph fragments separate at page breaks
drop_clipped_textInclude text outside recognized object clipping bounds
place_imagesOmit figure blocks from reading order; retain image metadata

expected_language defaults to auto. An explicit chinese hint enables the legacy Han-ratio heuristic for a broken text layer over a page-sized scan. English text is never rejected merely because it has few Han characters.

Lines use baselines and font size so tall math glyphs do not absorb neighbours. Paragraph joining uses indentation, text edges, spacing, and numbering. Chinese provisions remain boundaries, and CJK joins do not acquire spaces. Furniture uses repetition and margin position; short page selections have less evidence and diagnostics report when repetition detection is skipped. Tables use text geometry and alignment; exact table cells, equation semantics, caption association, and arbitrary-layout reconstruction are not guaranteed. Clipping recognition is conservative: top-level text objects with understood rectangular path bounds; nested form-local paths are not interpreted.

4. Native runtime and limits

PDFium is accessed through pdfium-render 0.9.4. The engine feature is optional; clients using only isolation do not load native PDFium in the parent process. The worker executable must be installed by the caller. The shared library is resolved from EngineOptions, which is process-level configuration rather than a per-document option: an explicit library_path, else the library in the install directory when present, else the platform’s dynamic-library search for pdfium. afpdf engine install fills the install directory (per-user data directory by default) with the pinned build 8035 from bblanchon/pdfium-binaries, verified against a checksum table compiled into the executable, and writes a manifest beside it; afpdf engine status reports the resolved library, whether it binds, and the platform’s limit support without changing anything. In-process extraction serializes complete calls and fixes the library after its first successful initialization, matching PDFium’s global binding lifetime.

IsolatedParser creates one afpdf worker process per PDF. Stdin contains a versioned typed request with PDF bytes encoded as base64. The worker responds with one AFDATA result on stdout or error on stderr. No persistent worker state is reused. Native aborts become worker_failed; malformed replies become worker_protocol; deadline and output bounds produce worker_timeout and worker_output_limit. A worker that rejects the call before reading its document (a mismatched executable) is reported by its own stderr event, not by the parent’s failed write. Raw native stderr is never copied into a caller error.

Defaults: 500 pages, 120 MiB input (also the hard maximum), 40 million pixels per image/render, 300000 ms wall-clock time, 256 MiB worker result, 64 KiB worker stderr, and a 2 GiB address space on Linux (zero, meaning no limit, elsewhere). The worker request frame is capped at 192 MiB. The page cap is explicit in diagnostics rather than silently pretending the full PDF was processed. Image extraction failures record IDs and codes; failed pages retain status. Input and output bounds also protect parent-side buffering. Base64 and glyph records add overhead; callers should lower limits to suit service capacity.

On Linux, the worker uses RLIMIT_AS before opening PDFium. A native allocation failure can abort rather than produce a graceful Rust error; the parent still reports a failed document. A nonzero memory limit on another platform fails every worker explicitly; the default is zero there, so the limit is never silently skipped and never silently invented. In-process SDK calls have no hard timeout or native crash containment. Processes provide containment, not a security sandbox. Timeouts and failed exchanges kill and reap the child. Async cancellation kills it via Tokio’s kill_on_drop process handling. Services retain their own bounded queues and concurrency controls. One worker handles one document.

5. Consumer boundary

The extraction tool owns PDF decoding, geometry, native image encoding/rendering, repeatable organization, and the versioned worker protocol. A service owns acquisition, authentication, transport/protobuf, encryption, caching, quotas, queue scheduling, storage, indexing, OCR/model orchestration, and domain rules. Legacy text/image placeholders belong in the consumer’s compatibility adapter. The initial service migrates with a version-only Cargo dependency plus its own local Cargo patch until this crate is published. Its deployment stages afpdf next to the PDFium shared library and names that library in EngineOptions.

6. Verification

Use scripts/test.sh for formatting, Clippy, pure layout/CLI tests, generated reference consistency, and native checks. scripts/test.sh engine runs the four small government PDF fixtures in a container against a system-installed PDFium. Automatic runtime selection tries Apple container on macOS, Podman, then Docker. The suite exercises the CLI worker and runs afpdf engine install into a temporary directory to check the download, checksum, manifest, and precedence over the system library. The Windows CI job runs the same install test through a real pdfium.dll. Fixtures document their sources in tests/fixtures/pdf/README.md.

The initial consumer migration saves the previous parser’s output before switching, then compares four fixtures across readable/raw/marker/embedded-image requests. Text, furniture, page status, image geometry and encoded-image hashes are compared. These fixtures constrain migration behavior; they are not proof of accuracy for arbitrary papers, languages or scanned PDFs.

Isolation tests exercise aborts, hangs, output floods, malformed replies, and recovery on a subsequent request. Broader stored corpora and mutated PDFs should remain release gates when available; do not claim that this small checked-in fixture suite covers an unavailable external corpus.

7. CLI configuration and AFDATA boundaries

afpdf extract INPUT returns document facts, organized article structure, and text. The configuration’s output.basic can include native evidence independently. There is no basic/readable/both mode, nor one flag for each processing rule. --set KEY=VALUE applies typed, invocation-only overrides. A new settings field needs its typed model/default/validation, not a new CLI argument or dispatcher.

Precedence: built-in defaults < JSON file < repeated invocation overrides. .afpdf/config.json in the current directory is the default location; an explicit --config PATH selects another JSON file. No ancestor search, global file merge, or SDK auto-loading is implied. Ordinary reads and extraction do not create files. Explicitly selected missing files fail extraction.

config show/get inspect defaults-filled settings. config set saves a sparse override, while config unset removes one. Both use AFDATA Document and DocumentFile: dot-path traversal, type-directed coercion, source-preserving staging, complete typed/domain validation, and atomic commit. A rejected edit leaves the file unchanged; a rejected first edit creates no file or directory. Config reads are capped at 1 MiB. Mutations inherit AFDATA’s refusal to overwrite unsafe link targets. Concurrent edits use the document layer’s last-writer-wins save semantics; atomic installation does not imply multi-writer merging.

extract settings reuse ExtractOptions directly; execution holds the engine location and isolation settings; output selects basic evidence, article blocks, and text independently. The CLI result always carries the document facts (metadata, total_pages, diagnostics) so an incomplete extraction is visible whatever content was selected. article and text are on by default; basic adds the per-page evidence (raw_text, glyphs, lines, image metadata) and nothing the top level already states. Requested image payloads appear once in the images array, independently of output.basic. The SDK’s full BasicDocument and internal worker protocol keep their typed image payloads unchanged. Pure basic output can disable organization with extract.processing=null after selecting basic-only output. See configuration.md for runnable configuration examples.

The CLI registry declares legal command shapes and AFDATA owns argument parsing, help, event construction, redaction and output routing. The internal worker always uses JSON and split result/error routing, and receives resolved settings. Native library diagnostics do not become public error prose. Coordinates carry PDF units (_pt, _px), byte limits/durations use _bytes/_ms, and binary payloads use base64 strings. PDF dates remain native PDF date strings.

Protocol and naming checks run against real CLI events and serialized extraction results. Bash orchestration forwards the AFDATA child marker into the container so the outer command owns a single terminal result.