Files

380 lines
22 KiB
Markdown
Raw Permalink Normal View History

2026-09-25 23:32:29 +02:00
# 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.