Files
pdf-odin-llm/ARCHITECTURE.md
2026-09-25 23:32:29 +02:00

22 KiB
Raw Permalink Blame History

pdf-odin — architecture and contributor contract

A self-contained PDF reader and renderer in pure Odin. No C dependencies: everything outside of core: is written here.

Pipeline

bytes
  │  lexer.odin, parser.odin          COS syntax  →  Object
  │  xref.odin, objstm.odin           file structure, object streams
  │  crypt.odin                       decryption (transparent, at parse time)
  ▼
Document  ── document.odin, pagetree.odin ─────────────────────────────────┐
  │  filters.odin + filter_*.odin     stream decoding                      │
  ▼                                                                        │
Page      ── page.odin                                                     │
  │  content.odin                     content-stream operators             │
  │  gstate.odin                      graphics state stack                 │
  │  colorspace.odin, function.odin, shading.odin, pattern.odin            │
  │  font*.odin, cmap.odin            glyph outlines and widths            │
  ▼                                                                        │
Device (vtable)  ── device.odin ───────────────────────────────────────────┘
  ├── device_raster.odin   → Bitmap (RGBA8)      path.odin, raster.odin,
  ├── device_text.odin     → extracted text      stroke.odin, blend.odin
  └── device_list.odin     → display list         image.odin

Everything above Device is format knowledge; everything below is pixels. The three devices share one interpreter, which is why text extraction agrees with what is drawn instead of being a second, subtly different parse.

File ownership

One concern per file. Do not add procedures to a file you do not own.

File Contents
pdf.odin package doc, version constants
error.odin Error, Repair, error_string
object.odin Object, Dict, Array, Stream, accessors
geom.odin Point, Matrix, Rect
lexer.odin COS tokeniser
parser.odin token stream → Object
xref.odin xref tables, xref streams, trailer chain, rebuild-by-scan
objstm.odin /Type /ObjStm compressed object streams
document.odin Document, open_*, close, resolve, object cache
pagetree.odin page tree walk, inherited attributes, Page
crypt.odin standard security handler, RC4, AES-CBC, key derivation
filters.odin filter chain dispatch, stream_data, predictors
filter_flate.odin tolerant inflate
filter_lzw.odin LZW
filter_ascii.odin ASCIIHex, ASCII85
filter_runlength.odin RunLength
filter_ccitt.odin CCITT G3/G4 fax
filter_dct.odin JPEG: baseline + progressive, grey/YCbCr/CMYK/YCCK
encoding.odin text strings, PDFDocEncoding, dates, name/number trees
function.odin PDF functions type 0/2/3/4
colorspace.odin all colour spaces → RGB
path.odin Path, construction, transform, flatten
raster.odin anti-aliased scanline fill
stroke.odin stroke → fill path (joins, caps, dashes)
bitmap.odin Bitmap: allocation, rows, premultiplication, RGBA conversion
blend.odin blend modes, compositing, soft masks
image.odin image XObjects → samples, image masks, /SMask
shading.odin shading types 1–7
pattern.odin tiling and shading patterns
cmap.odin CMap parsing, /ToUnicode, predefined CMaps
font.odin font loading and dispatch, widths, encodings
font_cff.odin CFF / Type2 charstrings
font_truetype.odin glyf/loca/cmap/hmtx/post
font_type1.odin Type1, eexec, Type1 charstrings
font_standard.odin standard-14 metrics, built-in encodings
content.odin content-stream lexer and operator dispatch, Type3 glyph execution
gstate.odin graphics state and its stack
device.odin the Device vtable
device_raster.odin rasterising device
device_text.odin text-extraction device
device_list.odin display-list device
page.odin render_page, extract_text, public page API
annot.odin annotation appearance streams
outline.odin document outline, destinations

Conventions

  • 4 spaces, no tabs. Types Pascal_Snake (Xref_Entry, Graphics_State) except the handful of short public nouns already fixed (Object, Dict, Page, Matrix). Procedures and variables snake_case. Enum members Pascal_Snake.
  • Procedures are prefixed by the noun they act on: path_transform, font_glyph, dict_get. This keeps a flat 40-file package navigable.
  • @(private) anything that is not part of the public API or used across files. The public API is exactly what the package doc comment at the top of pdf.odin names, plus the types those procedures take and return: open_file, open_bytes, Open_Options, close, page_count, page_get, Page, the object accessors (resolve, get_object, get_dict, get_stream, dict_*, array_*, to_*, stream_data), Error, Repair, error_string, the encryption accessors, and -- once the device layer exists -- render_page, extract_text and Bitmap. Everything else is @(private), tests included: a test lives in the same package and reaches private names fine, so being called by one is not a reason to export. Widening the public API means editing that doc comment in the same commit, which is what keeps the two in step.
  • Comments explain why, especially why a tolerance exists: "a /Length that disagrees with the bytes is common enough that …". Do not narrate what the next line does.
  • Cite the spec by clause number when the behaviour is non-obvious: // ISO 32000-2, 7.5.8.2.

Memory

  • A Document owns a growing arena (core:mem/virtual). Every object, dictionary, stream and decoded buffer produced while reading lives there and is reclaimed in one call by close. Allocate with doc.allocator.
  • Anything handed to a caller that is not owned by the Document takes an explicit allocator := context.allocator parameter and says so in its doc comment. That is render_page's Bitmap, extract_text's string, and nothing else.
  • Per-page rendering scratch goes in a temporary arena that is reset between pages, never in the Document arena — a 900-page render must not grow without bound.
  • Never call free or delete on Document-arena memory.

Robustness — the rule that matters most

Malformed PDFs are the normal case, not the exception. A reader that rejects a file every other viewer opens is a broken reader.

  1. Never panic. No unchecked slicing, no assert on file-derived data, no unwrapped or_else on parsed values. Bounds-check every index that came from the file.
  2. Never loop forever. Every loop driven by file data needs a bound: a recursion depth limit (MAX_DEPTH), an iteration fuel counter, or a visited-set. Cyclic /Parent chains, self-referencing object streams and xref /Prev loops all occur in real files.
  3. Degrade, don't fail. A broken font renders as blanks; a broken image is skipped; an unparsable content fragment is stepped over to the next operator. Record a Repair on the Document and carry on. Only return an Error when there is genuinely nothing to hand back.
  4. Clamp allocations. A /Length, /W, /Height or /Size from a file is attacker-controlled. Check it against the remaining file size before allocating, and against MAX_* limits.
  5. No NaN or infinity below the Device line. Numbers coming out of a file are checked with is_finite at the boundary.

Tests — write them with the code, not after

Every file foo.odin gets a foo_test.odin in the same package, using core:testing. A layer is not finished until its tests pass.

  • Unit tests are self-contained: byte literals in, expected values out. Codec tests use vectors from the format's own specification where one exists.
  • Golden PDFs live in testdata/ and are tiny, hand-written, and committed. They cover the structural cases: classic xref, xref stream, object stream, incremental update, broken xref needing a rebuild, wrong /Length, each encryption revision.
  • Corpus tests run over whatever is in the directory named by the PDF_ODIN_CORPUS environment variable and skip silently when it is unset, so they never depend on files that are not in the repository. They assert only invariants: opens without error, page count > 0, every page renders without crashing, no leaks.
  • Fuzz-shaped tests: truncate and bit-flip the golden files at many offsets and assert the library returns an error rather than crashing or hanging.

Run scripts/check.sh (type-check + vet) and scripts/test.sh before declaring anything done.

Limits

Defined in document.odin, referenced everywhere:

MAX_DEPTH            32     // nested arrays/dicts, form XObject recursion
MAX_OBJECT_NUMBER    8388607
MAX_XREF_CHAIN       64     // /Prev hops
MAX_CONTENT_OPERANDS 32
MAX_BITMAP_PIXELS    (1 << 28)

Status: what is written and what remains

The library renders. Every file in the table above is written, tested and driven over a real corpus. open_file -> page_get -> render_page produces a correct picture of a real page, extract_text produces its text off the same interpreter, and page_display_list records it for a caller with a backend of their own. There is no skeleton left.

Complete: pdf error object geom document lexer parser xref objstm pagetree crypt encoding filters filter_flate filter_lzw filter_ascii filter_runlength filter_ccitt filter_dct path bitmap gstate device raster stroke blend colorspace function cmap font_standard font_cff font_truetype font_type1 font image shading pattern content device_raster device_text device_list page annot outline.

Type3 was planned as font_type3.odin and does not need a file: a Type3 glyph is a content stream, so loading it belongs to font.odin with the other font dictionaries (font_load_type3, font_type3_proc) and running it belongs to the interpreter that runs every other content stream (content_run_type3, content_type3_d1, and the Type3_Glyph flag that makes d0/d1 legal and stops a glyph that shows itself from recursing). Splitting it out would have meant a file that could only call back into content.odin. It is finished and proven end to end: a Type3 font whose glyph is 0 0 750 750 re f at 40 pt rasterises to exactly the three 30-point squares its /FontMatrix and /Widths call for.

What remains

Nothing structural. What is left is one real gap, two deliberate approximations, and the review debt from the earlier batches.

Non-embedded fonts draw nothing. This is the one visible defect in the whole layer and the render sweep below found it. A font dictionary with no /FontFile* still loads, still lays out and still extracts text correctly -- that is what the render-layer contract asks for and it works -- but font_glyph_path has no outline to return, so the text is invisible. The library ships no font programs of its own and reads none from the system, so there is nothing to substitute with: fixing it means either bundling a metric-compatible face for the standard 14 or adding a system-font lookup, and both are new subsystems rather than a repair. Fifty-nine of the corpus's 894 fonts are unembedded, and they are why fifteen corpus pages come back blank or nearly blank.

Two transparency approximations, both stated at the top of device_raster.odin where they are made: a knockout group is rendered as a non-knockout one, and a non-isolated group whose own alpha is not 1 or whose own blend mode is not Normal is rendered isolated. Both are the approximations every renderer without a backdrop-removal pass makes, and docs/corpus-notes.md ranks the cases they differ on as rare.

Still owed from the earlier batches, unchanged: the two adversarial review lenses -- visibly-wrong pixels, and the data tables audited line by line against the AFMs and the AGL. Every table reached by binary search (STD_TEXT_GLYPHS, STD_SYMBOL_GLYPHS, STD_ZAPF_GLYPHS, AGL_TABLE, AGL_BY_RUNE) has been verified sorted and in range, and spot values across all five width tables and both AGL directions match their source documents, but that is a smoke test, not the audit.

What the integration checks have covered

Three batches were built in parallel and each has since been integrated. The first was the six rendering primitives — raster stroke blend colorspace function cmap font_standard; the second was the seven files above them — font font_cff font_truetype font_type1 image shading pattern; the third was the render layer itself — content device_raster device_text device_list page annot outline.

From the first batch:

  • The layer composes. path_flatten -> raster_fill -> blend_span and stroke_flatten -> raster_fill -> blend_span were driven end to end into a Bitmap and checked pixel by pixel against the geometry: aligned and half-covered rectangles, both fill rules, butt and round joins and caps, a dashed line, a hollow stroked square, a hairline under a 4x CTM, and a clip not anchored at the origin. All correct, with no fringing and no seams.
  • The three conventions the primitives meet on hold everywhere: coverage is 0..255 with 255 meaning fully covered; every f64 colour is straight and 0..1; every Bitmap below the Device line is premultiplied. blend.odin's header states this rule and every entry point in it obeys it.
  • One composition bug, in path.odin rather than in the six: path_flatten dropped the connecting segment of a l that followed an h, because its .Line arm did not seed the new subpath with the current point the way its .Curve arm always has. Fixed, with a regression test that holds the two arms to each other.
  • Two parallel-authorship seams, both fixed: object_identity (the cache key both colorspace_load and function_load use) had been defined in function.odin and reached into from colorspace.odin, and now lives in object.odin with the rest of the Object accessors; font_standard.odin carried its own hex-digit decoder alongside object.odin's hex_value.
  • One internal contradiction, fixed: the bare /Pattern singleton declared n = 1 directly under a comment saying a colour in it has no components. n is what tells the interpreter how many operands scn takes, and /P1 scn takes none, so it is 0.

From the second batch:

  • The seven compose with the primitives and with each other. A font dictionary with an embedded TrueType program was driven through font_load -> font_decode -> font_glyph_path -> path_flatten -> raster_fill -> blend_span and the ink checked pixel by pixel against where the glyph's em square lands under a real text matrix; an image was driven through image_load -> image_decode -> blend_span as a stencil and through blend_span_rgba8 as colour; an axial shading and the same shading behind a /PatternType 2 were painted and checked against exact pixel values computed by hand. Every one fitted first time.
  • One parallel-authorship seam, fixed: image.odin carried its own img_byte — the 0..1-to-byte quantiser, NaN handling and half-up rounding and all — beside blend.odin's blend_quantize, which is the same procedure and which shading.odin, written by a different author against the same contract, had correctly reused. image.odin now calls blend_quantize too. Quantising a colour to a byte is blend.odin's to own: it is the file whose header states the byte conventions the whole device layer meets on.
  • The per-format byte readers (cff_u16, tt_u16, mesh_bits, img_sample) were checked against each other and left alone. They look alike and are not: each font-program reader is required to be self-contained, and the two sample unpackers differ in kind — one is indexed and capped at 16 bits, the other is a sequential bitstream reader up to 32.
  • corpus_test.odin grew test_corpus_resources, which loads and drives every font, image, shading and pattern named by every page of every corpus file, recursing into form XObjects and tiling-pattern resources. Over 71 documents it reaches 894 distinct fonts and 1579 images, probes 123 000 glyph outlines and decodes 372 000 character codes, and found no failure of any kind. That is the first time the library met real subsetted font programs at scale, and the invariants it holds them to are the ones the interpreter will depend on: a Font always comes back, an embedded program either has glyphs or sets program_failed, every outline is finite, font_decode consumes exactly the bytes it is given, and an image either decodes to a bitmap of its own dimensions or degrades to an error with no bitmap at all.
  • Three things the sweep taught, all recorded here because they will otherwise be rediscovered as bugs: a subsetter leaves maxp.numGlyphs at the original count while emptying loca, so a font can declare 22 026 glyphs and hold 25 — sampling glyph ids rather than scanning them will find none of them; a Type1C subset is routinely stripped to .notdef and space, both of which are a single endchar byte, so "no glyph in this font draws" is a normal file and not a defect; and a font with no embedded program sets neither a glyph count nor program_failed, because nothing failed — the standard-14 metrics are the whole of what the file gave.
  • 296 of the 976 tests belong to these seven files. None are vacuous: the ones that appear to assert little all go through a local helper that asserts, and the two that end in expect(t, true) are the truncation and bit-flip fuzzers, where reaching the last line is the property under test — the same spelling xref_test.odin and objstm_test.odin have used since the COS layer.

From the third batch — the render layer, and the first time anything in this repository produced a picture:

  • The seven files fitted together with no conflicts to resolve. Nothing had to be moved between files, no procedure was duplicated across two authors, and no frozen type had to change: check.sh and the 1294-test suite were both green on the first run after the seven landed. That is what freezing Device, Bitmap, Render_Options and the Span_Proc contract ahead of the code bought.
  • The render sweep (test_corpus_render in corpus_test.odin) is the check this layer exists for and the most informative thing run on the project so far. It renders the first page of every corpus file — every page of every file under twenty — at 72 dpi, holds each render to page_size's dimensions, a 4-component straight-alpha bitmap and a time budget, and then measures the fraction of non-background pixels. Over 71 documents and 315 pages: zero errors, zero leaks, mean ink 18.3% (Downloads) and 7.1% (the ISO corpus), slowest page 1.4 s, no page's dimensions disagreeing with page_size.
  • Blankness is the invariant that finds real bugs, and it found one. A page that draws nothing is invisible to every other check: it parses, loads every font, decodes every image, and returns .None. So the sweep also asks the text device what the interpreter saw on any page under 1% ink, and reports three different things where a bare ink number reports one: a page that draws nothing and extracts nothing is an empty sheet, a page that draws nothing and extracts 3 000 characters is a defect, and a page with a paragraph on it under 1% ink is the partial version of the same defect. Of the 19 light pages in the two corpora, 6 are genuinely blank paper and 14 are the non-embedded font gap. Not one is an interpreter, clip or base-matrix bug — which was the thing most likely to be wrong and is the reason the sweep was written.
  • The diagnosis is unambiguous on Videcom/VRS XML Service 2020 02FEB2023.pdf page 1, the clearest case: the embedded Courier New draws its code blocks perfectly, every heading rule and the hyperlink rule land exactly where they belong — and every word set in Arial or Times New Roman, neither of which the file embeds, is missing. The geometry, the clipping, the colour and the stroking are all correct; the outlines simply are not in the file and are not in this library either.
  • Text extraction (test_corpus_text) runs the same interpreter into the text device over every page of every file: 3 066 pages, 5.5 MB, 5.37 M runes. The ISO corpus extracts text from 100% of its 1 234 pages with zero U+FFFD. The Downloads corpus yields text on 85% of pages at 0.55% U+FFFD, and every one of the 21 pages above 10% U+FFFD is in a single Arabic-language ITU recommendation whose subset fonts name their glyphs by contextual presentation form — lamwithaleffinal, hahwithmeeminitial — which is in neither the AGL nor the file's own /ToUnicode. Those glyphs render correctly; there is simply nothing in the file that says what they mean, so U+FFFD is the honest answer and not a defect. The unicode chain is otherwise clean across the whole corpus.
  • examples/pdfrender and examples/pdftext were added and are compiled by check.sh alongside pdfinfo, which is what keeps render_page, extract_text, Bitmap, bitmap_row and scale_matrix genuinely public rather than accidentally reachable only from inside the package.
  • 307 of the 1294 tests belong to these seven files. None are vacuous: the 21 that carry no testing.expect of their own all go through a local helper that asserts (rt_expect, pt_expect_point, ol_expect_label), the same spelling every batch before them used.

docs/corpus-notes.md records what the real-world corpus actually uses, which is what the ordering above is based on; docs/build-plan.md is the plan.