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

8.8 KiB
Raw Blame History

testdata — the golden PDFs

Fourteen committed files, one per structural case, asserted by golden_test.odin. They are deliberately tiny (370 B to 1.4 KB) and, wherever the format allows, plain text: open one in an editor and the whole of it is visible at once.

Why these exist at all

The rest of the suite builds its PDFs in memory with the pb_* builders in xref_test.odin, and should keep doing so — the builder records byte offsets as it writes, so a test never asserts a hand-counted number. But a builder is part of the same codebase as the reader, and there are three things it therefore cannot show:

  1. that open_file works on a real file on disk, not just on a byte slice;
  2. that byte offsets written by some other tool are the ones we parse — a committed file's offsets cannot silently follow a change in how we emit them;
  3. that a refactor did not quietly change what the library accepts by changing the builder at the same time. These bytes are frozen; the builder is not.

They also give the render layer, when it arrives, fourteen pages it can draw without inventing its own fixtures.

What every file has in common

  • One page (two in page-tree-deep.pdf) whose content stream draws a filled blue rectangle and a red stroked diagonal:

    % <file name>
    0.20 0.40 0.90 rg
    20 20 160 100 re
    f
    0.90 0.10 0.10 RG
    4 w
    20 20 m 180 180 l
    S
    

    The first line names the file, so a test that decodes a content stream can say which file's stream it got. golden_test.odin asserts the marker line on page 0 and 160 100 re on every page.

  • /MediaBox [0 0 200 200], except where the file's whole point is a different box: incremental-update.pdf (300×300 after the update) and page-tree-deep.pdf (300×400, inherited).

  • No /Filter on the content stream. Compression is exercised by the filter unit tests, which have the format's own vectors; a golden file that had to be inflated before it could be read would be a worse golden file.

The files

File Exercises Opens with
classic-xref.pdf the minimal well-formed document: four objects, one xref subsection, trailer, startxref no repairs
xref-stream.pdf PDF 1.5 /Type /XRef stream, /W [1 4 2], FlateDecode + /Predictor 12 /Columns 7 no repairs
object-stream.pdf catalog, page-tree root and page living inside a /Type /ObjStm; xref stream with type-2 entries no repairs
incremental-update.pdf two generations; the second rewrites object 3 and adds object 5, with a three-subsection table and /Prev no repairs
junk-before-header.pdf %PDF- at offset 70, every offset in the file measured from the header rather than from byte zero no repairs
broken-xref.pdf startxref pointing into a run of garbage; recovery is by scanning for N G obj Bad_Startxref, Rebuilt_Xref
bad-length.pdf a content stream whose /Length says 12 and whose body is 92 bytes; endstream has to be found by scanning Bad_Stream_Length
no-xref.pdf no table, no trailer, no startxref at all — objects and %%EOF, nothing else Bad_Startxref, Rebuilt_Xref, Missing_Root
page-tree-broken.pdf a /Kids cycle (2 → 3 → 2) with a real page hanging off the far side of it Cyclic_Page_Tree
encrypted-rc4-40.pdf standard security handler, /V 1 /R 2, RC4 40-bit no repairs
encrypted-rc4-128.pdf /V 2 /R 3 /Length 128, RC4 128-bit no repairs
encrypted-aes128.pdf /V 4 /R 4, crypt filter /StdCF with /CFM /AESV2 no repairs
encrypted-aes256.pdf /V 5 /R 6, /CFM /AESV3, with /UE, /OE and /Perms no repairs
page-tree-deep.pdf three nested /Pages levels; /MediaBox and /Rotate declared only on the root, /Resources only on the middle node; page 2 overrides /Rotate alone no repairs

"Opens with" is the exact set of Repair_Kinds the file must record once every page's content stream has been read — golden_test.odin compares the set, not a subset. A file that starts opening more cleanly than this is as much a regression as one that starts opening less cleanly, and both fail here.

Two details worth knowing before editing one of these:

  • broken-xref.pdf's garbage run is bare keywords (garbagegarbage…), not a % comment. A comment is skipped by the lexer, which would carry the probe straight on to the real xref table and make the bad offset work by accident. It is also longer than 2 × XREF_LOCATE_WINDOW so that the ±64-byte search around the bad offset cannot reach an object header either.
  • bad-length.pdf's repair is recorded when the stream is parsed, which is lazy. A test that reads doc.repairs straight after open_file sees nothing.

Encryption

All four encrypted files share one arrangement:

  • user password empty — the file opens without prompting;
  • owner password owner — so the owner branch of the key derivation is exercised by a real file and not only by a unit vector;
  • /P -44: bits 3 (modify) and 5 (annotate) clear, everything else set. R2 only ever defined four permission bits, so encrypted-rc4-40.pdf reports {Print, Copy} where the others report six;
  • a /Info dictionary whose /Title is the encrypted string Golden File, so that string decryption is covered as well as stream decryption;
  • a fixed /ID of 9A1C2B3D4E5F60718293A4B5C6D7E8F9 (both halves), which is an input to the R2–R4 key derivation and therefore may not be changed on its own;
  • a classic xref table rather than a cross-reference stream, because an xref stream is read before the file key exists and is never encrypted — which would make the file exercise less, not more.

encrypted-aes256.pdf additionally fixes the file encryption key itself (it is wrapped in /UE and /OE rather than derived), the two 8-byte salts in /U and the two in /O. /Perms is the 16 bytes P (little-endian) ‖ FF FF FF FF ‖ 'T' ‖ "adb" ‖ "odin", AES-256-ECB encrypted with the file key.

AES payloads are IV ‖ CBC(PKCS#7(plaintext)). The IVs are fixed, not random: iv[i] = obj_num XOR (i*17 + 3). Nothing checks them, and a golden file whose bytes changed on every regeneration would not be golden.

Regenerating one

There is deliberately no generator in the repository. The point of these files is that the bytes are fixed: a generator that ran at test time would be one more piece of the same codebase, and would answer none of the three questions at the top of this file. Editing a file by hand in a text editor is the expected way to change one — every offset that matters is in the xref table at the end, and golden_test.odin will tell you immediately if you got one wrong.

If you do need to rebuild one from scratch, the recipe that produced these was a throwaway @(test) procedure in the package (so that it could use the @(private) pb_* builders and the crypt.odin primitives), which then wrote the result with os.write_entire_file. The parts that are not obvious:

  • Structure. pb_make, pb_obj, pb_classic_xref, pb_trailer and pb_startxref from xref_test.odin do the whole of classic-xref.pdf. The content-stream objects were written with a local variant of pb_stream_obj that omits the double space an empty dictionary head leaves behind.
  • Cross-reference streams. xref_rows_encode(rows, 1, 4, 2) lays out the rows; png_up_encode(raw, 7) applies the PNG Up filter for /Predictor 12; zlib_stored wraps the result in stored DEFLATE blocks, which is a valid zlib stream and keeps the output byte-stable without needing a compressor.
  • Object streams. objstm_payload(nums, bodies) returns the payload and the /First the dictionary must declare. The one here is left unfiltered so that the pair table and the three objects stay legible.
  • /O and /U for R2–R4. The library only ever needs the inverse of Algorithm 3 (crypt_owner_password, which decrypts /O back to the user password), so the encrypting half has to be written out: pad the owner password, MD5 it, iterate MD5 50 times over the full 16-byte digest when R ≥ 3, take the first n bytes as an RC4 key, RC4 the padded user password, and for R ≥ 3 repeat 19 more times with the key XORed by i for i = 1…19 — ascending, where the decrypting direction descends. /U is then just crypt_key_r234 followed by crypt_user_value, both of which the library already has.
  • R6. crypt_hash_r56 is Algorithm 2.B and is used as-is for all four hashes. /UE and /OE are aes_cbc_encrypt of the 32-byte file key under the key-salt hash with a zero IV and no padding.

After regenerating, update the row in golden_test.odin's GOLDEN table if the page size, page count, marker line or repair set changed. Nothing else in the suite reads these files.