Files

863 lines
32 KiB
Odin
Raw Permalink Normal View History

2026-09-25 23:32:29 +02:00
package pdf
import "core:fmt"
import "core:strings"
import "core:testing"
// ---------------------------------------------------------------------------
// Outline, destinations and page labels
//
// These build whole files rather than Documents by hand: an outline is a
// linked structure spread over a dozen objects, and the cases worth testing --
// a /Next that loops, a destination naming a deleted page -- only exist once
// the references are real.
// ---------------------------------------------------------------------------
// ol_finish closes off a file whose objects are already written, with object 1
// as the catalog.
@(private = "file")
ol_finish :: proc(pb: ^Pdf_Builder) {
at := pb_classic_xref(pb)
pb_str(pb, "trailer\n<< /Size ")
pb_num(pb, len(pb.offs))
pb_str(pb, " /Root 1 0 R >>\n")
pb_startxref(pb, at)
}
// ol_pages writes the page tree every test here shares: three pages, objects
// 3, 4 and 5. `catalog_extra` goes into the catalog dictionary.
@(private = "file")
ol_pages :: proc(pb: ^Pdf_Builder, catalog_extra: string) {
pb_obj(pb, 1, fmt.tprintf("<< /Type /Catalog /Pages 2 0 R %s >>", catalog_extra))
pb_obj(pb, 2, "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>")
pb_obj(pb, 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>")
pb_obj(pb, 4, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>")
pb_obj(pb, 5, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>")
}
// ol_titles joins the titles of one level, so a test can state the order it
// expects as one string instead of a loop of index assertions.
@(private = "file")
ol_titles :: proc(items: []Outline_Item, allocator := context.allocator) -> string {
b := strings.builder_make(0, 32, allocator)
for it, i in items {
if i > 0 {
strings.write_byte(&b, ',')
}
strings.write_string(&b, it.title)
}
return strings.to_string(b)
}
@(private = "file")
ol_expect_titles :: proc(t: ^testing.T, items: []Outline_Item, want: string, where_: string) {
got := ol_titles(items)
defer delete(got)
testing.expectf(t, got == want, "%s: titles %q, wanted %q", where_, got, want)
}
// ---------------------------------------------------------------------------
// The tree
// ---------------------------------------------------------------------------
@(test)
test_outline_three_levels :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Outlines 10 0 R")
pb_obj(&pb, 10, "<< /Type /Outlines /First 11 0 R /Last 12 0 R /Count 2 >>")
pb_obj(&pb, 11, "<< /Title (One) /Parent 10 0 R /Next 12 0 R /Count 1 " +
"/First 13 0 R /Last 13 0 R /Dest [3 0 R /Fit] >>")
pb_obj(&pb, 12, "<< /Title (Two) /Parent 10 0 R /Prev 11 0 R /Count -1 " +
"/First 16 0 R /Last 16 0 R /Dest [4 0 R /XYZ 10 20 0] >>")
pb_obj(&pb, 13, "<< /Title (One.A) /Parent 11 0 R /Next 14 0 R /Dest [5 0 R /FitH 700] >>")
pb_obj(&pb, 14, "<< /Title (One.B) /Parent 11 0 R /Prev 13 0 R /Count 1 /First 15 0 R /Last 15 0 R >>")
pb_obj(&pb, 15, "<< /Title (One.B.i) /Parent 14 0 R >>")
pb_obj(&pb, 16, "<< /Title (Two.A) /Parent 12 0 R >>")
ol_finish(&pb)
doc, err := open_bytes(pb.buf[:])
defer close(doc)
testing.expect(t, err == .None, "open failed")
if doc == nil { return }
items, oerr := outline_load(doc)
defer outline_destroy(items)
testing.expect(t, oerr == .None, "outline_load failed")
ol_expect_titles(t, items, "One,Two", "top level")
if len(items) != 2 { return }
// /Count 1 is open, /Count -1 is the same subtree collapsed.
testing.expect(t, items[0].open, "One should be open")
testing.expect(t, !items[1].open, "Two should be closed")
// The linked chain 13 -> 14 is two children even though /Last names only
// the first: a walk that trusted /Last would stop one short.
ol_expect_titles(t, items[0].children, "One.A,One.B", "One's children")
ol_expect_titles(t, items[1].children, "Two.A", "Two's children")
if len(items[0].children) != 2 { return }
ol_expect_titles(t, items[0].children[1].children, "One.B.i", "level three")
testing.expectf(t, items[0].page == 0, "One page %v, wanted 0", items[0].page)
testing.expectf(t, items[1].page == 1, "Two page %v, wanted 1", items[1].page)
testing.expectf(t, items[0].children[0].page == 2,
"One.A page %v, wanted 2", items[0].children[0].page)
testing.expect(t, items[0].dest.kind == .Fit, "One's destination is not /Fit")
testing.expect(t, items[0].children[0].dest.kind == .Fit_H, "One.A's destination is not /FitH")
testing.expectf(t, items[0].children[0].dest.args[0] == 700,
"FitH top %v, wanted 700", items[0].children[0].dest.args[0])
// An item with no /Dest and no /A has no page.
testing.expectf(t, items[0].children[1].page == -1,
"One.B page %v, wanted -1", items[0].children[1].page)
}
@(test)
test_outline_next_cycle_terminates :: proc(t: ^testing.T) {
// 11 -> 12 -> 11. A walk without a visited set never returns from this.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Outlines 10 0 R")
pb_obj(&pb, 10, "<< /Type /Outlines /First 11 0 R /Last 12 0 R >>")
pb_obj(&pb, 11, "<< /Title (A) /Next 12 0 R >>")
pb_obj(&pb, 12, "<< /Title (B) /Next 11 0 R >>")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
items, oerr := outline_load(doc)
defer outline_destroy(items)
testing.expect(t, oerr == .None, "outline_load failed")
ol_expect_titles(t, items, "A,B", "cycle")
testing.expect(t, doc.is_damaged, "a cyclic /Next recorded no repair")
}
@(test)
test_outline_first_cycle_terminates :: proc(t: ^testing.T) {
// /First pointing back at an ancestor: the same trap one level down.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Outlines 10 0 R")
pb_obj(&pb, 10, "<< /Type /Outlines /First 11 0 R >>")
pb_obj(&pb, 11, "<< /Title (A) /First 12 0 R >>")
pb_obj(&pb, 12, "<< /Title (B) /First 11 0 R >>")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
items, _ := outline_load(doc)
defer outline_destroy(items)
ol_expect_titles(t, items, "A", "root")
if len(items) != 1 { return }
ol_expect_titles(t, items[0].children, "B", "A's children")
if len(items[0].children) != 1 { return }
testing.expectf(t, len(items[0].children[0].children) == 0,
"B has %v children, wanted 0", len(items[0].children[0].children))
}
@(test)
test_outline_self_next :: proc(t: ^testing.T) {
// The tightest loop there is: an item whose /Next is itself.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Outlines 10 0 R")
pb_obj(&pb, 10, "<< /Type /Outlines /First 11 0 R >>")
pb_obj(&pb, 11, "<< /Title (A) /Next 11 0 R >>")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
items, _ := outline_load(doc)
defer outline_destroy(items)
ol_expect_titles(t, items, "A", "self-linked item")
}
@(test)
test_outline_first_not_a_dict :: proc(t: ^testing.T) {
// /First as a number, as a reference to a number, and as a reference to
// nothing at all. None of the three is an outline item.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Outlines 10 0 R")
pb_obj(&pb, 10, "<< /Type /Outlines /First 42 /Last 42 /Count 3 >>")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
items, oerr := outline_load(doc)
defer outline_destroy(items)
testing.expect(t, oerr == .None, "a broken /First is not an error")
testing.expectf(t, len(items) == 0, "got %v items from a numeric /First", len(items))
pb2 := pb_make()
defer pb_destroy(&pb2)
ol_pages(&pb2, "/Outlines 10 0 R")
pb_obj(&pb2, 10, "<< /Type /Outlines /First 11 0 R >>")
pb_obj(&pb2, 11, "<< /Title (A) /First 12 0 R /Next 13 0 R >>")
pb_obj(&pb2, 12, "7")
pb_obj(&pb2, 13, "<< /Title (B) /First 99 0 R >>")
ol_finish(&pb2)
doc2, _ := open_bytes(pb2.buf[:])
defer close(doc2)
if doc2 == nil { return }
items2, _ := outline_load(doc2)
defer outline_destroy(items2)
ol_expect_titles(t, items2, "A,B", "siblings past a broken /First")
if len(items2) != 2 { return }
testing.expectf(t, len(items2[0].children) == 0,
"A has %v children from an integer /First", len(items2[0].children))
testing.expectf(t, len(items2[1].children) == 0,
"B has %v children from a dangling /First", len(items2[1].children))
}
@(test)
test_outline_missing_and_broken_roots :: proc(t: ^testing.T) {
// No /Outlines at all: an empty outline, and not an error.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
items, err := outline_load(doc)
defer outline_destroy(items)
testing.expect(t, err == .None, "a document with no outline reported an error")
testing.expectf(t, len(items) == 0, "got %v items from no outline", len(items))
// /Outlines pointing at an array.
pb2 := pb_make()
defer pb_destroy(&pb2)
ol_pages(&pb2, "/Outlines 10 0 R")
pb_obj(&pb2, 10, "[1 2 3]")
ol_finish(&pb2)
doc2, _ := open_bytes(pb2.buf[:])
defer close(doc2)
if doc2 == nil { return }
items2, err2 := outline_load(doc2)
defer outline_destroy(items2)
testing.expect(t, err2 == .None, "a non-dictionary /Outlines is not an error")
testing.expectf(t, len(items2) == 0, "got %v items from an array /Outlines", len(items2))
nil_items, nil_err := outline_load(nil)
testing.expect(t, nil_err == .Invalid_Argument, "outline_load(nil) should be a caller error")
testing.expect(t, nil_items == nil, "outline_load(nil) returned items")
outline_destroy(nil)
}
@(test)
test_outline_depth_capped :: proc(t: ^testing.T) {
// A chain of 200 nested /First links. The walk must stop at
// MAX_OUTLINE_DEPTH rather than recursing to the bottom.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Outlines 10 0 R")
pb_obj(&pb, 10, "<< /Type /Outlines /First 11 0 R >>")
for i in 0 ..< 200 {
num := u32(11 + i)
if i == 199 {
pb_obj(&pb, num, fmt.tprintf("<< /Title (L%d) >>", i))
} else {
pb_obj(&pb, num, fmt.tprintf("<< /Title (L%d) /First %d 0 R >>", i, num + 1))
}
}
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
items, _ := outline_load(doc)
defer outline_destroy(items)
depth := 0
level := items
for len(level) > 0 && depth < 1000 {
depth += 1
level = level[0].children
}
testing.expectf(t, depth == MAX_OUTLINE_DEPTH,
"walked %v levels, wanted the cap of %v", depth, MAX_OUTLINE_DEPTH)
testing.expectf(t, depth < 200, "the depth cap did not apply: %v levels", depth)
}
@(test)
test_outline_titles_decoded :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Outlines 10 0 R")
pb_obj(&pb, 10, "<< /Type /Outlines /First 11 0 R >>")
// UTF-16BE with a byte order mark: "Hi".
pb_obj(&pb, 11, "<< /Title <FEFF00480069> /Next 12 0 R >>")
// 0x92 in PDFDocEncoding is a trademark sign, not the Latin-1 character.
pb_obj(&pb, 12, "<< /Title (a\\222b) /Next 13 0 R >>")
// No /Title at all, and a /Title that is not a string.
pb_obj(&pb, 13, "<< /Next 14 0 R >>")
pb_obj(&pb, 14, "<< /Title /NotAString >>")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
items, _ := outline_load(doc)
defer outline_destroy(items)
testing.expectf(t, len(items) == 4, "got %v items, wanted 4", len(items))
if len(items) != 4 { return }
testing.expectf(t, items[0].title == "Hi", "UTF-16 title %q", items[0].title)
testing.expectf(t, items[1].title == "a™b", "PDFDoc title %q", items[1].title)
testing.expectf(t, items[2].title == "", "missing title %q", items[2].title)
testing.expectf(t, items[3].title == "", "non-string title %q", items[3].title)
}
@(test)
test_outline_action_destinations :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Outlines 10 0 R")
pb_obj(&pb, 10, "<< /Type /Outlines /First 11 0 R >>")
// A /GoTo action rather than a /Dest.
pb_obj(&pb, 11, "<< /Title (A) /Next 12 0 R /A << /S /GoTo /D [4 0 R /Fit] >> >>")
// A /URI action goes nowhere in this document.
pb_obj(&pb, 12, "<< /Title (B) /Next 13 0 R /A << /S /URI /URI (http://example.com) >> >>")
// Both: /Dest wins (ISO 32000-2, 12.3.3).
pb_obj(&pb, 13, "<< /Title (C) /Next 14 0 R /Dest [5 0 R /Fit] /A << /S /GoTo /D [3 0 R /Fit] >> >>")
// A /GoToR destination is a page of another file. Taking its /D as a page
// of this one would send the reader somewhere arbitrary.
pb_obj(&pb, 14, "<< /Title (D) /A << /S /GoToR /F (other.pdf) /D [1 /Fit] >> >>")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
items, _ := outline_load(doc)
defer outline_destroy(items)
testing.expectf(t, len(items) == 4, "got %v items, wanted 4", len(items))
if len(items) != 4 { return }
testing.expectf(t, items[0].page == 1, "/GoTo action page %v, wanted 1", items[0].page)
testing.expectf(t, items[1].page == -1, "/URI action page %v, wanted -1", items[1].page)
testing.expectf(t, items[2].page == 2, "/Dest should win, page %v, wanted 2", items[2].page)
testing.expectf(t, items[3].page == -1, "/GoToR action page %v, wanted -1", items[3].page)
}
// ---------------------------------------------------------------------------
// Destinations
// ---------------------------------------------------------------------------
// ol_dest_doc builds a file whose object 30 is a dictionary of destinations to
// be resolved by key, so one document serves a table of cases.
@(private = "file")
ol_dest_doc :: proc(pb: ^Pdf_Builder, catalog_extra, dests_body: string) -> (^Document, ^Dict) {
ol_pages(pb, catalog_extra)
pb_obj(pb, 30, dests_body)
ol_finish(pb)
doc, _ := open_bytes(pb.buf[:])
if doc == nil { return nil, nil }
return doc, get_dict(doc, Ref{30, 0})
}
@(test)
test_dest_explicit_array :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
doc, cases := ol_dest_doc(&pb, "", `<<
/a [3 0 R /XYZ 10 20 1.5]
/b [4 0 R /Fit]
/c [5 0 R /FitR 1 2 3 4]
/d [4 0 R /FitBV 90]
/e [5 0 R]
/f [4 0 R /Bogus 1 2]
/g [2 0 R /Fit]
/h []
>>`)
defer close(doc)
if doc == nil || cases == nil { return }
d, ok := dest_resolve(doc, dict_get(doc, cases, "a"))
testing.expect(t, ok, "/XYZ did not resolve")
testing.expectf(t, d.page == 0 && d.kind == .XYZ, "XYZ page %v kind %v", d.page, d.kind)
testing.expectf(t, d.args == [4]f64{10, 20, 1.5, 0}, "XYZ args %v", d.args)
testing.expectf(t, d.has == [4]bool{true, true, true, false}, "XYZ has %v", d.has)
d, ok = dest_resolve(doc, dict_get(doc, cases, "b"))
testing.expect(t, ok && d.page == 1 && d.kind == .Fit, "/Fit did not resolve")
testing.expectf(t, d.has == [4]bool{}, "/Fit takes no arguments, has %v", d.has)
d, _ = dest_resolve(doc, dict_get(doc, cases, "c"))
testing.expectf(t, d.kind == .Fit_R && d.args == [4]f64{1, 2, 3, 4},
"/FitR kind %v args %v", d.kind, d.args)
testing.expectf(t, d.has == [4]bool{true, true, true, true}, "/FitR has %v", d.has)
d, _ = dest_resolve(doc, dict_get(doc, cases, "d"))
testing.expectf(t, d.kind == .Fit_BV && d.args[0] == 90 && d.has[0] && !d.has[1],
"/FitBV kind %v args %v has %v", d.kind, d.args, d.has)
// A page and no view name: the page, with the view left alone.
d, ok = dest_resolve(doc, dict_get(doc, cases, "e"))
testing.expect(t, ok, "a one-element destination did not resolve")
testing.expectf(t, d.page == 2 && d.kind == .XYZ && d.has == [4]bool{},
"bare page destination %v", d)
// An unknown view name is not a reason to lose the page.
d, ok = dest_resolve(doc, dict_get(doc, cases, "f"))
testing.expect(t, ok, "an unknown view name failed the whole destination")
testing.expectf(t, d.page == 1 && d.has == [4]bool{}, "unknown view %v", d)
// A reference to something that is not a page.
d, _ = dest_resolve(doc, dict_get(doc, cases, "g"))
testing.expectf(t, d.page == -1, "the page tree root resolved to page %v", d.page)
_, ok = dest_resolve(doc, dict_get(doc, cases, "h"))
testing.expect(t, !ok, "an empty destination array resolved")
_, ok = dest_resolve(doc, dict_get(doc, cases, "nope"))
testing.expect(t, !ok, "a missing destination resolved")
_, ok = dest_resolve(nil, nil)
testing.expect(t, !ok, "dest_resolve(nil) resolved")
}
@(test)
test_dest_xyz_nulls :: proc(t: ^testing.T) {
// /XYZ null 700 null: keep the current left and zoom, scroll to y=700.
// The distinction is not one a value can carry -- 0 is a legal coordinate
// and a legal zoom means "unchanged" as well -- so `has` is the only place
// it can live.
pb := pb_make()
defer pb_destroy(&pb)
doc, cases := ol_dest_doc(&pb, "", `<<
/a [3 0 R /XYZ null 700 null]
/b [3 0 R /XYZ 0 0 0]
/c [3 0 R /XYZ]
/d [3 0 R /XYZ 5]
/e [3 0 R /XYZ 0 0 1.5]
/f [3 0 R /FitR 0 0 0 0]
>>`)
defer close(doc)
if doc == nil || cases == nil { return }
d, _ := dest_resolve(doc, dict_get(doc, cases, "a"))
testing.expectf(t, d.has == [4]bool{false, true, false, false}, "null XYZ has %v", d.has)
testing.expectf(t, d.args[1] == 700, "null XYZ top %v, wanted 700", d.args[1])
testing.expectf(t, d.args[0] == 0 && d.args[2] == 0,
"an absent argument left a value behind: %v", d.args)
// Zero left and zero top are coordinates; a zero zoom is the format's own
// spelling of null (ISO 32000-2, 12.3.2.2), and every /XYZ in the PDF
// specification's own bookmarks is written that way.
d, _ = dest_resolve(doc, dict_get(doc, cases, "b"))
testing.expectf(t, d.has == [4]bool{true, true, false, false},
"zero zoom must read as unchanged, has %v", d.has)
d, _ = dest_resolve(doc, dict_get(doc, cases, "e"))
testing.expectf(t, d.has == [4]bool{true, true, true, false} && d.args[2] == 1.5,
"a real zoom was lost: args %v has %v", d.args, d.has)
// The rule is /XYZ's alone: /FitR's numbers are coordinates and a zero
// among them is a real one.
d, _ = dest_resolve(doc, dict_get(doc, cases, "f"))
testing.expectf(t, d.has == [4]bool{true, true, true, true} && d.args == [4]f64{0, 0, 0, 0},
"/FitR zeros %v has %v", d.args, d.has)
d, _ = dest_resolve(doc, dict_get(doc, cases, "c"))
testing.expectf(t, d.has == [4]bool{}, "/XYZ with no arguments has %v", d.has)
d, _ = dest_resolve(doc, dict_get(doc, cases, "d"))
testing.expectf(t, d.has == [4]bool{true, false, false, false},
"/XYZ truncated after one argument has %v", d.has)
}
@(test)
test_dest_missing_page :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
doc, cases := ol_dest_doc(&pb, "", `<<
/gone [99 0 R /XYZ 1 2 3]
/free [7 0 R /Fit]
/nth 3
>>`)
defer close(doc)
if doc == nil || cases == nil { return }
d, ok := dest_resolve(doc, dict_get(doc, cases, "gone"))
testing.expect(t, ok, "a destination to a deleted page should still resolve")
testing.expectf(t, d.page == -1, "deleted page resolved to %v, wanted -1", d.page)
// The view survives, so a viewer can still say where it would have gone.
testing.expectf(t, d.kind == .XYZ && d.args[0] == 1, "view lost with the page: %v", d)
d, _ = dest_resolve(doc, dict_get(doc, cases, "free"))
testing.expectf(t, d.page == -1, "a free object resolved to page %v", d.page)
// A destination is not a bare number, whatever it names.
_, ok = dest_resolve(doc, dict_get(doc, cases, "nth"))
testing.expect(t, !ok, "a number resolved as a destination")
}
@(test)
test_dest_page_by_number :: proc(t: ^testing.T) {
// The remote form, [pageIndex /Fit], which producers use locally too.
pb := pb_make()
defer pb_destroy(&pb)
doc, cases := ol_dest_doc(&pb, "", `<<
/a [2 /Fit]
/b [0 /FitH 100]
/c [9 /Fit]
/d [-1 /Fit]
>>`)
defer close(doc)
if doc == nil || cases == nil { return }
d, _ := dest_resolve(doc, dict_get(doc, cases, "a"))
testing.expectf(t, d.page == 2, "index 2 gave page %v", d.page)
d, _ = dest_resolve(doc, dict_get(doc, cases, "b"))
testing.expectf(t, d.page == 0 && d.kind == .Fit_H, "index 0 gave %v", d)
d, _ = dest_resolve(doc, dict_get(doc, cases, "c"))
testing.expectf(t, d.page == -1, "index 9 of a 3-page document gave %v", d.page)
d, _ = dest_resolve(doc, dict_get(doc, cases, "d"))
testing.expectf(t, d.page == -1, "index -1 gave %v", d.page)
}
@(test)
test_dest_named_through_dests_dict :: proc(t: ^testing.T) {
// PDF 1.1: the catalog's /Dests dictionary.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Dests 20 0 R")
pb_obj(&pb, 20, "<< /chap1 [4 0 R /FitH 500] /chap2 21 0 R /chap3 << /D [5 0 R /Fit] >> >>")
pb_obj(&pb, 21, "[3 0 R /XYZ null null 2]")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
d, ok := dest_resolve(doc, Name("chap1"))
testing.expect(t, ok, "/chap1 did not resolve")
testing.expectf(t, d.page == 1 && d.kind == .Fit_H && d.args[0] == 500, "chap1 %v", d)
// The same name written as a string, which files do.
d, ok = dest_resolve(doc, String(transmute([]byte)string("chap1")))
testing.expectf(t, ok && d.page == 1, "chap1 as a string gave %v", d)
// Through an indirect reference, and through a << /D ... >> wrapper.
d, ok = dest_resolve(doc, Name("chap2"))
testing.expectf(t, ok && d.page == 0 && d.has == [4]bool{false, false, true, false},
"chap2 %v", d)
d, ok = dest_resolve(doc, Name("chap3"))
testing.expectf(t, ok && d.page == 2, "chap3 %v", d)
_, ok = dest_resolve(doc, Name("chap9"))
testing.expect(t, !ok, "an undefined name resolved")
}
@(test)
test_dest_named_through_name_tree :: proc(t: ^testing.T) {
// PDF 1.2 and later: the /Names /Dests name tree, with a /Kids level so
// the lookup has to descend rather than read one /Names array.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Names 20 0 R")
pb_obj(&pb, 20, "<< /Dests 21 0 R >>")
pb_obj(&pb, 21, "<< /Kids [22 0 R 23 0 R] >>")
pb_obj(&pb, 22, "<< /Limits [(alpha) (beta)] /Names [(alpha) [3 0 R /Fit] (beta) 24 0 R] >>")
pb_obj(&pb, 23, "<< /Limits [(gamma) (gamma)] /Names [(gamma) [5 0 R /FitV 12]] >>")
pb_obj(&pb, 24, "<< /D [4 0 R /XYZ 1 2 null] >>")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
d, ok := dest_resolve(doc, String(transmute([]byte)string("alpha")))
testing.expectf(t, ok && d.page == 0 && d.kind == .Fit, "alpha %v", d)
// A tree value that is a dictionary with /D, which is the form the format
// actually recommends for a named destination.
d, ok = dest_resolve(doc, String(transmute([]byte)string("beta")))
testing.expectf(t, ok && d.page == 1 && d.has == [4]bool{true, true, false, false}, "beta %v", d)
// In the second kid, so /Limits had to be read to get there.
d, ok = dest_resolve(doc, Name("gamma"))
testing.expectf(t, ok && d.page == 2 && d.kind == .Fit_V && d.args[0] == 12, "gamma %v", d)
_, ok = dest_resolve(doc, String(transmute([]byte)string("delta")))
testing.expect(t, !ok, "a name not in the tree resolved")
}
@(test)
test_dest_name_cycle_terminates :: proc(t: ^testing.T) {
// A name whose destination is the same name, and a /D chain that never
// reaches an array. Both must stop.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Dests 20 0 R")
pb_obj(&pb, 20, "<< /loop /loop /deep 21 0 R >>")
pb_obj(&pb, 21, "<< /D << /D << /D << /D << /D [3 0 R /Fit] >> >> >> >> >>")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
_, ok := dest_resolve(doc, Name("loop"))
testing.expect(t, !ok, "a self-naming destination resolved")
_, ok = dest_resolve(doc, Name("deep"))
testing.expect(t, !ok, "a destination past the hop limit resolved")
}
@(test)
test_outline_dest_named :: proc(t: ^testing.T) {
// The whole path: a bookmark whose /Dest is a name in the name tree.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/Outlines 10 0 R /Names 20 0 R")
pb_obj(&pb, 10, "<< /Type /Outlines /First 11 0 R >>")
pb_obj(&pb, 11, "<< /Title (Chapter) /Dest (ch1) /Next 12 0 R >>")
pb_obj(&pb, 12, "<< /Title (Appendix) /A << /S /GoTo /D /ch2 >> >>")
pb_obj(&pb, 20, "<< /Dests 21 0 R >>")
pb_obj(&pb, 21, "<< /Names [(ch1) [4 0 R /Fit] (ch2) [5 0 R /Fit]] >>")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
items, _ := outline_load(doc)
defer outline_destroy(items)
testing.expectf(t, len(items) == 2, "got %v items, wanted 2", len(items))
if len(items) != 2 { return }
testing.expectf(t, items[0].page == 1, "named /Dest gave page %v, wanted 1", items[0].page)
testing.expectf(t, items[1].page == 2, "named action gave page %v, wanted 2", items[1].page)
testing.expect(t, items[0].dest.page == items[0].page, "item.page disagrees with dest.page")
}
// ---------------------------------------------------------------------------
// Page labels
// ---------------------------------------------------------------------------
@(private = "file")
ol_expect_label :: proc(t: ^testing.T, doc: ^Document, index: int, want: string) {
got := page_label(doc, index)
defer delete(got)
testing.expectf(t, got == want, "page_label(%v) = %q, wanted %q", index, got, want)
}
@(test)
test_page_label_styles :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/PageLabels 20 0 R")
pb_obj(&pb, 20, `<< /Nums [
0 << /S /r >>
4 << /S /D >>
8 << /S /a >>
12 << /S /A >>
16 << /S /R >>
] >>`)
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
ol_expect_label(t, doc, 0, "i")
ol_expect_label(t, doc, 3, "iv")
ol_expect_label(t, doc, 4, "1")
ol_expect_label(t, doc, 7, "4")
ol_expect_label(t, doc, 8, "a")
ol_expect_label(t, doc, 11, "d")
ol_expect_label(t, doc, 12, "A")
ol_expect_label(t, doc, 15, "D")
ol_expect_label(t, doc, 16, "I")
// Past the last range's start: the last range runs to the end of the file.
ol_expect_label(t, doc, 20, "V")
ol_expect_label(t, doc, 25, "X")
// Before any page: never a label.
ol_expect_label(t, doc, -1, "")
}
@(test)
test_page_label_prefix_and_start :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/PageLabels 20 0 R")
pb_obj(&pb, 20, `<< /Nums [
0 << /P (Cover) >>
1 << /S /D /P (A-) /St 7 >>
4 << /S /r /St 3 >>
6 << /S /D /St 0 >>
8 << /P <FEFF00A7> /S /D >>
] >>`)
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
// A range with a prefix and no /S labels every page in it the same way.
ol_expect_label(t, doc, 0, "Cover")
// /St shifts the numbering; the prefix goes in front of every one.
ol_expect_label(t, doc, 1, "A-7")
ol_expect_label(t, doc, 3, "A-9")
ol_expect_label(t, doc, 4, "iii")
ol_expect_label(t, doc, 5, "iv")
// /St 0 is out of range and clamps to 1 rather than labelling a page "0".
ol_expect_label(t, doc, 6, "1")
ol_expect_label(t, doc, 7, "2")
// A UTF-16 prefix is a text string like any other.
ol_expect_label(t, doc, 8, "§1")
}
@(test)
test_page_label_no_tree :: proc(t: ^testing.T) {
// With no /PageLabels the label is the page's own position, 1-based.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
ol_expect_label(t, doc, 0, "1")
ol_expect_label(t, doc, 41, "42")
ol_expect_label(t, doc, -3, "")
ol_expect_label(t, nil, 0, "")
}
@(test)
test_page_label_broken_tree :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/PageLabels 20 0 R")
// Entries out of order, a key with a non-dictionary value, a negative key,
// an unknown style, and a range that starts after the pages under test.
pb_obj(&pb, 20, `<< /Nums [
4 << /S /D /St 100 >>
0 << /S /D >>
2 (not a dictionary)
-1 << /S /A >>
3 << /S /Q /P (x) >>
20 << /S /D /St 1 >>
] >>`)
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
// Order in the file is not order in the tree: page 1 belongs to the range
// keyed 0 even though it is written second.
ol_expect_label(t, doc, 0, "1")
ol_expect_label(t, doc, 1, "2")
// The entry at key 2 is unusable, so page 2 falls back to the range at 0.
ol_expect_label(t, doc, 2, "3")
// An unknown /S numbers nothing; the prefix is still the label.
ol_expect_label(t, doc, 3, "x")
ol_expect_label(t, doc, 4, "100")
ol_expect_label(t, doc, 5, "101")
ol_expect_label(t, doc, 21, "2")
// A /PageLabels that is not a tree at all.
pb2 := pb_make()
defer pb_destroy(&pb2)
ol_pages(&pb2, "/PageLabels 20 0 R")
pb_obj(&pb2, 20, "42")
ol_finish(&pb2)
doc2, _ := open_bytes(pb2.buf[:])
defer close(doc2)
if doc2 == nil { return }
ol_expect_label(t, doc2, 6, "7")
}
@(test)
test_page_label_kids_tree :: proc(t: ^testing.T) {
// The /Kids form of a number tree, which is what a document with many
// ranges is written as.
pb := pb_make()
defer pb_destroy(&pb)
ol_pages(&pb, "/PageLabels 20 0 R")
pb_obj(&pb, 20, "<< /Kids [21 0 R 22 0 R] >>")
pb_obj(&pb, 21, "<< /Limits [0 0] /Nums [0 << /S /r >>] >>")
pb_obj(&pb, 22, "<< /Limits [5 5] /Nums [5 << /S /D /P (p) /St 2 >>] >>")
ol_finish(&pb)
doc, _ := open_bytes(pb.buf[:])
defer close(doc)
if doc == nil { return }
ol_expect_label(t, doc, 2, "iii")
ol_expect_label(t, doc, 5, "p2")
ol_expect_label(t, doc, 6, "p3")
}
@(test)
test_page_label_numerals :: proc(t: ^testing.T) {
// The numeral writers, driven directly: a range per case would be a very
// long file, and these are pure functions of one number.
check_roman :: proc(t: ^testing.T, n: i64, upper: bool, want: string) {
b := strings.builder_make(0, 16)
defer strings.builder_destroy(&b)
label_write_roman(&b, n, upper)
got := strings.to_string(b)
testing.expectf(t, got == want, "roman(%v, upper=%v) = %q, wanted %q", n, upper, got, want)
}
check_alpha :: proc(t: ^testing.T, n: i64, upper: bool, want: string) {
b := strings.builder_make(0, 16)
defer strings.builder_destroy(&b)
label_write_alpha(&b, n, upper)
got := strings.to_string(b)
testing.expectf(t, got == want, "alpha(%v, upper=%v) = %q, wanted %q", n, upper, got, want)
}
check_roman(t, 1, false, "i")
check_roman(t, 4, false, "iv")
check_roman(t, 9, false, "ix")
check_roman(t, 14, false, "xiv")
check_roman(t, 40, false, "xl")
check_roman(t, 90, false, "xc")
check_roman(t, 400, false, "cd")
check_roman(t, 1990, false, "mcmxc")
check_roman(t, 2024, true, "MMXXIV")
check_roman(t, 3999, true, "MMMCMXCIX")
check_roman(t, 0, false, "")
check_roman(t, -5, false, "")
// Beyond the cap the numeral would be thousands of Ms, so it is decimal.
check_roman(t, 100000, false, "100000")
check_alpha(t, 1, false, "a")
check_alpha(t, 26, false, "z")
check_alpha(t, 27, false, "aa")
check_alpha(t, 52, false, "zz")
check_alpha(t, 53, false, "aaa")
check_alpha(t, 2, true, "B")
check_alpha(t, 0, false, "")
// The repetition count is capped: a file asking for page 10^9 must not ask
// for a 38-megabyte label.
long := strings.builder_make(0, 128)
defer strings.builder_destroy(&long)
label_write_alpha(&long, 1_000_000_000, false)
testing.expectf(t, len(strings.to_string(long)) == MAX_LABEL_LETTERS,
"a huge alphabetic label was %v bytes", len(strings.to_string(long)))
}