initial commit

This commit is contained in:
2026-09-25 23:32:29 +02:00
commit fff8b677f8
114 changed files with 84233 additions and 0 deletions
+528
View File
@@ -0,0 +1,528 @@
package pdf
import "core:mem"
import "core:strings"
// The document outline (bookmarks), destinations and page labels.
//
// None of this draws anything: it is the navigation model a viewer needs
// around the page it is showing -- the tree down the side, where a bookmark
// jumps to, and the "iv" it puts in the page box instead of "4".
//
// A destination is the format's way of naming a position: a page, a view
// (`/XYZ`, `/Fit`, ...), and the coordinates that view takes. It can be
// written out in full, or named, in which case it is looked up in the
// catalog's `/Dests` dictionary (PDF 1.1) or its `/Names /Dests` name tree
// (PDF 1.2 and later). Both spellings are still produced.
//
// The outline is a doubly linked tree -- `/First` and `/Last` down, `/Next`
// and `/Prev` across, `/Parent` up -- and not an array, so a file that gets
// one link wrong hands the reader an infinite loop rather than a short tree.
// Every walk here is bounded three ways: a depth cap, a sibling cap, and a
// visited set of the dictionaries already placed in the tree. The last is what
// catches the common damage, a `/Next` chain that points back at itself.
// ---------------------------------------------------------------------------
// Limits
// ---------------------------------------------------------------------------
// Nesting the outline walk will follow. Real outlines are five or six deep;
// the format sets no limit and a hand-built file can nest until the stack ends.
@(private)
MAX_OUTLINE_DEPTH :: 32
// Items in one sibling chain, and in the whole tree. A file with more than a
// million bookmarks is not one anybody is going to navigate.
@(private)
MAX_OUTLINE_SIBLINGS :: 1 << 16
@(private)
MAX_OUTLINE_ITEMS :: 1 << 20
// Indirections followed while resolving a destination: a name into the /Dests
// dictionary, whose value is a dictionary, whose /D is the array. Three hops
// is the deepest the format defines; the fourth is there so a file that adds
// one more legitimate wrapper still works, and the bound is what stops a name
// that resolves to itself.
@(private)
MAX_DEST_HOPS :: 4
// Bytes of a /P prefix that reach the label, and repetitions of a letter in an
// alphabetic label. Both are file-controlled and both would otherwise let a
// tiny file ask for an enormous string.
@(private)
MAX_LABEL_PREFIX :: 512
@(private)
MAX_LABEL_LETTERS :: 64
// Largest value written as a Roman numeral. Above it the numeral would be a
// row of thousands with no information in it, so the number is written in
// decimal instead -- which is what a viewer showing "page 40000" wants anyway.
@(private)
MAX_LABEL_ROMAN :: 32767
// ---------------------------------------------------------------------------
// Destinations
// ---------------------------------------------------------------------------
// Dest_Kind is the view a destination asks for (ISO 32000-2, 12.3.2.2). The
// names mirror the format's: /XYZ, /FitH, /FitBV and the rest.
Dest_Kind :: enum u8 {
XYZ = 0, // left top zoom -- any of the three may be "unchanged"
Fit, // fit the whole page
Fit_H, // top: fit the width, with this at the top of the window
Fit_V, // left: fit the height
Fit_R, // left bottom right top: fit this rectangle
Fit_B, // fit the bounding box of the page's contents
Fit_BH, // top: fit the bounding box's width
Fit_BV, // left: fit the bounding box's height
}
// Destination is a resolved place in this document.
//
// `args` holds the view's numbers in the order the format writes them, and
// `has` says which of them the file actually gave: /XYZ's arguments are
// individually allowed to be null, meaning "leave that one as it is", and a
// zero is a legal coordinate, so the two cannot be told apart by value.
//
// The zero value is NOT a valid destination -- it names page 0. Use
// DESTINATION_NONE, whose page is -1, for "no destination".
Destination :: struct {
page: int, // page index, or -1 if the destination named no page here
kind: Dest_Kind,
args: [4]f64,
has: [4]bool,
}
DESTINATION_NONE :: Destination{page = -1}
// dest_kind_from_name maps a destination's view name.
@(private)
dest_kind_from_name :: proc(name: string) -> (kind: Dest_Kind, ok: bool) {
switch name {
case "XYZ": return .XYZ, true
case "Fit": return .Fit, true
case "FitH": return .Fit_H, true
case "FitV": return .Fit_V, true
case "FitR": return .Fit_R, true
case "FitB": return .Fit_B, true
case "FitBH": return .Fit_BH, true
case "FitBV": return .Fit_BV, true
}
return .XYZ, false
}
// dest_arg_count is how many numbers follow the view name.
@(private)
dest_arg_count :: proc(kind: Dest_Kind) -> int {
switch kind {
case .XYZ: return 3
case .Fit, .Fit_B: return 0
case .Fit_H, .Fit_V, .Fit_BH, .Fit_BV: return 1
case .Fit_R: return 4
}
return 0
}
// dest_resolve turns any of the forms a destination is written in into a
// Destination.
//
// `obj` may be the destination array itself, a name or string that names one,
// a dictionary with a /D entry (which is both what the name tree stores and
// what a /GoTo action is), or a reference to any of those.
//
// `ok` is false only when nothing destination-shaped was found. A destination
// that is well formed but names a page this document does not have is still
// ok, with `page` -1: that is a broken link, not a broken file, and dropping
// the whole bookmark because its target was deleted loses its title too.
dest_resolve :: proc(doc: ^Document, obj: Object) -> (Destination, bool) {
if doc == nil {
return DESTINATION_NONE, false
}
o := resolve(doc, obj)
for _ in 0 ..< MAX_DEST_HOPS {
#partial switch v in o {
case ^Array:
return dest_from_array(doc, v)
case Name:
next, found := dest_by_name(doc, transmute([]byte)string(v))
if !found {
return DESTINATION_NONE, false
}
o = next
case String:
next, found := dest_by_name(doc, ([]byte)(v))
if !found {
return DESTINATION_NONE, false
}
o = next
case ^Dict:
// Both /Dests-tree values (<< /D [...] >>) and /GoTo actions
// (<< /S /GoTo /D [...] >>) put the destination under /D. An
// action of another kind -- /URI, /Launch -- has no destination in
// this document, and its /D, if it has one, is not one either.
if s := dict_name(doc, v, "S"); s != "" && s != "GoTo" {
return DESTINATION_NONE, false
}
d := dict_get(doc, v, "D")
if is_null(d) {
return DESTINATION_NONE, false
}
o = d
case:
return DESTINATION_NONE, false
}
}
return DESTINATION_NONE, false
}
// dest_by_name looks a named destination up in both places one can live.
@(private)
dest_by_name :: proc(doc: ^Document, key: []byte) -> (Object, bool) {
if doc == nil || doc.catalog == nil {
return nil, false
}
// PDF 1.1: a flat /Dests dictionary in the catalog, keyed by name.
if dests := dict_dict(doc, doc.catalog, "Dests"); dests != nil {
if v := dict_get(doc, dests, string(key)); !is_null(v) {
return v, true
}
}
// PDF 1.2 and later: the /Names /Dests name tree, keyed by string. The two
// key spaces are distinct in the format and identical in practice, so a
// name is looked up in the tree and a string in the dictionary as well --
// producers do mix them, and the bytes are the same either way.
if names := dict_dict(doc, doc.catalog, "Names"); names != nil {
if tree := dict_dict(doc, names, "Dests"); tree != nil {
if v := name_tree_lookup(doc, tree, key); !is_null(v) {
return v, true
}
}
}
return nil, false
}
// dest_from_array reads the explicit form, [page /XYZ left top zoom]. An empty
// array names nothing at all and is the one array that is not a destination.
@(private)
dest_from_array :: proc(doc: ^Document, a: ^Array) -> (Destination, bool) {
dest := DESTINATION_NONE
if array_len(a) == 0 {
return dest, false
}
// The page is a reference in a destination inside this document and an
// integer index in one that names a page of another file (a /GoToR
// action's /D). Files use the integer form locally too.
first := array_get_raw(a, 0)
if ref, is_ref := to_ref(first); is_ref {
dest.page = page_index_of(doc, ref)
} else if n, is_int := to_int(resolve(doc, first)); is_int {
if n >= 0 && n < i64(page_count(doc)) {
dest.page = int(n)
}
}
// A missing or unknown view name leaves .XYZ with nothing set, which means
// "this page, view unchanged" -- the least surprising thing to do with a
// destination whose second element a producer got wrong.
kind, known := dest_kind_from_name(array_name(doc, a, 1))
if !known {
return dest, true
}
dest.kind = kind
for i in 0 ..< dest_arg_count(kind) {
v, is_num := to_number(array_get(doc, a, 2 + i))
// A null argument is the format's "leave this unchanged", and a
// non-finite one is a producer bug with the same only sane reading.
if !is_num || !is_finite(v) {
continue
}
// "A zoom value of 0 has the same meaning as a null value"
// (ISO 32000-2, 12.3.2.2, Table 151). Producers write it constantly --
// it is what every /XYZ in the PDF specification itself carries -- and
// a viewer that took it literally would zoom to nothing.
if kind == .XYZ && i == 2 && v == 0 {
continue
}
dest.args[i] = v
dest.has[i] = true
}
return dest, true
}
// ---------------------------------------------------------------------------
// The outline
// ---------------------------------------------------------------------------
// Outline_Item is one bookmark.
//
// `page` is `dest.page` repeated, because a viewer building a table of
// contents wants the page and nothing else nine times out of ten.
Outline_Item :: struct {
title: string, // decoded text, never nil, possibly empty
page: int, // resolved page index, or -1
dest: Destination,
open: bool, // /Count > 0: this item's children are shown expanded
children: []Outline_Item,
}
// outline_load reads the document outline.
//
// The whole tree is returned, in the order the file links it. A document with
// no outline gives an empty slice and no error -- that is the common case, not
// a failure.
//
// The result is the caller's, allocated with `allocator`: the item slices and
// every title string. Free it with `outline_destroy`, passing the same
// allocator.
outline_load :: proc(doc: ^Document, allocator := context.allocator) -> ([]Outline_Item, Error) {
if doc == nil {
return nil, .Invalid_Argument
}
root := dict_dict(doc, doc.catalog, "Outlines")
if root == nil {
return nil, .None
}
// Keyed by dictionary address, which is stable: get_object caches by
// object number, so the same reference always yields the same ^Dict. An
// item reached twice is a damaged tree, and taking it once is what stops
// both a /Next cycle and a /First that points back at an ancestor.
visited := make(map[^Dict]bool, 64, context.allocator)
defer delete(visited)
visited[root] = true
budget := MAX_OUTLINE_ITEMS
items := outline_children(doc, root, 0, &visited, &budget, allocator)
return items, .None
}
// outline_destroy frees what outline_load returned. `allocator` must be the
// one it was given.
outline_destroy :: proc(items: []Outline_Item, allocator := context.allocator) {
for it in items {
delete(it.title, allocator)
outline_destroy(it.children, allocator)
}
if items != nil {
delete(items, allocator)
}
}
// outline_children walks one /First .. /Next chain.
@(private)
outline_children :: proc(
doc: ^Document, parent: ^Dict, depth: int,
visited: ^map[^Dict]bool, budget: ^int, allocator: mem.Allocator,
) -> []Outline_Item {
if depth >= MAX_OUTLINE_DEPTH || budget^ <= 0 {
if depth >= MAX_OUTLINE_DEPTH {
add_repair(doc, .Recursion_Limit_Hit, 0)
}
return nil
}
// Gathered in scratch and copied out exactly, so the returned slice has no
// spare capacity for the caller's `delete` to disagree about.
list := make([dynamic]Outline_Item, 0, 8, context.allocator)
defer delete(list)
node := dict_dict(doc, parent, "First")
for i := 0; node != nil && i < MAX_OUTLINE_SIBLINGS; i += 1 {
if budget^ <= 0 {
break
}
if visited[node] or_else false {
// A /Next or /First that points at an item already in the tree.
add_repair(doc, .Bad_Page_Object, 0)
break
}
visited[node] = true
budget^ -= 1
append(&list, outline_item(doc, node, depth, visited, budget, allocator))
node = dict_dict(doc, node, "Next")
}
if len(list) == 0 {
return nil
}
out := make([]Outline_Item, len(list), allocator)
copy(out, list[:])
return out
}
// outline_item reads one node and, recursively, its children.
@(private)
outline_item :: proc(
doc: ^Document, node: ^Dict, depth: int,
visited: ^map[^Dict]bool, budget: ^int, allocator: mem.Allocator,
) -> (item: Outline_Item) {
item.page = -1
item.dest = DESTINATION_NONE
// decode_text_string always allocates, so a missing or non-string /Title
// still gives a string outline_destroy can free unconditionally.
raw, _ := to_bytes(dict_get(doc, node, "Title"))
item.title = decode_text_string(raw, allocator)
// /Count is the number of visible descendants; its sign is what says
// whether this item is open (ISO 32000-2, 12.3.3). Zero means no children,
// for which "closed" is the only sensible answer.
item.open = dict_int(doc, node, "Count", 0) > 0
// /Dest first, then a /GoTo action's /D: a file may carry both, and /Dest
// is the one the format says wins.
target := dict_get(doc, node, "Dest")
if is_null(target) {
if a := dict_dict(doc, node, "A"); a != nil {
target = a
}
}
if !is_null(target) {
if d, ok := dest_resolve(doc, target); ok {
item.dest = d
}
}
item.page = item.dest.page
item.children = outline_children(doc, node, depth + 1, visited, budget, allocator)
return item
}
// ---------------------------------------------------------------------------
// Page labels
// ---------------------------------------------------------------------------
// page_label is the label a viewer shows for page `index`: "iv" for the fourth
// page of a front matter numbered in lowercase Roman, "A-3" for a page in an
// appendix, "4" when the file says nothing.
//
// The catalog's /PageLabels number tree divides the document into ranges, each
// keyed by the index of its first page and each saying how that range is
// numbered: /S is the style (D, r, R, a, A), /P a prefix put in front of every
// label in the range, and /St the number the range starts at (ISO 32000-2,
// 12.4.2). A range with a /P and no /S labels every one of its pages with the
// prefix alone, which is legal and is how a cover page gets called "Cover".
//
// The result is always freshly allocated with `allocator` and owned by the
// caller, empty string included.
//
// Each call reads the tree: cheap, because a document has a handful of ranges
// and not one per page, but a caller labelling all 900 pages of a long
// document should keep the answers rather than ask again.
page_label :: proc(doc: ^Document, index: int, allocator := context.allocator) -> string {
b := strings.builder_make(0, 16, allocator)
if doc == nil || index < 0 {
return strings.to_string(b)
}
entry, start, found := page_label_range(doc, index)
if !found {
// No /PageLabels, or none covering this page: the page's own position
// is what every viewer falls back to.
strings.write_int(&b, index + 1)
return strings.to_string(b)
}
if raw := dict_bytes(doc, entry, "P"); len(raw) > 0 {
prefix := decode_text_string(raw[:min(len(raw), MAX_LABEL_PREFIX)], context.allocator)
defer delete(prefix, context.allocator)
strings.write_string(&b, prefix)
}
style := dict_name(doc, entry, "S")
if style == "" {
return strings.to_string(b)
}
// /St is required to be at least 1. Clamped rather than rejected, and
// clamped at the top as well so the arithmetic below cannot overflow.
st := clamp(dict_int(doc, entry, "St", 1), 1, 1 << 31)
n := st + i64(index) - start
switch style {
case "D":
strings.write_i64(&b, n)
case "r":
label_write_roman(&b, n, false)
case "R":
label_write_roman(&b, n, true)
case "a":
label_write_alpha(&b, n, false)
case "A":
label_write_alpha(&b, n, true)
}
// An unknown style contributes nothing: the prefix is still a label, and
// guessing decimal would put a number on a page the file did not number.
return strings.to_string(b)
}
// page_label_range finds the /PageLabels entry covering `index`: the one whose
// key is the largest that is not greater than it.
@(private)
page_label_range :: proc(doc: ^Document, index: int) -> (entry: ^Dict, start: i64, found: bool) {
root := dict_dict(doc, doc.catalog, "PageLabels")
if root == nil {
return nil, 0, false
}
keys, values := number_tree_collect(doc, root, context.allocator)
defer delete(keys, context.allocator)
defer delete(values, context.allocator)
// Scanned rather than binary-searched: the /Nums arrays are required to be
// in increasing order and damaged files are not, and a document has a
// handful of ranges, not thousands.
for k, i in keys {
if k < 0 || k > i64(index) {
continue
}
if found && k < start {
continue
}
d, is_dict := to_dict(resolve(doc, values[i]))
if !is_dict {
continue
}
entry, start, found = d, k, true
}
return
}
// label_write_roman writes 1 as "i", 4 as "iv", 1990 as "mcmxc".
@(private)
label_write_roman :: proc(b: ^strings.Builder, n: i64, upper: bool) {
if n <= 0 {
return
}
if n > MAX_LABEL_ROMAN {
strings.write_i64(b, n)
return
}
values := [?]i64{1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1}
lower := [?]string{"m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i"}
upper_ := [?]string{"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"}
rest := n
for v, i in values {
for rest >= v {
strings.write_string(b, upper ? upper_[i] : lower[i])
rest -= v
}
}
}
// label_write_alpha writes 1 as "a", 26 as "z", 27 as "aa", 53 as "aaa" -- the
// format's own scheme, which repeats the letter rather than carrying (12.4.2).
@(private)
label_write_alpha :: proc(b: ^strings.Builder, n: i64, upper: bool) {
if n <= 0 {
return
}
letter := byte((n - 1) % 26) + (upper ? 'A' : 'a')
reps := int(min((n - 1) / 26 + 1, MAX_LABEL_LETTERS))
for _ in 0 ..< reps {
strings.write_byte(b, letter)
}
}