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

1265 lines
41 KiB
Odin

package pdf
import "core:testing"
// ---------------------------------------------------------------------------
// Sample-PDF builders
//
// Every structural test in the library needs a file whose byte offsets are
// right, and hand-counting them is how a test ends up asserting the wrong
// thing. These assemble a document and record the offsets as they go, so a
// test says what shape it wants and never a number.
//
// They are `@(private)` rather than file-private on purpose: the page tree,
// annotation and encryption layers all want the same three-object skeleton.
// ---------------------------------------------------------------------------
@(private)
Pdf_Builder :: struct {
buf: [dynamic]byte,
// Byte offset of each object header, indexed by object number; -1 for an
// object number that was never written.
offs: [dynamic]int,
}
@(private)
pb_make :: proc(header := "%PDF-1.7\n") -> Pdf_Builder {
pb: Pdf_Builder
pb.buf = make([dynamic]byte)
pb.offs = make([dynamic]int)
pb_str(&pb, header)
return pb
}
@(private)
pb_destroy :: proc(pb: ^Pdf_Builder) {
delete(pb.buf)
delete(pb.offs)
}
@(private)
pb_str :: proc(pb: ^Pdf_Builder, s: string) {
append(&pb.buf, ..transmute([]byte)s)
}
@(private)
pb_raw :: proc(pb: ^Pdf_Builder, s: []byte) {
if len(s) > 0 {
append(&pb.buf, ..s)
}
}
@(private)
pb_num :: proc(pb: ^Pdf_Builder, v: int) {
digits: [24]byte
n := 0
x := v
if x < 0 {
append(&pb.buf, '-')
x = -x
}
if x == 0 {
digits[0] = '0'
n = 1
}
for x > 0 {
digits[n] = byte('0' + x % 10)
x /= 10
n += 1
}
for i := n - 1; i >= 0; i -= 1 {
append(&pb.buf, digits[i])
}
}
@(private)
pb_pad :: proc(pb: ^Pdf_Builder, v, width: int) {
digits: [24]byte
n := 0
x := max(v, 0)
if x == 0 {
digits[0] = '0'
n = 1
}
for x > 0 {
digits[n] = byte('0' + x % 10)
x /= 10
n += 1
}
for _ in n ..< width {
append(&pb.buf, '0')
}
for i := n - 1; i >= 0; i -= 1 {
append(&pb.buf, digits[i])
}
}
@(private)
pb_here :: proc(pb: ^Pdf_Builder) -> int {
return len(pb.buf)
}
@(private)
pb_mark :: proc(pb: ^Pdf_Builder, num: u32) {
for len(pb.offs) <= int(num) {
append(&pb.offs, -1)
}
pb.offs[num] = len(pb.buf)
}
// pb_obj writes "N 0 obj <body> endobj".
@(private)
pb_obj :: proc(pb: ^Pdf_Builder, num: u32, body: string) {
pb_mark(pb, num)
pb_num(pb, int(num))
pb_str(pb, " 0 obj\n")
pb_str(pb, body)
pb_str(pb, "\nendobj\n")
}
// pb_stream_obj writes a stream object, appending the /Length the payload
// actually has. `dict_head` is the dictionary without its << >>.
@(private)
pb_stream_obj :: proc(pb: ^Pdf_Builder, num: u32, dict_head: string, payload: []byte) {
pb_mark(pb, num)
pb_num(pb, int(num))
pb_str(pb, " 0 obj\n<< ")
pb_str(pb, dict_head)
pb_str(pb, " /Length ")
pb_num(pb, len(payload))
pb_str(pb, " >>\nstream\n")
pb_raw(pb, payload)
pb_str(pb, "\nendstream\nendobj\n")
}
@(private)
pb_xref_begin :: proc(pb: ^Pdf_Builder) -> int {
at := len(pb.buf)
pb_str(pb, "xref\n")
return at
}
@(private)
pb_xref_sub :: proc(pb: ^Pdf_Builder, start, count: int) {
pb_num(pb, start)
pb_str(pb, " ")
pb_num(pb, count)
pb_str(pb, "\n")
}
// pb_entry writes one table entry. The default `eol` gives the 20-byte form
// the spec requires; passing "\n" gives the 19-byte form most producers write.
@(private)
pb_entry :: proc(pb: ^Pdf_Builder, offset, gen: int, used: bool, eol := " \n") {
pb_pad(pb, offset, 10)
pb_str(pb, " ")
pb_pad(pb, gen, 5)
pb_str(pb, used ? " n" : " f")
pb_str(pb, eol)
}
// pb_classic_xref_ex writes one subsection covering objects `real_start`
// upwards, but declaring it starts at `declared_start`. The two differ only in
// the tests for the off-by-one repair.
@(private)
pb_classic_xref_ex :: proc(pb: ^Pdf_Builder, declared_start, real_start: int, eol := " \n") -> int {
at := pb_xref_begin(pb)
n := len(pb.offs)
pb_xref_sub(pb, declared_start, n - real_start)
for i in real_start ..< n {
if i == 0 || pb.offs[i] < 0 {
pb_entry(pb, 0, 65535, false, eol)
} else {
pb_entry(pb, pb.offs[i], 0, true, eol)
}
}
return at
}
@(private)
pb_classic_xref :: proc(pb: ^Pdf_Builder) -> int {
return pb_classic_xref_ex(pb, 0, 0)
}
@(private)
pb_trailer :: proc(pb: ^Pdf_Builder, body: string, xref_at: int) {
pb_str(pb, "trailer\n")
pb_str(pb, body)
pb_str(pb, "\n")
pb_startxref(pb, xref_at)
}
@(private)
pb_startxref :: proc(pb: ^Pdf_Builder, at: int) {
pb_str(pb, "startxref\n")
pb_num(pb, at)
pb_str(pb, "\n%%EOF\n")
}
// pb_placeholder reserves ten digits that pb_patch fills in later, for the
// forward references a /Prev loop needs.
@(private)
pb_placeholder :: proc(pb: ^Pdf_Builder) -> int {
at := len(pb.buf)
pb_str(pb, "0000000000")
return at
}
@(private)
pb_patch :: proc(pb: ^Pdf_Builder, at, value: int) {
x := max(value, 0)
for i := 9; i >= 0; i -= 1 {
if at + i < len(pb.buf) {
pb.buf[at + i] = byte('0' + x % 10)
}
x /= 10
}
}
// pb_skeleton writes the three objects every document needs: a catalog, a page
// tree root and one page.
@(private)
pb_skeleton :: proc(pb: ^Pdf_Builder) {
pb_obj(pb, 1, "<< /Type /Catalog /Pages 2 0 R >>")
pb_obj(pb, 2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
pb_obj(pb, 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>")
}
// pb_sample_classic is the smallest well-formed file: three objects and a
// classic single-section table.
@(private)
pb_sample_classic :: proc() -> Pdf_Builder {
pb := pb_make()
pb_skeleton(&pb)
at := pb_classic_xref(&pb)
pb_trailer(&pb, "<< /Size 4 /Root 1 0 R >>", at)
return pb
}
// ---------------------------------------------------------------------------
// Cross-reference stream payloads
// ---------------------------------------------------------------------------
@(private)
Xref_Row :: struct {
f0, f1, f2: u64,
}
// xref_rows_encode lays out rows in the /W field widths given.
@(private)
xref_rows_encode :: proc(rows: []Xref_Row, w0, w1, w2: int, allocator := context.allocator) -> []byte {
row := w0 + w1 + w2
out := make([]byte, len(rows) * row, allocator)
for r, i in rows {
base := i * row
xref_put_be(out[base:base + w0], r.f0)
xref_put_be(out[base + w0:base + w0 + w1], r.f1)
xref_put_be(out[base + w0 + w1:base + row], r.f2)
}
return out
}
@(private)
xref_put_be :: proc(dst: []byte, v: u64) {
x := v
for i := len(dst) - 1; i >= 0; i -= 1 {
dst[i] = byte(x & 0xff)
x >>= 8
}
}
// png_up_encode applies the PNG "Up" filter, which is what a producer writing
// /Predictor 12 does. Every row gains a leading tag byte.
@(private)
png_up_encode :: proc(src: []byte, row_len: int, allocator := context.allocator) -> []byte {
if row_len <= 0 { return nil }
rows := len(src) / row_len
out := make([]byte, rows * (row_len + 1), allocator)
prev := make([]byte, row_len, allocator)
defer delete(prev, allocator)
for r in 0 ..< rows {
o := r * (row_len + 1)
out[o] = 2
for c in 0 ..< row_len {
out[o + 1 + c] = byte((int(src[r * row_len + c]) - int(prev[c])) & 0xff)
}
copy(prev, src[r * row_len:(r + 1) * row_len])
}
return out
}
// zlib_stored wraps bytes in an RFC 1950 stream of stored (uncompressed)
// DEFLATE blocks. A test does not need compression, only a payload the
// library's inflate must accept.
@(private)
zlib_stored :: proc(src: []byte, allocator := context.allocator) -> []byte {
n_blocks := max(1, (len(src) + 65534) / 65535)
out := make([dynamic]byte, 0, len(src) + n_blocks * 5 + 6, allocator)
append(&out, 0x78, 0x01)
i := 0
for b in 0 ..< n_blocks {
n := min(65535, len(src) - i)
append(&out, b == n_blocks - 1 ? 0x01 : 0x00)
append(&out, byte(n & 0xff), byte((n >> 8) & 0xff))
m := 0xffff - n
append(&out, byte(m & 0xff), byte((m >> 8) & 0xff))
if n > 0 {
append(&out, ..src[i:i + n])
}
i += n
}
s1, s2 := u32(1), u32(0)
for c in src {
s1 = (s1 + u32(c)) % 65521
s2 = (s2 + s1) % 65521
}
adler := s2 << 16 | s1
append(&out, byte(adler >> 24), byte(adler >> 16), byte(adler >> 8), byte(adler))
return out[:]
}
// objstm_payload builds the body of a /Type /ObjStm: the pair table followed
// by the objects. `first` is the /First the dictionary must declare.
@(private)
objstm_payload :: proc(nums: []u32, bodies: []string, allocator := context.allocator) ->
(payload: []byte, first: int) {
pb := Pdf_Builder{buf = make([dynamic]byte, allocator), offs = make([dynamic]int, allocator)}
defer delete(pb.offs)
rel := make([]int, len(bodies), allocator)
defer delete(rel, allocator)
total := 0
for body, i in bodies {
rel[i] = total
total += len(body) + 1
}
for num, i in nums {
if i >= len(rel) { break }
pb_num(&pb, int(num))
pb_str(&pb, " ")
pb_num(&pb, rel[i])
pb_str(&pb, " ")
}
first = len(pb.buf)
for body in bodies {
pb_str(&pb, body)
pb_str(&pb, " ")
}
return pb.buf[:], first
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
@(private)
pb_open :: proc(pb: ^Pdf_Builder) -> (^Document, Error) {
// The page tree is a layer above this one; xref tests must not depend on
// it being able to walk what they built.
return open_bytes(pb.buf[:], Open_Options{skip_page_tree = true})
}
@(private)
doc_has_repair :: proc(doc: ^Document, kind: Repair_Kind) -> bool {
if doc == nil { return false }
for r in doc.repairs {
if r.kind == kind { return true }
}
return false
}
@(private = "file")
expect_page :: proc(t: ^testing.T, doc: ^Document, num: u32, width: f64) {
d := get_dict(doc, Ref{num, 0})
if !testing.expect(t, d != nil, "page object did not resolve to a dictionary") {
return
}
testing.expect(t, dict_name(doc, d, "Type") == "Page", "object is not a /Page")
r := dict_rect(doc, d, "MediaBox")
testing.expectf(t, r.x1 == width, "MediaBox width %v, wanted %v", r.x1, width)
}
// ---------------------------------------------------------------------------
// Classic tables
// ---------------------------------------------------------------------------
@(test)
test_xref_classic_single_section :: proc(t: ^testing.T) {
pb := pb_sample_classic()
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
testing.expect(t, doc != nil, "no document")
if doc == nil { return }
testing.expect(t, !doc.is_damaged, "a well-formed file recorded a repair")
testing.expectf(t, object_count(doc) == 4, "object_count %v, wanted 4", object_count(doc))
testing.expect(t, doc.catalog != nil, "no catalog")
testing.expect(t, doc.xref[1].kind == .Normal, "object 1 is not a normal entry")
testing.expect(t, doc.xref[0].kind == .Free, "object 0 must be free")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_classic_19_byte_entries :: proc(t: ^testing.T) {
// The spec fixes the entry at 20 bytes; a large fraction of producers
// write 19 by using a bare LF. Both must read the same.
pb := pb_make()
pb_skeleton(&pb)
at := pb_classic_xref_ex(&pb, 0, 0, "\n")
pb_trailer(&pb, "<< /Size 4 /Root 1 0 R >>", at)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expect(t, !doc.is_damaged, "19-byte entries should not be a repair")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_incremental_update :: proc(t: ^testing.T) {
pb := pb_make()
pb_skeleton(&pb)
x1 := pb_classic_xref(&pb)
pb_trailer(&pb, "<< /Size 4 /Root 1 0 R >>", x1)
// Generation two: object 3 is rewritten with a different page size, and
// object 4 appears for the first time.
pb_obj(&pb, 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] >>")
pb_obj(&pb, 4, "<< /Type /ExtGState /LW 3 >>")
x2 := pb_xref_begin(&pb)
pb_xref_sub(&pb, 3, 2)
pb_entry(&pb, pb.offs[3], 0, true)
pb_entry(&pb, pb.offs[4], 0, true)
pb_str(&pb, "trailer\n<< /Size 5 /Root 1 0 R /Prev ")
pb_num(&pb, x1)
pb_str(&pb, " >>\n")
pb_startxref(&pb, x2)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
// The newest definition of object 3 wins ...
expect_page(t, doc, 3, 400)
// ... an object only the newest section has is there ...
testing.expect(t, dict_name(doc, get_dict(doc, Ref{4, 0}), "Type") == "ExtGState",
"object added by the update is missing")
// ... and objects only the first generation defined are still reachable.
testing.expect(t, doc.catalog != nil, "catalog from the older section was lost")
testing.expect(t, dict_name(doc, get_dict(doc, Ref{2, 0}), "Type") == "Pages",
"object from the older section was lost")
}
@(test)
test_xref_trailer_merge_keeps_older_keys :: proc(t: ^testing.T) {
pb := pb_make()
pb_skeleton(&pb)
pb_obj(&pb, 4, "<< /Producer (first) >>")
x1 := pb_classic_xref(&pb)
pb_trailer(&pb, "<< /Size 5 /Root 1 0 R /Info 4 0 R /ID [<01> <02>] >>", x1)
pb_obj(&pb, 5, "<< /Type /ExtGState >>")
x2 := pb_xref_begin(&pb)
pb_xref_sub(&pb, 5, 1)
pb_entry(&pb, pb.offs[5], 0, true)
// The newest trailer omits /Info entirely, which is legal and common.
pb_str(&pb, "trailer\n<< /Size 6 /Root 1 0 R /ID [<03> <04>] /Prev ")
pb_num(&pb, x1)
pb_str(&pb, " >>\n")
pb_startxref(&pb, x2)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
info := dict_dict(doc, doc.trailer, "Info")
testing.expect(t, info != nil, "/Info from the older trailer was dropped")
id := dict_array(doc, doc.trailer, "ID")
first, ok := to_bytes(array_get(doc, id, 0))
testing.expect(t, ok && len(first) == 1 && first[0] == 0x03,
"the newest /ID did not win the merge")
testing.expectf(t, dict_int(doc, doc.trailer, "Size", 0) == 6,
"merged /Size %v, wanted 6", dict_int(doc, doc.trailer, "Size", 0))
}
@(test)
test_xref_free_entry_shadows_older :: proc(t: ^testing.T) {
// An incremental update that deletes an object marks it free. The older
// section still has an in-use entry, and it must not win.
pb := pb_make()
pb_skeleton(&pb)
pb_obj(&pb, 4, "<< /Type /ExtGState >>")
x1 := pb_classic_xref(&pb)
pb_trailer(&pb, "<< /Size 5 /Root 1 0 R >>", x1)
x2 := pb_xref_begin(&pb)
pb_xref_sub(&pb, 4, 1)
pb_entry(&pb, 0, 1, false)
pb_str(&pb, "trailer\n<< /Size 5 /Root 1 0 R /Prev ")
pb_num(&pb, x1)
pb_str(&pb, " >>\n")
pb_startxref(&pb, x2)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expect(t, doc.xref[4].kind == .Free, "deleted object was resurrected")
testing.expect(t, get_object(doc, Ref{4, 0}) == nil, "deleted object still resolves")
}
// ---------------------------------------------------------------------------
// The off-by-one subsection header
// ---------------------------------------------------------------------------
@(test)
test_xref_subsection_declared_one_starts_at_zero :: proc(t: ^testing.T) {
// "1 4" over entries that really begin with the free-list head. Trusting
// the header shifts every object number by one.
pb := pb_make()
pb_skeleton(&pb)
at := pb_classic_xref_ex(&pb, 1, 0)
pb_trailer(&pb, "<< /Size 4 /Root 1 0 R >>", at)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expect(t, doc.catalog != nil, "catalog not found after the shift was repaired")
testing.expect(t, dict_name(doc, get_dict(doc, Ref{1, 0}), "Type") == "Catalog",
"object 1 is not the catalog")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_subsection_declared_zero_starts_at_one :: proc(t: ^testing.T) {
// The mirror image: "0 3" over entries that begin at object 1. Object 0
// can never be in use, which is what gives it away.
pb := pb_make()
pb_skeleton(&pb)
at := pb_classic_xref_ex(&pb, 0, 1)
pb_trailer(&pb, "<< /Size 4 /Root 1 0 R >>", at)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expect(t, dict_name(doc, get_dict(doc, Ref{1, 0}), "Type") == "Catalog",
"object 1 is not the catalog")
expect_page(t, doc, 3, 200)
}
// ---------------------------------------------------------------------------
// Cross-reference streams
// ---------------------------------------------------------------------------
@(private = "file")
build_xref_stream_file :: proc(predictor: bool) -> Pdf_Builder {
pb := pb_make("%PDF-1.5\n")
pb_skeleton(&pb)
at := pb_here(&pb)
rows := [5]Xref_Row{
{0, 0, 65535},
{1, u64(pb.offs[1]), 0},
{1, u64(pb.offs[2]), 0},
{1, u64(pb.offs[3]), 0},
{1, u64(at), 0},
}
raw := xref_rows_encode(rows[:], 1, 4, 2)
defer delete(raw)
if predictor {
pred := png_up_encode(raw, 7)
defer delete(pred)
payload := zlib_stored(pred)
defer delete(payload)
pb_stream_obj(&pb, 4,
"/Type /XRef /Size 5 /W [1 4 2] /Root 1 0 R /Filter /FlateDecode " +
"/DecodeParms << /Predictor 12 /Columns 7 >>", payload)
} else {
pb_stream_obj(&pb, 4, "/Type /XRef /Size 5 /W [1 4 2] /Root 1 0 R", raw)
}
pb_startxref(&pb, at)
return pb
}
@(test)
test_xref_stream_plain :: proc(t: ^testing.T) {
pb := build_xref_stream_file(false)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expect(t, !doc.is_damaged, "a well-formed xref stream recorded a repair")
testing.expect(t, doc.catalog != nil, "no catalog")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_stream_png_predictor :: proc(t: ^testing.T) {
pb := build_xref_stream_file(true)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expect(t, !doc.is_damaged, "a well-formed xref stream recorded a repair")
expect_page(t, doc, 3, 200)
testing.expect(t, doc.xref[4].kind == .Normal, "the xref stream's own entry is missing")
}
@(test)
test_xref_stream_zero_width_type_field :: proc(t: ^testing.T) {
// "/W [0 2 1]": the type field is not stored and defaults to 1, which is
// how a file with no free and no compressed objects saves a byte a row.
pb := pb_make("%PDF-1.5\n")
pb_skeleton(&pb)
at := pb_here(&pb)
rows := [5]Xref_Row{
{0, 0, 0},
{0, u64(pb.offs[1]), 0},
{0, u64(pb.offs[2]), 0},
{0, u64(pb.offs[3]), 0},
{0, u64(at), 0},
}
raw := xref_rows_encode(rows[:], 0, 2, 1)
defer delete(raw)
pb_stream_obj(&pb, 4, "/Type /XRef /Size 5 /W [0 2 1] /Root 1 0 R /Index [1 4]", raw[3:])
pb_startxref(&pb, at)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expect(t, doc.xref[1].kind == .Normal, "type defaulted wrong")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_stream_index_clamped_to_data :: proc(t: ^testing.T) {
// /Index promises far more entries than the stream holds. The extra rows
// must be dropped, not read off the end of the buffer.
pb := pb_make("%PDF-1.5\n")
pb_skeleton(&pb)
at := pb_here(&pb)
rows := [5]Xref_Row{
{0, 0, 65535},
{1, u64(pb.offs[1]), 0},
{1, u64(pb.offs[2]), 0},
{1, u64(pb.offs[3]), 0},
{1, u64(at), 0},
}
raw := xref_rows_encode(rows[:], 1, 4, 2)
defer delete(raw)
pb_stream_obj(&pb, 4, "/Type /XRef /Size 5 /W [1 4 2] /Root 1 0 R /Index [0 900000]", raw)
pb_startxref(&pb, at)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
expect_page(t, doc, 3, 200)
testing.expect(t, object_count(doc) < 900000, "the table grew to a fabricated /Index count")
}
@(test)
test_xref_absurd_size_is_clamped :: proc(t: ^testing.T) {
pb := pb_make()
pb_skeleton(&pb)
at := pb_classic_xref(&pb)
pb_trailer(&pb, "<< /Size 2000000000 /Root 1 0 R >>", at)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
// The floor in xref_max_objects is 1024 entries, so a tiny file with a
// preposterous /Size costs 24 KiB rather than 48 GB.
testing.expectf(t, object_count(doc) <= 1024,
"table grew to %v for a %v byte file", object_count(doc), len(pb.buf))
expect_page(t, doc, 3, 200)
}
// ---------------------------------------------------------------------------
// Hybrid-reference files
// ---------------------------------------------------------------------------
@(test)
test_xref_hybrid_xrefstm :: proc(t: ^testing.T) {
// The classic table hides object 3 by marking it free; the stream named by
// /XRefStm says it lives inside object stream 4. A reader that lets the
// classic entry win loses the page.
pb := pb_make("%PDF-1.5\n")
pb_obj(&pb, 1, "<< /Type /Catalog /Pages 2 0 R >>")
pb_obj(&pb, 2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
bodies := [1]string{"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>"}
nums := [1]u32{3}
payload, first := objstm_payload(nums[:], bodies[:])
defer delete(payload)
pb_mark(&pb, 3) // object 3 has no header of its own
pb.offs[3] = -1
pb_mark(&pb, 4)
pb_str(&pb, "4 0 obj\n<< /Type /ObjStm /N 1 /First ")
pb_num(&pb, first)
pb_str(&pb, " /Length ")
pb_num(&pb, len(payload))
pb_str(&pb, " >>\nstream\n")
pb_raw(&pb, payload)
pb_str(&pb, "\nendstream\nendobj\n")
stm_at := pb_here(&pb)
rows := [6]Xref_Row{
{0, 0, 65535},
{1, u64(pb.offs[1]), 0},
{1, u64(pb.offs[2]), 0},
{2, 4, 0},
{1, u64(pb.offs[4]), 0},
{1, u64(stm_at), 0},
}
raw := xref_rows_encode(rows[:], 1, 4, 2)
defer delete(raw)
pb_stream_obj(&pb, 5, "/Type /XRef /Size 6 /W [1 4 2] /Root 1 0 R", raw)
at := pb_xref_begin(&pb)
pb_xref_sub(&pb, 0, 6)
pb_entry(&pb, 0, 65535, false)
pb_entry(&pb, pb.offs[1], 0, true)
pb_entry(&pb, pb.offs[2], 0, true)
pb_entry(&pb, 0, 0, false) // object 3: hidden from old readers
pb_entry(&pb, pb.offs[4], 0, true)
pb_entry(&pb, pb.offs[5], 0, true)
pb_str(&pb, "trailer\n<< /Size 6 /Root 1 0 R /XRefStm ")
pb_num(&pb, stm_at)
pb_str(&pb, " >>\n")
pb_startxref(&pb, at)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expect(t, doc.xref[3].kind == .Compressed,
"the /XRefStm entry did not replace the classic free entry")
expect_page(t, doc, 3, 300)
}
// ---------------------------------------------------------------------------
// Loops and bad pointers
// ---------------------------------------------------------------------------
@(test)
test_xref_prev_loop_terminates :: proc(t: ^testing.T) {
pb := pb_make()
pb_skeleton(&pb)
xa := pb_xref_begin(&pb)
pb_xref_sub(&pb, 0, 4)
pb_entry(&pb, 0, 65535, false)
pb_entry(&pb, pb.offs[1], 0, true)
pb_entry(&pb, pb.offs[2], 0, true)
pb_entry(&pb, pb.offs[3], 0, true)
pb_str(&pb, "trailer\n<< /Size 4 /Root 1 0 R /Prev ")
hole := pb_placeholder(&pb)
pb_str(&pb, " >>\n")
xb := pb_xref_begin(&pb)
pb_xref_sub(&pb, 0, 1)
pb_entry(&pb, 0, 65535, false)
pb_str(&pb, "trailer\n<< /Size 4 /Root 1 0 R /Prev ")
pb_num(&pb, xa)
pb_str(&pb, " >>\n")
pb_startxref(&pb, xb)
pb_patch(&pb, hole, xb) // A points back at B: a two-node loop
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_startxref_past_eof_rebuilds :: proc(t: ^testing.T) {
pb := pb_make()
pb_skeleton(&pb)
at := pb_classic_xref(&pb)
_ = at
pb_trailer(&pb, "<< /Size 4 /Root 1 0 R >>", 99_000_000)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "a rebuild should have opened this file")
if doc == nil { return }
testing.expect(t, doc_has_repair(doc, .Rebuilt_Xref), "no rebuild was recorded")
testing.expect(t, doc.catalog != nil, "rebuild did not find the catalog")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_startxref_into_junk_rebuilds :: proc(t: ^testing.T) {
// startxref points into the middle of a stream's payload, which is what a
// file that was concatenated or partly re-saved looks like.
pb := pb_make()
pb_skeleton(&pb)
junk := make([]byte, 512)
defer delete(junk)
for i in 0 ..< len(junk) {
junk[i] = byte('a' + i % 26)
}
pb_stream_obj(&pb, 4, "/Type /Junk", junk)
at := pb_classic_xref(&pb)
_ = at
pb_trailer(&pb, "<< /Size 5 /Root 1 0 R >>", pb.offs[4] + 200)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "a rebuild should have opened this file")
if doc == nil { return }
testing.expect(t, doc_has_repair(doc, .Rebuilt_Xref), "no rebuild was recorded")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_missing_entirely_rebuilds :: proc(t: ^testing.T) {
pb := pb_make()
pb_skeleton(&pb)
pb_str(&pb, "trailer\n<< /Size 4 /Root 1 0 R >>\n%%EOF\n")
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "a rebuild should have opened this file")
if doc == nil { return }
testing.expect(t, doc_has_repair(doc, .Rebuilt_Xref), "no rebuild was recorded")
testing.expect(t, doc.catalog != nil, "rebuild did not find the catalog")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_rebuild_without_any_trailer :: proc(t: ^testing.T) {
// No xref, no trailer: the catalog has to be found by looking for
// /Type /Catalog.
pb := pb_make()
pb_skeleton(&pb)
pb_str(&pb, "%%EOF\n")
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "a rebuild should have opened this file")
if doc == nil { return }
testing.expect(t, doc.catalog != nil, "catalog was not found by scanning")
testing.expect(t, dict_name(doc, doc.catalog, "Type") == "Catalog", "wrong catalog")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_rebuild_prefers_later_definition :: proc(t: ^testing.T) {
// Two definitions of object 3 and no usable table. The one further down
// the file is the newer one.
pb := pb_make()
pb_skeleton(&pb)
pb_obj(&pb, 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 500 500] >>")
pb_str(&pb, "trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n7\n%%EOF\n")
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
expect_page(t, doc, 3, 500)
}
@(test)
test_xref_rebuild_skips_headers_inside_streams :: proc(t: ^testing.T) {
// A stream payload that spells out an object header must not be mistaken
// for one: doing so would point object 3 at the middle of a stream.
pb := pb_make()
pb_skeleton(&pb)
decoy := "3 0 obj << /Type /Page /MediaBox [0 0 999 999] >> endobj"
pb_stream_obj(&pb, 4, "/Type /Junk", transmute([]byte)decoy)
pb_str(&pb, "trailer\n<< /Size 5 /Root 1 0 R >>\n%%EOF\n")
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
expect_page(t, doc, 3, 200)
}
// ---------------------------------------------------------------------------
// Files with junk before %PDF-
// ---------------------------------------------------------------------------
@(test)
test_xref_header_offset :: proc(t: ^testing.T) {
// A mail header, an HTTP response or a self-extracting wrapper in front of
// the file. Every offset in it is measured from "%PDF-", not from zero.
inner := pb_sample_classic()
defer pb_destroy(&inner)
junk := "Received: by 10.0.0.1 with SMTP id x1234567890abcdefghijklmnop;\r\n" +
"Content-Type: application/pdf\r\n\r\n"
pb := pb_make(junk)
defer pb_destroy(&pb)
pb_raw(&pb, inner.buf[:])
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expectf(t, doc.header_offset == i64(len(junk)),
"header_offset %v, wanted %v", doc.header_offset, len(junk))
testing.expect(t, doc.catalog != nil, "no catalog")
expect_page(t, doc, 3, 200)
}
// ---------------------------------------------------------------------------
// Malformed input must not crash or hang
// ---------------------------------------------------------------------------
@(test)
test_xref_truncation_survives :: proc(t: ^testing.T) {
pb := build_xref_stream_file(true)
defer pb_destroy(&pb)
for cut := 1; cut <= len(pb.buf); cut += 7 {
doc, _ := open_bytes(pb.buf[:cut], Open_Options{skip_page_tree = true})
if doc != nil {
// Touching every object is what a caller would do next.
for num in 1 ..< object_count(doc) {
_ = get_object(doc, Ref{u32(num), 0})
}
close(doc)
}
}
testing.expect(t, true, "truncation must never crash or hang")
}
@(test)
test_xref_byte_flips_survive :: proc(t: ^testing.T) {
base := pb_sample_classic()
defer pb_destroy(&base)
scratch := make([]byte, len(base.buf))
defer delete(scratch)
for at := 0; at < len(base.buf); at += 5 {
copy(scratch, base.buf[:])
scratch[at] ~= 0xff
doc, _ := open_bytes(scratch, Open_Options{skip_page_tree = true})
if doc != nil {
for num in 1 ..< object_count(doc) {
_ = get_object(doc, Ref{u32(num), 0})
}
close(doc)
}
}
testing.expect(t, true, "a flipped byte must never crash or hang")
}
@(test)
test_xref_empty_and_tiny_inputs :: proc(t: ^testing.T) {
cases := [?]string{
"",
"%",
"%PDF-1.7\n",
"%PDF-1.7\nstartxref\n",
"%PDF-1.7\nstartxref\n0\n%%EOF\n",
"%PDF-1.7\nxref\ntrailer\n",
"%PDF-1.7\n1 0 obj\nendobj\n",
"startxref\n4\n%%EOF",
"xref\n0 4294967295\n",
}
for src in cases {
doc, err := open_bytes(transmute([]byte)src, Open_Options{skip_page_tree = true})
if doc != nil {
close(doc)
} else {
testing.expect(t, err != .None, "a nil document must come with an error")
}
}
}
// ---------------------------------------------------------------------------
// Unit tests for the scanning primitives
// ---------------------------------------------------------------------------
@(test)
test_xref_find_token_is_whole_word :: proc(t: ^testing.T) {
data := transmute([]byte)string("endobj obj objx 1 0 obj")
// "obj" inside "endobj" and inside "objx" must not match.
at := xref_find_token(data, 0, "obj")
testing.expectf(t, at == 7, "found obj at %v, wanted 7", at)
at2 := xref_find_token(data, 8, "obj")
testing.expectf(t, at2 == 20, "found obj at %v, wanted 20", at2)
testing.expect(t, xref_find_token(data, 0, "stream") == -1, "matched a keyword that is absent")
stream := transmute([]byte)string("endstream stream")
testing.expectf(t, xref_find_token(stream, 0, "stream") == 10,
"\"stream\" matched inside \"endstream\"")
}
@(test)
test_xref_header_before :: proc(t: ^testing.T) {
Case :: struct {
src: string,
num: u32,
gen: u16,
ok: bool,
}
cases := [?]Case{
{"12 0 obj", 12, 0, true},
{" 7 65535 obj", 7, 65535, true},
{"3\t2\nobj", 3, 2, true},
{"x12 0 obj", 0, 0, false}, // glued to a regular character
{"0 0 obj", 0, 0, false}, // object 0 cannot be defined
{"obj", 0, 0, false},
{"12 obj", 0, 0, false}, // handled elsewhere, not here
{"12 0 999999 obj", 0, 0, false}, // generation out of range
}
for c in cases {
data := transmute([]byte)c.src
at := xref_find_token(data, 0, "obj")
if at < 0 {
testing.expect(t, !c.ok, "no obj keyword in a case that expected one")
continue
}
num, gen, _, ok := xref_header_before(data, at)
testing.expectf(t, ok == c.ok, "%q: ok %v, wanted %v", c.src, ok, c.ok)
if ok && c.ok {
testing.expectf(t, num == c.num && gen == c.gen,
"%q: got %v %v, wanted %v %v", c.src, num, gen, c.num, c.gen)
}
}
}
@(test)
test_xref_read_field_big_endian :: proc(t: ^testing.T) {
data := [?]byte{0x01, 0x02, 0x03, 0x04, 0x05}
testing.expect(t, xref_read_field(data[:], 0, 0) == 0, "a zero-width field is zero")
testing.expect(t, xref_read_field(data[:], 0, 1) == 0x01, "one byte")
testing.expect(t, xref_read_field(data[:], 1, 2) == 0x0203, "two bytes")
testing.expect(t, xref_read_field(data[:], 1, 4) == 0x02030405, "four bytes")
// Reading past the end stops rather than wandering off the buffer.
testing.expect(t, xref_read_field(data[:], 3, 8) == 0x0405, "clamped at the end")
}
@(test)
test_xref_digits :: proc(t: ^testing.T) {
v, ok := xref_digits(transmute([]byte)string("00123"))
testing.expect(t, ok && v == 123, "leading zeros")
_, long_ok := xref_digits(transmute([]byte)string("1234567890123456"))
testing.expect(t, !long_ok, "an absurdly long run is not an object number")
_, empty_ok := xref_digits(nil)
testing.expect(t, !empty_ok, "an empty run is not a number")
}
@(test)
test_xref_previous_startxref_rescues :: proc(t: ^testing.T) {
// A tool that crashed part way through an incremental update leaves a
// startxref pointing at nothing, with the previous one still intact. That
// is a far better table than anything a rebuild can reconstruct.
pb := pb_sample_classic()
defer pb_destroy(&pb)
pb_str(&pb, "startxref\n99000000\n%%EOF\n")
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expect(t, doc_has_repair(doc, .Bad_Startxref), "the dead startxref was not noticed")
testing.expect(t, !doc_has_repair(doc, .Rebuilt_Xref),
"the older startxref should have been used instead of a rebuild")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_trailer_keyword_missing :: proc(t: ^testing.T) {
// Some producers write the dictionary straight after the table.
pb := pb_make()
pb_skeleton(&pb)
at := pb_classic_xref(&pb)
pb_str(&pb, "<< /Size 4 /Root 1 0 R >>\n")
pb_startxref(&pb, at)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expect(t, doc.catalog != nil, "no catalog")
testing.expect(t, !doc_has_repair(doc, .Rebuilt_Xref), "should not have needed a rebuild")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_subsection_count_absurd :: proc(t: ^testing.T) {
// "0 900000000" over four entries. The count has to be clamped to what the
// file can hold, and the entries after the table must not be eaten.
pb := pb_make()
pb_skeleton(&pb)
at := pb_xref_begin(&pb)
pb_xref_sub(&pb, 0, 900_000_000)
pb_entry(&pb, 0, 65535, false)
pb_entry(&pb, pb.offs[1], 0, true)
pb_entry(&pb, pb.offs[2], 0, true)
pb_entry(&pb, pb.offs[3], 0, true)
pb_str(&pb, "trailer\n<< /Size 4 /Root 1 0 R >>\n")
pb_startxref(&pb, at)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
expect_page(t, doc, 3, 200)
testing.expectf(t, object_count(doc) <= 1024, "table grew to %v", object_count(doc))
}
@(test)
test_xref_stream_without_w_falls_back :: proc(t: ^testing.T) {
// No /W at all: the rows cannot be decoded, so the whole section is
// unusable and the file has to be rebuilt rather than half-read.
pb := pb_make("%PDF-1.5\n")
pb_skeleton(&pb)
at := pb_here(&pb)
payload := [?]byte{0, 0, 0, 0, 0, 0, 0}
pb_stream_obj(&pb, 4, "/Type /XRef /Size 5 /Root 1 0 R", payload[:])
pb_startxref(&pb, at)
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "a rebuild should have opened this file")
if doc == nil { return }
testing.expect(t, doc_has_repair(doc, .Rebuilt_Xref), "no rebuild was recorded")
expect_page(t, doc, 3, 200)
}
@(test)
test_xref_many_objects :: proc(t: ^testing.T) {
// Enough objects that the table is regrown many times over.
N :: 1500
pb := pb_make()
defer pb_destroy(&pb)
pb_skeleton(&pb)
for i in 4 ..= u32(N) {
pb_obj(&pb, i, "<< /Type /Junk >>")
}
at := pb_classic_xref(&pb)
pb_trailer(&pb, "<< /Size 1501 /Root 1 0 R >>", at)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expectf(t, object_count(doc) == N + 1, "object_count %v, wanted %v",
object_count(doc), N + 1)
n_found := 0
for i in 4 ..= u32(N) {
if dict_name(doc, get_dict(doc, Ref{i, 0}), "Type") == "Junk" {
n_found += 1
}
}
testing.expectf(t, n_found == N - 3, "resolved %v of %v objects", n_found, N - 3)
}
@(test)
test_xref_object_read_during_load_is_not_cached :: proc(t: ^testing.T) {
// An older section's cross-reference stream gives its /Length indirectly.
// Resolving that reference while the table is still half built reads the
// object as null -- correctly, there is nothing there yet -- and caching
// that answer would make it null for the life of the Document, long after
// the section that defines it has been read.
pb := pb_make("%PDF-1.5\n")
defer pb_destroy(&pb)
pb_skeleton(&pb)
// The section below is one row wide, so this is also the honest /Length.
pb_obj(&pb, 7, "7")
// The older section, covering object 7 alone.
xb := pb_here(&pb)
row_b := [1]Xref_Row{{1, u64(pb.offs[7]), 0}}
raw_b := xref_rows_encode(row_b[:], 1, 4, 2)
defer delete(raw_b)
pb_mark(&pb, 6)
pb_str(&pb, "6 0 obj\n<< /Type /XRef /Size 10 /W [1 4 2] /Index [7 1] /Root 1 0 R /Length 7 0 R >>\nstream\n")
pb_raw(&pb, raw_b)
pb_str(&pb, "\nendstream\nendobj\n")
pb_obj(&pb, 9, "<< /Type /Junk >>")
// The newest section covers 0..6 and 8..9, deliberately skipping object 7
// so that the older section is the only place it is defined.
xa := pb_here(&pb)
rows_a := [9]Xref_Row{
{0, 0, 65535},
{1, u64(pb.offs[1]), 0},
{1, u64(pb.offs[2]), 0},
{1, u64(pb.offs[3]), 0},
{0, 0, 0},
{0, 0, 0},
{1, u64(xb), 0},
{1, u64(pb.offs[9]), 0},
{1, u64(xa), 0},
}
raw_a := xref_rows_encode(rows_a[:], 1, 4, 2)
defer delete(raw_a)
pb_mark(&pb, 8)
pb_str(&pb, "8 0 obj\n<< /Type /XRef /Size 10 /W [1 4 2] /Index [0 7 8 2] /Root 1 0 R /Prev ")
pb_num(&pb, xb)
pb_str(&pb, " /Length ")
pb_num(&pb, len(raw_a))
pb_str(&pb, " >>\nstream\n")
pb_raw(&pb, raw_a)
pb_str(&pb, "\nendstream\nendobj\n")
pb_startxref(&pb, xa)
doc, err := pb_open(&pb)
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
testing.expect(t, doc.xref[7].kind == .Normal,
"object 7 should come from the older section")
v, ok := to_int(get_object(doc, Ref{7, 0}))
testing.expectf(t, ok && v == i64(len(raw_b)),
"object 7 read as %v (ok %v), wanted %v", v, ok, len(raw_b))
expect_page(t, doc, 3, 200)
}