22 KiB
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 variablessnake_case. Enum membersPascal_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 ofpdf.odinnames, 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_textandBitmap. 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
/Lengththat 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
Documentowns a growing arena (core:mem/virtual). Every object, dictionary, stream and decoded buffer produced while reading lives there and is reclaimed in one call byclose. Allocate withdoc.allocator. - Anything handed to a caller that is not owned by the Document takes an
explicit
allocator := context.allocatorparameter and says so in its doc comment. That isrender_page'sBitmap,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
freeordeleteon 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.
- Never panic. No unchecked slicing, no
asserton file-derived data, no unwrappedor_elseon parsed values. Bounds-check every index that came from the file. - 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/Parentchains, self-referencing object streams and xref/Prevloops all occur in real files. - 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
Repairon the Document and carry on. Only return anErrorwhen there is genuinely nothing to hand back. - Clamp allocations. A
/Length,/W,/Heightor/Sizefrom a file is attacker-controlled. Check it against the remaining file size before allocating, and againstMAX_*limits. - No NaN or infinity below the Device line. Numbers coming out of a file
are checked with
is_finiteat 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_CORPUSenvironment 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_spanandstroke_flatten -> raster_fill -> blend_spanwere 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
f64colour is straight and 0..1; everyBitmapbelow 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.odinrather than in the six:path_flattendropped the connecting segment of althat followed anh, because its.Linearm did not seed the new subpath with the current point the way its.Curvearm 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 bothcolorspace_loadandfunction_loaduse) had been defined infunction.odinand reached into fromcolorspace.odin, and now lives inobject.odinwith the rest of theObjectaccessors;font_standard.odincarried its own hex-digit decoder alongsideobject.odin'shex_value. - One internal contradiction, fixed: the bare
/Patternsingleton declaredn = 1directly under a comment saying a colour in it has no components.nis what tells the interpreter how many operandsscntakes, and/P1 scntakes 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_spanand the ink checked pixel by pixel against where the glyph's em square lands under a real text matrix; an image was driven throughimage_load -> image_decode -> blend_spanas a stencil and throughblend_span_rgba8as colour; an axial shading and the same shading behind a/PatternType 2were painted and checked against exact pixel values computed by hand. Every one fitted first time. - One parallel-authorship seam, fixed:
image.odincarried its ownimg_byte— the 0..1-to-byte quantiser, NaN handling and half-up rounding and all — besideblend.odin'sblend_quantize, which is the same procedure and whichshading.odin, written by a different author against the same contract, had correctly reused.image.odinnow callsblend_quantizetoo. Quantising a colour to a byte isblend.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.odingrewtest_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 setsprogram_failed, every outline is finite,font_decodeconsumes 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.numGlyphsat the original count while emptyingloca, 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.notdefandspace, both of which are a singleendcharbyte, 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 norprogram_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 spellingxref_test.odinandobjstm_test.odinhave 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.shand the 1294-test suite were both green on the first run after the seven landed. That is what freezingDevice,Bitmap,Render_Optionsand theSpan_Proccontract ahead of the code bought. - The render sweep (
test_corpus_renderincorpus_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 topage_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 withpage_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.pdfpage 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/pdfrenderandexamples/pdftextwere added and are compiled bycheck.shalongsidepdfinfo, which is what keepsrender_page,extract_text,Bitmap,bitmap_rowandscale_matrixgenuinely 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.expectof 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.