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

283 lines
10 KiB
Odin

package pdf
import "core:math"
// Point is a coordinate pair. PDF user space is y-up with the origin at the
// bottom-left of the page; device space here is y-down with the origin at the
// top-left of the bitmap, and the flip lives in the base matrix that
// `render_page` builds, not in the geometry code.
Point :: [2]f64
// Matrix is a PDF transformation matrix, stored in the order the format writes
// it: `[a b c d e f]` standing for
//
// | a b 0 |
// | c d 0 |
// | e f 1 |
//
// with row vectors, so a point maps as x' = a*x + c*y + e, y' = b*x + d*y + f.
Matrix :: struct {
a, b, c, d, e, f: f64,
}
MATRIX_IDENTITY :: Matrix{1, 0, 0, 1, 0, 0}
// Rect is an axis-aligned rectangle. x0/y0 is not required to be the lower
// corner on construction -- `rect_normalize` sorts them -- because PDF
// rectangle arrays are explicitly allowed to give their corners in either
// order.
Rect :: struct {
x0, y0, x1, y1: f64,
}
RECT_EMPTY :: Rect{0, 0, 0, 0}
// RECT_INFINITE is the identity for intersection: clipping against it is a
// no-op, which is what an unset clip path needs to be.
RECT_INFINITE :: Rect{-1e30, -1e30, 1e30, 1e30}
// A4 in points, the fallback page size for a page with no usable /MediaBox.
DEFAULT_PAGE_RECT :: Rect{0, 0, 595.276, 841.89}
// ---------------------------------------------------------------------------
// Matrices
// ---------------------------------------------------------------------------
// matrix_mul returns m applied first, then n. This is the order the `cm`
// operator wants: `CTM = cm_operand * CTM`.
matrix_mul :: proc "contextless" (m, n: Matrix) -> Matrix {
return Matrix{
a = m.a * n.a + m.b * n.c,
b = m.a * n.b + m.b * n.d,
c = m.c * n.a + m.d * n.c,
d = m.c * n.b + m.d * n.d,
e = m.e * n.a + m.f * n.c + n.e,
f = m.e * n.b + m.f * n.d + n.f,
}
}
translate_matrix :: proc "contextless" (tx, ty: f64) -> Matrix {
return Matrix{1, 0, 0, 1, tx, ty}
}
scale_matrix :: proc "contextless" (sx, sy: f64) -> Matrix {
return Matrix{sx, 0, 0, sy, 0, 0}
}
// rotate_matrix takes degrees, matching the /Rotate page entry and the way
// callers think about page rotation.
rotate_matrix :: proc "contextless" (degrees: f64) -> Matrix {
r := degrees * math.PI / 180
s := math.sin(r)
c := math.cos(r)
return Matrix{c, s, -s, c, 0, 0}
}
skew_matrix :: proc "contextless" (ax, ay: f64) -> Matrix {
return Matrix{1, math.tan(ay * math.PI / 180), math.tan(ax * math.PI / 180), 1, 0, 0}
}
matrix_determinant :: proc "contextless" (m: Matrix) -> f64 {
return m.a * m.d - m.b * m.c
}
// matrix_invert reports ok = false for a singular matrix. Degenerate CTMs are
// legal in content streams (a zero-width scale is how some producers hide
// content) so callers must not assume success.
matrix_invert :: proc "contextless" (m: Matrix) -> (inv: Matrix, ok: bool) {
det := matrix_determinant(m)
if det == 0 || !is_finite(det) {
return MATRIX_IDENTITY, false
}
id := 1 / det
inv.a = m.d * id
inv.b = -m.b * id
inv.c = -m.c * id
inv.d = m.a * id
inv.e = (m.c * m.f - m.d * m.e) * id
inv.f = (m.b * m.e - m.a * m.f) * id
return inv, true
}
transform_point :: proc "contextless" (m: Matrix, p: Point) -> Point {
return Point{
m.a * p.x + m.c * p.y + m.e,
m.b * p.x + m.d * p.y + m.f,
}
}
// transform_vector applies only the linear part, for quantities that have a
// direction and a magnitude but no position -- a dash phase, a line width.
transform_vector :: proc "contextless" (m: Matrix, p: Point) -> Point {
return Point{m.a * p.x + m.c * p.y, m.b * p.x + m.d * p.y}
}
// transform_rect returns the bounding box of the transformed rectangle, which
// is larger than the transformed shape whenever the matrix rotates or skews.
transform_rect :: proc "contextless" (m: Matrix, r: Rect) -> Rect {
if r == RECT_INFINITE { return r }
p0 := transform_point(m, Point{r.x0, r.y0})
p1 := transform_point(m, Point{r.x1, r.y0})
p2 := transform_point(m, Point{r.x0, r.y1})
p3 := transform_point(m, Point{r.x1, r.y1})
return Rect{
x0 = min(p0.x, p1.x, p2.x, p3.x),
y0 = min(p0.y, p1.y, p2.y, p3.y),
x1 = max(p0.x, p1.x, p2.x, p3.x),
y1 = max(p0.y, p1.y, p2.y, p3.y),
}
}
// matrix_expansion is the square root of |determinant|: the average factor by
// which the matrix scales lengths. Used to turn a user-space line width or
// flatness tolerance into a device-space one.
matrix_expansion :: proc "contextless" (m: Matrix) -> f64 {
return math.sqrt(abs(matrix_determinant(m)))
}
// matrix_max_expansion is the largest factor by which the matrix can stretch
// any single direction. Flattening tolerance has to use this rather than the
// average, or a curve under an anisotropic scale flattens too coarsely along
// its stretched axis.
matrix_max_expansion :: proc "contextless" (m: Matrix) -> f64 {
return max(math.sqrt(m.a * m.a + m.b * m.b), math.sqrt(m.c * m.c + m.d * m.d))
}
matrix_is_identity :: proc "contextless" (m: Matrix) -> bool {
return m == MATRIX_IDENTITY
}
// matrix_is_rectilinear reports whether the matrix maps axis-aligned
// rectangles to axis-aligned rectangles. Axis-aligned clips can be applied as
// a scissor rect instead of a mask, which is much cheaper and covers most
// real content.
//
// The comparison is against a relative tolerance rather than against zero:
// `rotate_matrix(90)` computes cos(pi/2) as 6.1e-17, not 0, and a page with
// `/Rotate 90` is precisely the case this predicate exists to catch. Testing
// for exact zeros would answer "no" for every rotated page in existence.
matrix_is_rectilinear :: proc "contextless" (m: Matrix) -> bool {
scale := max(abs(m.a), abs(m.b), abs(m.c), abs(m.d))
if scale == 0 {
// A matrix that collapses everything to a point maps a rectangle to a
// (degenerate) rectangle, so the cheap path is still correct.
return true
}
eps := scale * 1e-9
return (abs(m.b) <= eps && abs(m.c) <= eps) || (abs(m.a) <= eps && abs(m.d) <= eps)
}
// ---------------------------------------------------------------------------
// Rectangles
// ---------------------------------------------------------------------------
rect_normalize :: proc "contextless" (r: Rect) -> Rect {
return Rect{min(r.x0, r.x1), min(r.y0, r.y1), max(r.x0, r.x1), max(r.y0, r.y1)}
}
rect_width :: proc "contextless" (r: Rect) -> f64 { return r.x1 - r.x0 }
rect_height :: proc "contextless" (r: Rect) -> f64 { return r.y1 - r.y0 }
rect_is_empty :: proc "contextless" (r: Rect) -> bool {
return r.x1 <= r.x0 || r.y1 <= r.y0
}
rect_contains :: proc "contextless" (r: Rect, p: Point) -> bool {
return p.x >= r.x0 && p.x <= r.x1 && p.y >= r.y0 && p.y <= r.y1
}
rect_intersect :: proc "contextless" (a, b: Rect) -> Rect {
if a == RECT_INFINITE { return b }
if b == RECT_INFINITE { return a }
r := Rect{max(a.x0, b.x0), max(a.y0, b.y0), min(a.x1, b.x1), min(a.y1, b.y1)}
if rect_is_empty(r) { return RECT_EMPTY }
return r
}
rect_union :: proc "contextless" (a, b: Rect) -> Rect {
if rect_is_empty(a) { return b }
if rect_is_empty(b) { return a }
return Rect{min(a.x0, b.x0), min(a.y0, b.y0), max(a.x1, b.x1), max(a.y1, b.y1)}
}
rect_include :: proc "contextless" (r: Rect, p: Point) -> Rect {
if rect_is_empty(r) { return Rect{p.x, p.y, p.x, p.y} }
return Rect{min(r.x0, p.x), min(r.y0, p.y), max(r.x1, p.x), max(r.y1, p.y)}
}
rect_expand :: proc "contextless" (r: Rect, by: f64) -> Rect {
return Rect{r.x0 - by, r.y0 - by, r.x1 + by, r.y1 + by}
}
// ---------------------------------------------------------------------------
// Reading geometry out of the file
// ---------------------------------------------------------------------------
// object_to_rect reads a 4-number array. The corners are normalised, so a
// /MediaBox written as [612 792 0 0] -- which is legal, and does occur -- comes
// back the right way round.
object_to_rect :: proc(doc: ^Document, o: Object) -> (r: Rect, ok: bool) {
a := to_array(o) or_return
if array_len(a) < 4 { return RECT_EMPTY, false }
r = Rect{
array_num(doc, a, 0),
array_num(doc, a, 1),
array_num(doc, a, 2),
array_num(doc, a, 3),
}
if !rect_is_finite(r) { return RECT_EMPTY, false }
return rect_normalize(r), true
}
// object_to_matrix reads a 6-number array, as written for /Matrix on form
// XObjects, patterns and Type3 fonts.
object_to_matrix :: proc(doc: ^Document, o: Object) -> (m: Matrix, ok: bool) {
a := to_array(o) or_return
if array_len(a) < 6 { return MATRIX_IDENTITY, false }
m = Matrix{
array_num(doc, a, 0),
array_num(doc, a, 1),
array_num(doc, a, 2),
array_num(doc, a, 3),
array_num(doc, a, 4),
array_num(doc, a, 5),
}
if !matrix_is_finite(m) { return MATRIX_IDENTITY, false }
return m, true
}
dict_rect :: proc(doc: ^Document, d: ^Dict, key: string, default := RECT_EMPTY) -> Rect {
r, ok := object_to_rect(doc, dict_get(doc, d, key))
return ok ? r : default
}
dict_matrix :: proc(doc: ^Document, d: ^Dict, key: string, default := MATRIX_IDENTITY) -> Matrix {
m, ok := object_to_matrix(doc, dict_get(doc, d, key))
return ok ? m : default
}
// ---------------------------------------------------------------------------
// Finite-value guards
//
// A NaN or infinity reaching the rasteriser turns into an unbounded scanline
// loop or a silently blank page, so every number that comes out of a file is
// checked at the boundary rather than trusted.
// ---------------------------------------------------------------------------
is_finite :: proc "contextless" (x: f64) -> bool {
return x == x && x != math.INF_F64 && x != math.NEG_INF_F64
}
rect_is_finite :: proc "contextless" (r: Rect) -> bool {
return is_finite(r.x0) && is_finite(r.y0) && is_finite(r.x1) && is_finite(r.y1)
}
matrix_is_finite :: proc "contextless" (m: Matrix) -> bool {
return is_finite(m.a) && is_finite(m.b) && is_finite(m.c) &&
is_finite(m.d) && is_finite(m.e) && is_finite(m.f)
}
// clamp_finite replaces a non-finite value with `fallback`.
clamp_finite :: proc "contextless" (x, fallback: f64) -> f64 {
return is_finite(x) ? x : fallback
}