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

156 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_Kind`s 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.