Files
pdf-odin-llm/lexer.odin
T

499 lines
17 KiB
Odin
Raw Normal View History

2026-09-25 23:32:29 +02:00
package pdf
import "core:mem"
import "core:strings"
// The COS tokeniser (ISO 32000-2, 7.2).
//
// It is deliberately total: every call to `lexer_next` either advances `pos`
// or returns an EOF token, so no input -- valid, truncated or random -- can
// make a caller spin. Nothing here rejects input either; the malformations
// this layer meets (a string that never closes, a hex digit missing its pair,
// a number that is three signs and a dot) are all common enough in real files
// that failing on them would mean refusing documents every viewer opens. The
// damage is reported through `Token.truncated` and through the values
// themselves, and the parser turns that into a `Repair`.
// ---------------------------------------------------------------------------
// Character classes (ISO 32000-2, 7.2.3, tables 1 and 2)
// ---------------------------------------------------------------------------
is_pdf_whitespace :: proc "contextless" (c: byte) -> bool {
switch c {
case 0x00, 0x09, 0x0A, 0x0C, 0x0D, 0x20: return true
}
return false
}
is_pdf_delimiter :: proc "contextless" (c: byte) -> bool {
switch c {
case '(', ')', '<', '>', '[', ']', '{', '}', '/', '%': return true
}
return false
}
// A regular character is anything that is neither whitespace nor a delimiter;
// names, numbers and keywords are runs of them.
is_pdf_regular :: proc "contextless" (c: byte) -> bool {
return !is_pdf_whitespace(c) && !is_pdf_delimiter(c)
}
// ---------------------------------------------------------------------------
// Tokens
// ---------------------------------------------------------------------------
Token_Kind :: enum u8 {
EOF,
Integer,
Real,
String,
Name,
Array_Open,
Array_Close,
Dict_Open,
Dict_Close,
Brace_Open, // only meaningful inside a type 4 (PostScript) function
Brace_Close,
Keyword, // obj, endobj, stream, R, true, false, null, operators
Error, // a stray delimiter; one byte was consumed
}
Token :: struct {
kind: Token_Kind,
// Byte range of the token in the lexer's input: [start, end).
start: int,
end: int,
// Both numeric fields are filled for both numeric kinds, so a caller that
// wants "a number" never has to switch on the kind. A value that does not
// fit, or that parsed to an infinity, reads as 0 -- see `parse_number`.
int_value: i64,
real_value: f64,
// Names (with #xx resolved) and keywords. Borrows from the input unless
// the name contained an escape, in which case it was allocated with the
// allocator passed to `lexer_next`.
text: string,
// String objects, unescaped and hex-unpacked. Always allocated with the
// allocator passed to `lexer_next` (never a view into the input): a
// decryption pass may have to rewrite these bytes in place, and the input
// is the file itself.
bytes: []byte,
// The token ran into the end of the input before its closing delimiter.
// The value is still usable -- this is a repair, not an error.
truncated: bool,
}
Lexer :: struct {
data: []byte,
pos: int,
}
lexer_make :: proc(data: []byte) -> Lexer {
return Lexer{data = data, pos = 0}
}
// lexer_skip_whitespace steps over whitespace and comments. A '%' outside of a
// string runs to the end of the line and is whitespace for every purpose
// (ISO 32000-2, 7.2.4).
lexer_skip_whitespace :: proc(lex: ^Lexer) {
for lex.pos < len(lex.data) {
c := lex.data[lex.pos]
if is_pdf_whitespace(c) {
lex.pos += 1
continue
}
if c == '%' {
lex.pos += 1
for lex.pos < len(lex.data) {
d := lex.data[lex.pos]
if d == '\r' || d == '\n' { break }
lex.pos += 1
}
continue
}
return
}
}
// lexer_next returns the next token, advancing past it.
//
// `allocator` owns any bytes the token had to materialise: the payload of a
// string, and the text of a name that contained #xx escapes. Callers holding a
// Document pass `doc.allocator` so the token's memory dies with the Document.
lexer_next :: proc(lex: ^Lexer, allocator := context.allocator) -> Token {
lexer_skip_whitespace(lex)
if lex.pos >= len(lex.data) {
return Token{kind = .EOF, start = lex.pos, end = lex.pos}
}
start := lex.pos
c := lex.data[start]
switch c {
case '[':
lex.pos += 1
return Token{kind = .Array_Open, start = start, end = lex.pos}
case ']':
lex.pos += 1
return Token{kind = .Array_Close, start = start, end = lex.pos}
case '{':
lex.pos += 1
return Token{kind = .Brace_Open, start = start, end = lex.pos}
case '}':
lex.pos += 1
return Token{kind = .Brace_Close, start = start, end = lex.pos}
case '(':
return lex_literal_string(lex, allocator)
case '/':
return lex_name(lex, allocator)
case '<':
if start + 1 < len(lex.data) && lex.data[start + 1] == '<' {
lex.pos += 2
return Token{kind = .Dict_Open, start = start, end = lex.pos}
}
return lex_hex_string(lex, allocator)
case '>':
if start + 1 < len(lex.data) && lex.data[start + 1] == '>' {
lex.pos += 2
return Token{kind = .Dict_Close, start = start, end = lex.pos}
}
// A lone '>' is junk. Consume it so the caller makes progress.
lex.pos += 1
return Token{kind = .Error, start = start, end = lex.pos}
case ')':
lex.pos += 1
return Token{kind = .Error, start = start, end = lex.pos}
case '+', '-', '.', '0' ..= '9':
return lex_number(lex)
}
if is_pdf_regular(c) {
return lex_keyword(lex)
}
// Unreachable for the delimiters above, but a delimiter added to the class
// table later must still consume a byte rather than stall.
lex.pos += 1
return Token{kind = .Error, start = start, end = lex.pos}
}
// lexer_next_structural returns the next token without ever allocating: the
// kinds whose value has to be materialised -- strings, and names carrying #xx
// escapes -- come back with an empty payload. Their `kind`, `start` and `end`
// are still right, which is all a structural probe looks at.
//
// The probes that hunt for "num gen obj" or an xref subsection line accept
// integers and keywords only, and in a damaged file they run at every
// plausible offset and reject nearly all of them. Lexing those rejects with a
// real allocator copies the string bodies they step over -- a file reading
// "1 (" at a hundred probed offsets would copy its own tail a hundred times,
// and whichever of those copies is not attached to a Document leaks outright.
lexer_next_structural :: proc(lex: ^Lexer) -> Token {
return lexer_next(lex, mem.nil_allocator())
}
// lexer_peek returns the next token without consuming it. It may allocate
// exactly as `lexer_next` does, so peeking at a string costs a copy; the
// parser keeps its own token queue instead of peeking for that reason.
lexer_peek :: proc(lex: ^Lexer, allocator := context.allocator) -> Token {
save := lex.pos
tok := lexer_next(lex, allocator)
lex.pos = save
return tok
}
// lexer_at_keyword reports whether the next token is exactly `kw`, without
// consuming anything. Used for the keywords that are found by position rather
// than parsed -- `stream`, `endstream`, `endobj`.
lexer_at_keyword :: proc(lex: ^Lexer, kw: string) -> bool {
save := lex.pos
defer lex.pos = save
lexer_skip_whitespace(lex)
return lexer_match_here(lex, kw)
}
// lexer_accept_keyword consumes `kw` if it is next, and reports whether it did.
lexer_accept_keyword :: proc(lex: ^Lexer, kw: string) -> bool {
save := lex.pos
lexer_skip_whitespace(lex)
if lexer_match_here(lex, kw) {
lex.pos += len(kw)
return true
}
lex.pos = save
return false
}
@(private = "file")
lexer_match_here :: proc(lex: ^Lexer, kw: string) -> bool {
end := lex.pos + len(kw)
if end > len(lex.data) { return false }
if string(lex.data[lex.pos:end]) != kw { return false }
// "stream" must not match the first six bytes of "streamable".
if end < len(lex.data) && is_pdf_regular(lex.data[end]) { return false }
return true
}
// lexer_skip_eol steps over one end-of-line marker: CRLF, LF or a bare CR.
// The bare CR is not legal after `stream` but is written by enough producers
// that rejecting it would fail real documents (ISO 32000-2, 7.3.8.1).
lexer_skip_eol :: proc(lex: ^Lexer) {
if lex.pos < len(lex.data) && lex.data[lex.pos] == '\r' {
lex.pos += 1
}
if lex.pos < len(lex.data) && lex.data[lex.pos] == '\n' {
lex.pos += 1
}
}
// ---------------------------------------------------------------------------
// Numbers
// ---------------------------------------------------------------------------
@(private = "file")
lex_number :: proc(lex: ^Lexer) -> Token {
start := lex.pos
for lex.pos < len(lex.data) && is_pdf_regular(lex.data[lex.pos]) {
lex.pos += 1
}
text := string(lex.data[start:lex.pos])
tok := Token{start = start, end = lex.pos}
// parse_number never reports failure as an exception: a number a viewer
// would read as 0 must not abort the object around it.
value, _ := parse_number(text)
#partial switch v in value {
case i64:
tok.kind = .Integer
tok.int_value = v
tok.real_value = f64(v)
case f64:
tok.kind = .Real
tok.real_value = clamp_finite(v, 0)
tok.int_value = f64_to_i64_saturating(tok.real_value)
case:
tok.kind = .Integer
}
return tok
}
// f64_to_i64_saturating clamps before converting: an out-of-range float to
// integer conversion is undefined behaviour, and /Length values of 1e300 exist.
@(private)
f64_to_i64_saturating :: proc "contextless" (v: f64) -> i64 {
if !is_finite(v) { return 0 }
if v >= 9.2e18 { return max(i64) }
if v <= -9.2e18 { return min(i64) }
return i64(v)
}
// ---------------------------------------------------------------------------
// Names (ISO 32000-2, 7.3.5)
// ---------------------------------------------------------------------------
@(private = "file")
lex_name :: proc(lex: ^Lexer, allocator: mem.Allocator) -> Token {
start := lex.pos
lex.pos += 1 // the '/'
body := lex.pos
for lex.pos < len(lex.data) && is_pdf_regular(lex.data[lex.pos]) {
lex.pos += 1
}
raw := string(lex.data[body:lex.pos])
// A bare '/' is the empty name, which is a legal object.
return Token{
kind = .Name,
start = start,
end = lex.pos,
text = decode_name_escapes(raw, allocator),
}
}
// ---------------------------------------------------------------------------
// Keywords
// ---------------------------------------------------------------------------
@(private = "file")
lex_keyword :: proc(lex: ^Lexer) -> Token {
start := lex.pos
for lex.pos < len(lex.data) && is_pdf_regular(lex.data[lex.pos]) {
lex.pos += 1
}
return Token{
kind = .Keyword,
start = start,
end = lex.pos,
text = string(lex.data[start:lex.pos]),
}
}
// ---------------------------------------------------------------------------
// Literal strings (ISO 32000-2, 7.3.4.2)
// ---------------------------------------------------------------------------
@(private = "file")
lex_literal_string :: proc(lex: ^Lexer, allocator: mem.Allocator) -> Token {
start := lex.pos
lex.pos += 1 // the '('
// The unescaped form is never longer than the escaped one, so one
// allocation of the remaining length is always enough and never wrong.
// Size the buffer from the raw body once, up front: the unescaped value is
// never longer than the bytes it came from, so this both fits and cannot
// over-allocate on a file that claims a huge string it does not contain.
b := strings.builder_make(0, literal_body_len(lex.data, lex.pos), allocator)
depth := 1
truncated := true
scan: for lex.pos < len(lex.data) {
c := lex.data[lex.pos]
lex.pos += 1
switch c {
case '\\':
if lex.pos >= len(lex.data) { break scan }
e := lex.data[lex.pos]
lex.pos += 1
switch e {
case 'n': strings.write_byte(&b, '\n')
case 'r': strings.write_byte(&b, '\r')
case 't': strings.write_byte(&b, '\t')
case 'b': strings.write_byte(&b, '\b')
case 'f': strings.write_byte(&b, '\f')
case '(': strings.write_byte(&b, '(')
case ')': strings.write_byte(&b, ')')
case '\\': strings.write_byte(&b, '\\')
case '\r':
// Backslash before an EOL is a line continuation: both the
// backslash and the EOL disappear.
if lex.pos < len(lex.data) && lex.data[lex.pos] == '\n' {
lex.pos += 1
}
case '\n':
// continuation
case '0' ..= '7':
value := int(e - '0')
for _ in 0 ..< 2 {
if lex.pos >= len(lex.data) { break }
d := lex.data[lex.pos]
if d < '0' || d > '7' { break }
value = value * 8 + int(d - '0')
lex.pos += 1
}
strings.write_byte(&b, byte(value & 0xFF))
case:
// "\q" is just "q": the backslash is dropped, the byte kept.
strings.write_byte(&b, e)
}
case '(':
depth += 1
strings.write_byte(&b, '(')
case ')':
depth -= 1
if depth == 0 {
truncated = false
break scan
}
strings.write_byte(&b, ')')
case '\r':
// A raw CR, LF or CRLF in the source is one LF in the value.
if lex.pos < len(lex.data) && lex.data[lex.pos] == '\n' {
lex.pos += 1
}
strings.write_byte(&b, '\n')
case:
strings.write_byte(&b, c)
}
}
return Token{
kind = .String,
start = start,
end = lex.pos,
bytes = transmute([]byte)strings.to_string(b),
truncated = truncated,
}
}
// ---------------------------------------------------------------------------
// Hex strings (ISO 32000-2, 7.3.4.3)
// ---------------------------------------------------------------------------
@(private = "file")
lex_hex_string :: proc(lex: ^Lexer, allocator: mem.Allocator) -> Token {
start := lex.pos
lex.pos += 1 // the '<'
b := strings.builder_make(0, hex_body_len(lex.data, lex.pos) / 2 + 1, allocator)
nibble: byte
have_nibble := false
truncated := true
for lex.pos < len(lex.data) {
c := lex.data[lex.pos]
lex.pos += 1
if c == '>' {
truncated = false
break
}
v, ok := hex_value(c)
// Whitespace is defined to be skipped here, and other junk is skipped
// too: files with a stray '<' or a UTF-8 quote inside a hex string are
// repaired by every viewer rather than rejected.
if !ok { continue }
if have_nibble {
strings.write_byte(&b, nibble << 4 | v)
have_nibble = false
} else {
nibble = v
have_nibble = true
}
}
// "An odd number of digits: the final digit is assumed to be followed by
// a zero." (7.3.4.3)
if have_nibble {
strings.write_byte(&b, nibble << 4)
}
return Token{
kind = .String,
start = start,
end = lex.pos,
bytes = transmute([]byte)strings.to_string(b),
truncated = truncated,
}
}
// literal_body_len counts the raw bytes of a literal string body starting at
// `pos` (just past the '('), stopping at the balancing ')' or at EOF. It is an
// upper bound on the decoded length, which is what the value buffer is sized
// from.
@(private = "file")
literal_body_len :: proc(data: []byte, pos: int) -> int {
depth := 1
i := pos
for i < len(data) {
switch data[i] {
case '\\':
i += 1 // whatever follows is data, never a delimiter
case '(':
depth += 1
case ')':
depth -= 1
if depth == 0 {
return i - pos
}
}
i += 1
}
return len(data) - pos
}
@(private = "file")
hex_body_len :: proc(data: []byte, pos: int) -> int {
for i := pos; i < len(data); i += 1 {
if data[i] == '>' {
return i - pos
}
}
return len(data) - pos
}