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

352 lines
12 KiB
Odin

package pdf
import "core:math"
import "core:testing"
@(test)
test_path_rect_and_bounds :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_rect(&p, 10, 20, 100, 50)
testing.expect_value(t, len(p.verbs), 5) // move, 3 lines, close
testing.expect_value(t, len(p.points), 4)
testing.expect_value(t, path_bounds(&p), Rect{10, 20, 110, 70})
testing.expect_value(t, path_tight_bounds(&p), Rect{10, 20, 110, 70})
}
@(test)
test_path_drops_non_finite :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
nan := math.nan_f64()
path_move_to(&p, nan, 0)
testing.expect_value(t, len(p.verbs), 0)
path_move_to(&p, 0, math.INF_F64)
testing.expect_value(t, len(p.verbs), 0)
path_curve_to(&p, 0, 0, nan, 1, 2, 3)
testing.expect_value(t, len(p.verbs), 0)
path_move_to(&p, 1, 2)
testing.expect_value(t, len(p.verbs), 1)
}
// "l" before any "m" starts a subpath rather than being dropped: content
// streams do this and viewers accept it.
@(test)
test_path_line_without_move :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_line_to(&p, 5, 5)
testing.expect_value(t, len(p.verbs), 1)
testing.expect_value(t, p.verbs[0], Path_Verb.Move)
}
// A cubic that bulges outside its endpoints must widen the tight box, and the
// tight box must never be larger than the control hull.
@(test)
test_path_tight_bounds_curve :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_move_to(&p, 0, 0)
path_curve_to(&p, 0, 100, 100, 100, 100, 0)
hull := path_bounds(&p)
tight := path_tight_bounds(&p)
testing.expect_value(t, hull, Rect{0, 0, 100, 100})
// Peak of this symmetric curve is at t=0.5: y = 3/4 * 100 = 75.
testing.expect(t, abs(tight.y1 - 75) < 1e-9, "curve peak should be 75")
testing.expect_value(t, tight.x0, 0)
testing.expect_value(t, tight.x1, 100)
testing.expect_value(t, tight.y0, 0)
}
@(test)
test_path_transform :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_rect(&p, 0, 0, 10, 10)
path_transform(&p, matrix_mul(scale_matrix(2, 3), translate_matrix(5, 7)))
testing.expect_value(t, path_bounds(&p), Rect{5, 7, 25, 37})
}
@(test)
test_flatten_line_is_exact :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
fp := flat_path_make()
defer flat_path_destroy(&fp)
path_move_to(&p, 0, 0)
path_line_to(&p, 10, 0)
path_line_to(&p, 10, 10)
path_close(&p)
path_flatten(&fp, &p, MATRIX_IDENTITY)
testing.expect_value(t, flat_path_count(&fp), 1)
pts := flat_path_slice(&fp, 0)
testing.expect_value(t, len(pts), 3)
testing.expect_value(t, pts[0], Point{0, 0})
testing.expect_value(t, pts[2], Point{10, 10})
testing.expect(t, fp.subpaths[0].closed, "subpath should be closed")
}
// Flattening twice into one Flat_Path must produce two independent subpaths --
// the clip stack and tiling patterns both rely on it.
@(test)
test_flatten_accumulates :: proc(t: ^testing.T) {
a := path_make(); defer path_destroy(&a)
b := path_make(); defer path_destroy(&b)
fp := flat_path_make(); defer flat_path_destroy(&fp)
path_rect(&a, 0, 0, 10, 10)
path_rect(&b, 100, 100, 10, 10)
path_flatten(&fp, &a, MATRIX_IDENTITY)
path_flatten(&fp, &b, MATRIX_IDENTITY)
testing.expect_value(t, flat_path_count(&fp), 2)
testing.expect_value(t, len(flat_path_slice(&fp, 0)), 4)
testing.expect_value(t, len(flat_path_slice(&fp, 1)), 4)
testing.expect_value(t, flat_path_slice(&fp, 1)[0], Point{100, 100})
}
// Every flattened point must lie within the flatness tolerance of the true
// curve, and the segment count must stay sane.
@(test)
test_flatten_curve_tolerance :: proc(t: ^testing.T) {
p := path_make(); defer path_destroy(&p)
fp := flat_path_make(); defer flat_path_destroy(&fp)
path_move_to(&p, 0, 0)
path_curve_to(&p, 0, 100, 100, 100, 100, 0)
path_flatten(&fp, &p, MATRIX_IDENTITY)
pts := flat_path_slice(&fp, 0)
testing.expect(t, len(pts) > 4, "curve should subdivide")
testing.expect(t, len(pts) < 400, "curve should not over-subdivide")
testing.expect_value(t, pts[0], Point{0, 0})
testing.expect_value(t, pts[len(pts) - 1], Point{100, 0})
// Every sampled point of the true curve must lie close to the polyline.
// Measure to the nearest *segment*, not the nearest vertex: with coarse
// subdivision a sample halfway along a segment is far from both of its
// endpoints while the polyline itself is still accurate.
eval :: proc(t_: f64) -> Point {
mt := 1 - t_
x := 3 * mt * t_ * t_ * 100 + t_ * t_ * t_ * 100
y := 3 * mt * mt * t_ * 100 + 3 * mt * t_ * t_ * 100
return Point{x, y}
}
dist_to_segment :: proc(p, a, b: Point) -> f64 {
vx := b.x - a.x
vy := b.y - a.y
len2 := vx * vx + vy * vy
u := len2 == 0 ? 0 : ((p.x - a.x) * vx + (p.y - a.y) * vy) / len2
u = clamp(u, 0, 1)
dx := p.x - (a.x + u * vx)
dy := p.y - (a.y + u * vy)
return math.sqrt(dx * dx + dy * dy)
}
worst: f64 = 0
for i in 0 ..= 200 {
tv := f64(i) / 200
want := eval(tv)
best: f64 = 1e30
for j in 0 ..< len(pts) - 1 {
best = min(best, dist_to_segment(want, pts[j], pts[j + 1]))
}
worst = max(worst, best)
}
testing.expect(t, worst < 1.0, "flattened polyline should stay within a pixel of the curve")
}
// A degenerate curve (all control points equal) must terminate immediately.
@(test)
test_flatten_degenerate_terminates :: proc(t: ^testing.T) {
p := path_make(); defer path_destroy(&p)
fp := flat_path_make(); defer flat_path_destroy(&fp)
path_move_to(&p, 5, 5)
path_curve_to(&p, 5, 5, 5, 5, 5, 5)
path_flatten(&fp, &p, MATRIX_IDENTITY)
testing.expect(t, len(fp.points) < 8, "degenerate curve must not subdivide")
}
@(test)
test_flatten_empty_and_nil :: proc(t: ^testing.T) {
fp := flat_path_make(); defer flat_path_destroy(&fp)
p := path_make(); defer path_destroy(&p)
path_flatten(&fp, &p, MATRIX_IDENTITY)
testing.expect_value(t, flat_path_count(&fp), 0)
testing.expect_value(t, len(flat_path_slice(&fp, 0)), 0)
testing.expect_value(t, len(flat_path_slice(&fp, -1)), 0)
}
// A curve whose endpoints coincide encloses a loop. The chord is zero length,
// so the ordinary flatness test would declare it flat and throw the loop away.
@(test)
test_flatten_closed_loop_curve :: proc(t: ^testing.T) {
p := path_make(); defer path_destroy(&p)
fp := flat_path_make(); defer flat_path_destroy(&fp)
path_move_to(&p, 0, 0)
path_curve_to(&p, 100, 0, 100, 100, 0, 0)
path_flatten(&fp, &p, MATRIX_IDENTITY)
b := flat_path_bounds(&fp)
testing.expect(t, rect_width(b) > 40, "loop must not collapse in x")
testing.expect(t, rect_height(b) > 15, "loop must not collapse in y")
testing.expect(t, len(fp.points) > 8, "loop must subdivide")
}
// A segment after `h` begins a new subpath AT the current point, which the
// close left at the start of the subpath it closed (ISO 32000-2, 8.5.2.1) --
// "even if the new segment begins at the endpoint reached by the h operation".
// Dropping that point loses the connecting segment entirely and leaves a
// one-point subpath, which a fill ignores and a stroke draws as a stray dot.
// The Line and Curve arms of path_flatten must agree about this.
@(test)
test_flatten_segment_after_close_keeps_current_point :: proc(t: ^testing.T) {
// "m 0 0 l 10 0 h l 20 0"
line := path_make(); defer path_destroy(&line)
path_move_to(&line, 0, 0)
path_line_to(&line, 10, 0)
path_close(&line)
path_line_to(&line, 20, 0)
lf := flat_path_make(); defer flat_path_destroy(&lf)
path_flatten(&lf, &line, MATRIX_IDENTITY)
testing.expect_value(t, flat_path_count(&lf), 2)
second := flat_path_slice(&lf, 1)
testing.expect_value(t, len(second), 2)
testing.expect_value(t, second[0], Point{0, 0})
testing.expect_value(t, second[1], Point{20, 0})
// The same shape written with a curve has always behaved this way, and is
// what the line case is being held to.
curve := path_make(); defer path_destroy(&curve)
path_move_to(&curve, 0, 0)
path_line_to(&curve, 10, 0)
path_close(&curve)
path_curve_to(&curve, 20, 0, 20, 0, 20, 0)
cf := flat_path_make(); defer flat_path_destroy(&cf)
path_flatten(&cf, &curve, MATRIX_IDENTITY)
testing.expect_value(t, flat_path_count(&cf), 2)
testing.expect_value(t, flat_path_slice(&cf, 1)[0], Point{0, 0})
}
// ---------------------------------------------------------------------------
// Flattening what the clip can reach, and only that
// ---------------------------------------------------------------------------
// PF_BOMB is a cubic whose control points are ten trillion units away. It is
// not exotic: it is what an attacker writes, and what a producer emits when a
// transform has gone wrong. The flatness test never gives up on it, so it
// subdivides to MAX_FLATTEN_DEPTH and yields 65 537 points -- a megabyte of
// polyline for one `c`, none of which can be seen.
@(private = "file")
pf_bomb :: proc(p: ^Path, n: int) {
path_move_to(p, 0, 0)
for _ in 0 ..< n {
path_curve_to(p, -4.98e13, 5.61e13, 7.23e13, 9.65e13, -7.82e13, 9.74e13)
}
}
@(test)
test_path_flatten_culls_what_the_clip_cannot_reach :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
pf_bomb(&p, 16)
fp := flat_path_make()
defer flat_path_destroy(&fp)
path_flatten(&fp, &p, MATRIX_IDENTITY)
// Sixteen of them exhaust the whole-flattening cap, which is the only
// thing that stood between this and 16 MB.
testing.expectf(t, len(fp.points) >= MAX_FLATTEN_POINTS,
"the uncalled flattening should reach the cap, got %d points", len(fp.points))
flat_path_clear(&fp)
path_flatten(&fp, &p, MATRIX_IDENTITY, FLATNESS, Rect{-1, -1, 613, 793})
testing.expectf(t, len(fp.points) <= 64,
"a page-sized clip reaches none of it: %d points", len(fp.points))
}
// The substitution has to be invisible. A shape that crosses the clip is
// flattened with and without the cull and both are rasterised: the coverage
// inside the clip must agree to the byte, because the chord that replaces an
// unreachable curve lies in the same convex hull the curve did and cannot
// change the winding number of anything the clip covers.
@(private = "file")
Pf_Cov :: struct {
w, h: int,
buf: []u8,
}
@(private = "file")
pf_span :: proc(user: rawptr, y: int, x0: int, coverage: []u8) {
c := (^Pf_Cov)(user)
if y < 0 || y >= c.h { return }
for i in 0 ..< len(coverage) {
x := x0 + i
if x < 0 || x >= c.w { continue }
c.buf[y * c.w + x] = coverage[i]
}
}
@(private = "file")
pf_coverage :: proc(t: ^testing.T, p: ^Path, cull: Maybe(Rect), even_odd: bool) -> []u8 {
W :: 120
H :: 90
fp := flat_path_make()
defer flat_path_destroy(&fp)
path_flatten(&fp, p, MATRIX_IDENTITY, FLATNESS, cull)
cov := Pf_Cov{w = W, h = H, buf = make([]u8, W * H)}
err := raster_fill(&fp, even_odd, Raster_Clip{0, 0, W, H}, pf_span, &cov)
testing.expect_value(t, err, Error.None)
return cov.buf
}
@(test)
test_path_flatten_cull_draws_the_same_picture :: proc(t: ^testing.T) {
// Three curves that leave the clip and come back, one that never comes
// near it, and one wholly inside: the culled and the full flattening have
// to agree on every pixel.
p := path_make()
defer path_destroy(&p)
path_move_to(&p, 20, 20)
path_curve_to(&p, 1e6, -1e6, -1e6, 1e6, 100, 70)
path_curve_to(&p, -4.98e13, 5.61e13, 7.23e13, 9.65e13, 60, 10)
path_curve_to(&p, 5e5, 5e5, 6e5, 5e5, 90, 80)
path_curve_to(&p, 30, 85, 25, 40, 20, 20)
path_close(&p)
for even_odd in ([2]bool{false, true}) {
full := pf_coverage(t, &p, nil, even_odd)
defer delete(full)
culled := pf_coverage(t, &p, Rect{-1, -1, 121, 91}, even_odd)
defer delete(culled)
ink := 0
diff := 0
first := -1
for i in 0 ..< len(full) {
if full[i] != 0 { ink += 1 }
if full[i] != culled[i] {
diff += 1
if first < 0 { first = i }
}
}
testing.expect(t, ink > 1000, "the shape has to actually cover the clip")
testing.expectf(t, diff == 0, "even_odd=%v: %d pixels differ, first at (%d,%d)",
even_odd, diff, first % 120, first / 120)
}
}