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

674 lines
22 KiB
Odin

package pdf
import "core:bytes"
import "core:strings"
import "core:unicode/utf8"
// Text strings, dates, and the name/number trees that hold a document's
// named destinations, embedded files and page labels.
//
// Everything here works on bytes that came out of a file, so everything here
// is written to survive nonsense: a truncated date reads as far as it can, an
// unpaired UTF-16 surrogate becomes U+FFFD rather than invalid UTF-8, and a
// tree whose /Kids point back at their parent terminates.
// ---------------------------------------------------------------------------
// Text strings (ISO 32000-2, 7.9.2.2)
// ---------------------------------------------------------------------------
// PDFDocEncoding as a byte -> Unicode table (ISO 32000-2, Annex D.2).
//
// It is Latin-1 apart from three regions: 0x18-0x1F, which hold the accents
// Latin-1 puts in the C1 area; 0x80-0x9F, which hold the typographic
// characters that Latin-1 has no room for; and 0xA0, which is the euro sign
// rather than a no-break space.
//
// A zero entry means "not defined by PDFDocEncoding" -- 0x00, 0x7F, 0x9F and
// 0xAD. Those decode to the byte's own value, which is what every viewer does
// and what keeps round-tripping a Latin-1 string through this table lossless.
@(private, rodata)
PDF_DOC_ENCODING := [256]rune{
0x18 = 0x02D8, // breve
0x19 = 0x02C7, // caron
0x1A = 0x02C6, // modifier letter circumflex accent
0x1B = 0x02D9, // dot above
0x1C = 0x02DD, // double acute accent
0x1D = 0x02DB, // ogonek
0x1E = 0x02DA, // ring above
0x1F = 0x02DC, // small tilde
0x80 = 0x2022, // bullet
0x81 = 0x2020, // dagger
0x82 = 0x2021, // double dagger
0x83 = 0x2026, // horizontal ellipsis
0x84 = 0x2014, // em dash
0x85 = 0x2013, // en dash
0x86 = 0x0192, // latin small letter f with hook
0x87 = 0x2044, // fraction slash
0x88 = 0x2039, // single left-pointing angle quotation mark
0x89 = 0x203A, // single right-pointing angle quotation mark
0x8A = 0x2212, // minus sign
0x8B = 0x2030, // per mille sign
0x8C = 0x201E, // double low-9 quotation mark
0x8D = 0x201C, // left double quotation mark
0x8E = 0x201D, // right double quotation mark
0x8F = 0x2018, // left single quotation mark
0x90 = 0x2019, // right single quotation mark
0x91 = 0x201A, // single low-9 quotation mark
0x92 = 0x2122, // trade mark sign
0x93 = 0xFB01, // latin small ligature fi
0x94 = 0xFB02, // latin small ligature fl
0x95 = 0x0141, // latin capital letter l with stroke
0x96 = 0x0152, // latin capital ligature oe
0x97 = 0x0160, // latin capital letter s with caron
0x98 = 0x0178, // latin capital letter y with diaeresis
0x99 = 0x017D, // latin capital letter z with caron
0x9A = 0x0131, // latin small letter dotless i
0x9B = 0x0142, // latin small letter l with stroke
0x9C = 0x0153, // latin small ligature oe
0x9D = 0x0161, // latin small letter s with caron
0x9E = 0x017E, // latin small letter z with caron
0xA0 = 0x20AC, // euro sign
}
// decode_text_string converts a PDF text string to UTF-8.
//
// A leading U+FEFF byte order mark (FE FF) means the rest is UTF-16BE; an
// EF BB BF mark means it is already UTF-8; anything else is PDFDocEncoding
// (ISO 32000-2, 7.9.2.2). An FF FE mark is not in the format, but enough
// producers emit little-endian UTF-16 with one that decoding it costs nothing
// and rescues those files -- no PDFDocEncoding text begins with "y-diaeresis,
// thorn".
//
// The result is always freshly allocated with `allocator` and owned by the
// caller. It is always valid UTF-8: a lone surrogate, a truncated UTF-16 code
// unit or a malformed UTF-8 sequence each become U+FFFD.
decode_text_string :: proc(raw: []byte, allocator := context.allocator) -> string {
// +1 so that the empty string is still a real allocation the caller can
// delete unconditionally.
b := strings.builder_make(0, len(raw) + 1, allocator)
switch {
case len(raw) >= 2 && raw[0] == 0xFE && raw[1] == 0xFF:
text_write_utf16(&b, raw[2:], true)
case len(raw) >= 2 && raw[0] == 0xFF && raw[1] == 0xFE:
text_write_utf16(&b, raw[2:], false)
case len(raw) >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF:
text_write_utf8(&b, raw[3:])
case:
text_write_pdfdoc(&b, raw)
}
return strings.to_string(b)
}
// decode_text_object is decode_text_string for a string object, and the form
// most callers want: /Title, /Author, /Subject and the rest are all strings
// that may be indirect.
decode_text_object :: proc(doc: ^Document, o: Object, allocator := context.allocator) -> string {
raw, _ := to_bytes(resolve(doc, o))
return decode_text_string(raw, allocator)
}
@(private)
text_write_pdfdoc :: proc(b: ^strings.Builder, raw: []byte) {
for c in raw {
r := PDF_DOC_ENCODING[c]
if r == 0 {
r = rune(c)
}
strings.write_rune(b, r)
}
}
@(private)
text_write_utf8 :: proc(b: ^strings.Builder, raw: []byte) {
// Re-encoding rather than copying: the mark only claims the bytes are
// UTF-8, and a claim out of a file is not a guarantee.
for i := 0; i < len(raw); {
r, size := utf8.decode_rune(raw[i:])
if size <= 0 {
strings.write_rune(b, utf8.RUNE_ERROR)
break
}
strings.write_rune(b, r)
i += size
}
}
@(private)
text_write_utf16 :: proc(b: ^strings.Builder, raw: []byte, big_endian: bool) {
unit :: proc(raw: []byte, i: int, big_endian: bool) -> u16 {
hi, lo := raw[i], raw[i + 1]
if !big_endian {
hi, lo = lo, hi
}
return u16(hi) << 8 | u16(lo)
}
i := 0
for i + 1 < len(raw) {
u := unit(raw, i, big_endian)
i += 2
switch {
case u >= 0xD800 && u <= 0xDBFF:
// High surrogate: only a following low surrogate completes it.
if i + 1 < len(raw) {
v := unit(raw, i, big_endian)
if v >= 0xDC00 && v <= 0xDFFF {
i += 2
strings.write_rune(b, rune(0x10000 + (u32(u - 0xD800) << 10 | u32(v - 0xDC00))))
continue
}
}
strings.write_rune(b, utf8.RUNE_ERROR)
case u >= 0xDC00 && u <= 0xDFFF:
// Low surrogate with nothing in front of it.
strings.write_rune(b, utf8.RUNE_ERROR)
case:
strings.write_rune(b, rune(u))
}
}
// A trailing half code unit: the string was truncated somewhere.
if i < len(raw) {
strings.write_rune(b, utf8.RUNE_ERROR)
}
}
// ---------------------------------------------------------------------------
// Dates (ISO 32000-2, 7.9.4)
// ---------------------------------------------------------------------------
// Date is a decoded PDF date. Absent fields take their lowest legal value:
// month and day 1, everything else 0.
//
// `tz_hour` and `tz_minute` both carry the sign of the offset, so the offset
// from UT in minutes is always `tz_hour * 60 + tz_minute` and never needs a
// separate sign flag. They are meaningless unless `has_tz` is set -- a date
// with no offset is local time of an unknown locale, which is not the same
// thing as UT.
Date :: struct {
year, month, day: int,
hour, minute, second: int,
tz_hour, tz_minute: int,
has_tz: bool,
}
// parse_date decodes "D:YYYYMMDDHHmmSSOHH'mm'".
//
// Every field after the year is optional and truncated dates are the norm.
// So is malformation: the "D:" prefix is missing in plenty of files, the
// apostrophes around the offset minutes are variously absent, singular or
// doubled, and a colon or a space turns up where an apostrophe belongs. All of
// that is accepted. Out-of-range fields are clamped rather than rejected,
// because a viewer showing 31 December for a date that says month 13 is more
// useful than one showing nothing.
//
// ok is false only when no four-digit year could be found at all.
parse_date :: proc(raw: []byte) -> (date: Date, ok: bool) {
// A date is a few dozen bytes. Anything longer is not a date with junk
// after it, it is a different kind of object, and scanning all of it for
// something year-shaped would only invent a date out of arbitrary digits.
scan := raw
if len(scan) > 64 {
scan = scan[:64]
}
p := 0
for p < len(scan) && date_is_space(scan[p]) {
p += 1
}
if p + 1 < len(scan) && (scan[p] == 'D' || scan[p] == 'd') && scan[p + 1] == ':' {
p += 2
}
// Producers put spaces, a second colon, or nothing at all between the
// prefix and the year; skip forward to the first run of four digits.
start := -1
for j := p; j < len(scan); j += 1 {
if date_digit_run(scan, j) >= 4 {
start = j
break
}
}
if start < 0 {
return {}, false
}
p = start
y, _ := date_take(scan, &p, 4)
date.year = clamp(y, 0, 9999)
date.month, date.day = 1, 1
if m, m_ok := date_take(scan, &p, 2); m_ok {
date.month = clamp(m, 1, 12)
if d, d_ok := date_take(scan, &p, 2); d_ok {
date.day = clamp(d, 1, date_days_in_month(date.year, date.month))
if h, h_ok := date_take(scan, &p, 2); h_ok {
// 24 as "midnight ending the day" turns up; clamp it back
// rather than rolling the date over.
date.hour = clamp(h, 0, 23)
if mi, mi_ok := date_take(scan, &p, 2); mi_ok {
date.minute = clamp(mi, 0, 59)
if s, s_ok := date_take(scan, &p, 2); s_ok {
// A leap second (60) is clamped: nothing downstream
// has a use for it.
date.second = clamp(s, 0, 59)
}
}
}
}
}
date_parse_offset(scan, p, &date)
return date, true
}
@(private)
date_parse_offset :: proc(raw: []byte, from: int, date: ^Date) {
p := from
for p < len(raw) && date_is_space(raw[p]) {
p += 1
}
if p >= len(raw) {
return
}
sign := 0
switch raw[p] {
case 'Z', 'z':
// Z is UT. Some producers write "Z00'00'"; a non-zero offset after a
// Z contradicts itself, so the Z wins and the digits are ignored.
date.has_tz = true
return
case '+':
sign = 1
case '-':
sign = -1
case:
return
}
p += 1
h, h_ok := date_take(raw, &p, 2)
if !h_ok {
// A sign with no hours is not an offset.
return
}
date.has_tz = true
date.tz_hour = sign * clamp(h, 0, 23)
for p < len(raw) && (raw[p] == '\'' || raw[p] == ':' || raw[p] == ' ') {
p += 1
}
if m, m_ok := date_take(raw, &p, 2); m_ok {
date.tz_minute = sign * clamp(m, 0, 59)
}
}
// date_offset_minutes is the offset from UT in minutes, 0 when the date
// carries none.
date_offset_minutes :: proc(d: Date) -> int {
if !d.has_tz {
return 0
}
return d.tz_hour * 60 + d.tz_minute
}
@(private)
date_is_space :: proc "contextless" (c: byte) -> bool {
return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == 0
}
@(private)
date_is_digit :: proc "contextless" (c: byte) -> bool {
return c >= '0' && c <= '9'
}
// date_digit_run counts consecutive digits at `i`, stopping at 4: nothing
// here needs to tell a four-digit run from a fourteen-digit one.
@(private)
date_digit_run :: proc "contextless" (raw: []byte, i: int) -> int {
n := 0
for j := i; j < len(raw) && n < 4; j += 1 {
if !date_is_digit(raw[j]) {
break
}
n += 1
}
return n
}
// date_take reads exactly `n` digits, advancing `p` only if it finds them.
// A field with one digit where two belong is treated as absent: guessing
// whether "D:2005032" means the 2nd or the 20th is worse than stopping.
@(private)
date_take :: proc(raw: []byte, p: ^int, n: int) -> (value: int, ok: bool) {
if p^ < 0 || p^ + n > len(raw) {
return 0, false
}
v := 0
for i in 0 ..< n {
c := raw[p^ + i]
if !date_is_digit(c) {
return 0, false
}
v = v * 10 + int(c - '0')
}
p^ += n
return v, true
}
@(private)
date_days_in_month :: proc "contextless" (year, month: int) -> int {
switch month {
case 4, 6, 9, 11:
return 30
case 2:
leap := (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
return leap ? 29 : 28
}
return 31
}
// ---------------------------------------------------------------------------
// Name trees (ISO 32000-2, 7.9.6) and number trees (7.9.7)
// ---------------------------------------------------------------------------
// Work budget for one traversal, counted in nodes visited plus entries
// linearly scanned. A well-formed tree of any realistic size stays far below
// it; a tree built to make a reader spin does not get to.
@(private)
MAX_TREE_STEPS :: 1 << 16
// Entries a single collect will return. A file claiming more than this has a
// tree that is not going to be useful to anyone.
@(private)
MAX_TREE_ENTRIES :: 1 << 20
@(private)
Tree_Kind :: enum u8 {
Name,
Number,
}
// name_tree_lookup finds `key` in a name tree, or null if it is not there.
//
// `root` is the tree's root node -- the value of /Dests, /EmbeddedFiles and
// the rest of the catalog's /Names dictionary. Keys are compared by byte
// value, which is the order the format requires the /Names arrays to be in.
name_tree_lookup :: proc(doc: ^Document, root: ^Dict, key: []byte) -> Object {
path: [MAX_DEPTH]^Dict
steps := MAX_TREE_STEPS
value, _ := tree_find(doc, root, .Name, key, 0, 0, &path, &steps)
return value
}
// number_tree_lookup finds `key` in a number tree (/PageLabels, /ParentTree),
// or null.
number_tree_lookup :: proc(doc: ^Document, root: ^Dict, key: i64) -> Object {
path: [MAX_DEPTH]^Dict
steps := MAX_TREE_STEPS
value, _ := tree_find(doc, root, .Number, nil, key, 0, &path, &steps)
return value
}
// tree_find descends one node.
//
// `path` holds the ancestors of the current node so that a /Kids entry
// pointing back up the tree is detected; with the depth cap that is enough,
// because the walk only ever moves downwards. `steps` bounds the total work,
// which matters because a node with no /Limits has to be searched
// speculatively.
@(private)
tree_find :: proc(
doc: ^Document, node: ^Dict, kind: Tree_Kind, skey: []byte, ikey: i64,
depth: int, path: ^[MAX_DEPTH]^Dict, steps: ^int,
) -> (value: Object, found: bool) {
if node == nil || depth >= MAX_DEPTH || steps^ <= 0 {
return nil, false
}
for i in 0 ..< depth {
if path[i] == node {
return nil, false
}
}
path[depth] = node
steps^ -= 1
leaf := dict_array(doc, node, kind == .Name ? "Names" : "Nums")
if leaf != nil {
if v, hit := tree_search_leaf(doc, leaf, kind, skey, ikey, steps); hit {
return v, true
}
}
kids := dict_array(doc, node, "Kids")
for i in 0 ..< array_len(kids) {
if steps^ <= 0 {
break
}
kid, is_dict := to_dict(array_get(doc, kids, i))
if !is_dict {
continue
}
if !tree_limits_contain(doc, kid, kind, skey, ikey) {
continue
}
if v, hit := tree_find(doc, kid, kind, skey, ikey, depth + 1, path, steps); hit {
return v, true
}
}
return nil, false
}
// tree_limits_contain tests a kid's /Limits. A node with no usable /Limits is
// searched rather than skipped: the entry is required, but files that omit it
// are readable and the alternative is losing every key below that node.
@(private)
tree_limits_contain :: proc(doc: ^Document, node: ^Dict, kind: Tree_Kind, skey: []byte, ikey: i64) -> bool {
limits := dict_array(doc, node, "Limits")
if array_len(limits) < 2 {
return true
}
if kind == .Name {
if lo, lo_ok := to_bytes(array_get(doc, limits, 0)); lo_ok && bytes.compare(skey, lo) < 0 {
return false
}
if hi, hi_ok := to_bytes(array_get(doc, limits, 1)); hi_ok && bytes.compare(skey, hi) > 0 {
return false
}
return true
}
if lo, lo_ok := to_int(array_get(doc, limits, 0)); lo_ok && ikey < lo {
return false
}
if hi, hi_ok := to_int(array_get(doc, limits, 1)); hi_ok && ikey > hi {
return false
}
return true
}
// tree_search_leaf binary-searches a leaf's key/value pairs.
//
// The format requires the pairs to be sorted, and a binary search over a few
// thousand named destinations is the whole point of the structure. But an
// unsorted (or ragged, or partly non-string) array does occur, and a miss
// there would silently lose a destination that is plainly present, so a miss
// falls back to a linear scan paid for out of the step budget.
@(private)
tree_search_leaf :: proc(
doc: ^Document, leaf: ^Array, kind: Tree_Kind, skey: []byte, ikey: i64, steps: ^int,
) -> (value: Object, found: bool) {
n := array_len(leaf) / 2
lo, hi := 0, n
for lo < hi {
if steps^ <= 0 {
return nil, false
}
steps^ -= 1
mid := lo + (hi - lo) / 2
cmp, ok := tree_compare(kind, array_get(doc, leaf, mid * 2), skey, ikey)
if !ok {
break
}
switch {
case cmp == 0:
return array_get(doc, leaf, mid * 2 + 1), true
case cmp < 0:
lo = mid + 1
case:
hi = mid
}
}
for i in 0 ..< n {
if steps^ <= 0 {
return nil, false
}
steps^ -= 1
if cmp, ok := tree_compare(kind, array_get(doc, leaf, i * 2), skey, ikey); ok && cmp == 0 {
return array_get(doc, leaf, i * 2 + 1), true
}
}
return nil, false
}
// tree_compare orders a key found in the file against the key being looked
// for. ok is false when the entry is not of the tree's key type, which the
// caller reads as "this array cannot be binary-searched".
@(private)
tree_compare :: proc(kind: Tree_Kind, entry: Object, skey: []byte, ikey: i64) -> (cmp: int, ok: bool) {
if kind == .Name {
b, is_str := to_bytes(entry)
if !is_str {
return 0, false
}
return bytes.compare(b, skey), true
}
v, is_int := to_int(entry)
if !is_int {
return 0, false
}
switch {
case v < ikey: return -1, true
case v > ikey: return +1, true
}
return 0, true
}
@(private)
Tree_Collector :: struct {
// All nil on the counting pass. A name tree fills `keys`, a number tree
// fills `nums`; only one of the two is ever non-nil.
keys: [][]byte,
nums: []i64,
values: []Object,
n: int,
steps: int,
}
// name_tree_collect returns every key and value in a name tree, in tree order.
//
// Both slices are allocated with `allocator` and are the caller's to free.
// The keys are *not* copies: each one points at the string bytes inside the
// Document and is only valid while that Document is open. Copy them if they
// have to outlive it.
name_tree_collect :: proc(
doc: ^Document, root: ^Dict, allocator := context.allocator,
) -> (keys: [][]byte, values: []Object) {
path: [MAX_DEPTH]^Dict
count := Tree_Collector{steps = MAX_TREE_STEPS}
tree_walk(doc, root, .Name, &count, 0, &path)
n := min(count.n, MAX_TREE_ENTRIES)
if n == 0 {
return nil, nil
}
keys = make([][]byte, n, allocator)
values = make([]Object, n, allocator)
fill := Tree_Collector{keys = keys, values = values, steps = MAX_TREE_STEPS}
tree_walk(doc, root, .Name, &fill, 0, &path)
return keys, values
}
// number_tree_collect is name_tree_collect for a number tree. Same ownership
// rules; the keys are values, not pointers, so they outlive the Document.
number_tree_collect :: proc(
doc: ^Document, root: ^Dict, allocator := context.allocator,
) -> (keys: []i64, values: []Object) {
path: [MAX_DEPTH]^Dict
count := Tree_Collector{steps = MAX_TREE_STEPS}
tree_walk(doc, root, .Number, &count, 0, &path)
n := min(count.n, MAX_TREE_ENTRIES)
if n == 0 {
return nil, nil
}
// The walker fills a [][]byte; a number tree's keys are gathered here
// instead, out of the same values array, so only one walker exists.
nkeys := make([]i64, n, allocator)
values = make([]Object, n, allocator)
fill := Tree_Collector{nums = nkeys, values = values, steps = MAX_TREE_STEPS}
tree_walk(doc, root, .Number, &fill, 0, &path)
return nkeys, values
}
@(private)
tree_walk :: proc(
doc: ^Document, node: ^Dict, kind: Tree_Kind, c: ^Tree_Collector, depth: int, path: ^[MAX_DEPTH]^Dict,
) {
if node == nil || depth >= MAX_DEPTH || c.steps <= 0 {
return
}
for i in 0 ..< depth {
if path[i] == node {
return
}
}
path[depth] = node
c.steps -= 1
leaf := dict_array(doc, node, kind == .Name ? "Names" : "Nums")
for i in 0 ..< array_len(leaf) / 2 {
if c.steps <= 0 || c.n >= MAX_TREE_ENTRIES {
return
}
c.steps -= 1
entry := array_get(doc, leaf, i * 2)
if kind == .Name {
kb, is_str := to_bytes(entry)
if !is_str {
continue
}
if c.keys != nil && c.n < len(c.keys) {
c.keys[c.n] = kb
}
} else {
kn, is_int := to_int(entry)
if !is_int {
continue
}
if c.nums != nil && c.n < len(c.nums) {
c.nums[c.n] = kn
}
}
if c.values != nil && c.n < len(c.values) {
c.values[c.n] = array_get(doc, leaf, i * 2 + 1)
}
c.n += 1
}
kids := dict_array(doc, node, "Kids")
for i in 0 ..< array_len(kids) {
if c.steps <= 0 {
return
}
kid, is_dict := to_dict(array_get(doc, kids, i))
if !is_dict {
continue
}
tree_walk(doc, kid, kind, c, depth + 1, path)
}
}