380 lines
22 KiB
Markdown
380 lines
22 KiB
Markdown
# 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.
|