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

1495 lines
57 KiB
Odin

package pdf
import "core:fmt"
import "core:log"
import "core:mem/virtual"
import "core:os"
import "core:path/filepath"
import "core:slice"
import "core:strings"
import "core:testing"
// Annotation tests.
//
// These build whole files rather than Documents by hand: an annotation is a
// dictionary that names an appearance stream that names a resource, and the
// cases worth testing -- an /AS that picks one of two states, a /Rect written
// backwards -- only exist once the references are real. `Pdf_Builder` comes
// from xref_test.odin.
//
// Everything that draws is checked through `An_Dev`, a Device that records the
// calls it is handed. The interpreter's output IS its sequence of device
// calls, so pinning the calls pins the behaviour; the appearance matrix is
// then checked twice over, once against a hand-computed value out of
// `annot_matrix` and once against where the ink actually landed.
// ---------------------------------------------------------------------------
// The recording device
// ---------------------------------------------------------------------------
@(private = "file")
An_Kind :: enum u8 {
Fill,
Stroke,
Clip,
Pop_Clip,
Begin_Group,
End_Group,
Begin_Layer,
End_Layer,
}
@(private = "file")
An_Rec :: struct {
kind: An_Kind,
ctm: Matrix,
// Bounding box of the path in DEVICE space: where the ink went, which is
// what the placement algorithm is ultimately about.
box: Rect,
// The area a group was opened over.
area: Rect,
}
@(private = "file")
An_Dev :: struct {
dev: Device,
log: [dynamic]An_Rec,
}
// an_box is the device-space bound of a path. Nothing is cloned: the
// interpreter reuses the path after the call, and a bounding box is all these
// tests ever ask of it.
@(private = "file")
an_box :: proc(p: ^Path, ctm: Matrix) -> (r: Rect) {
if p == nil || len(p.points) == 0 {
return RECT_EMPTY
}
first := transform_point(ctm, p.points[0])
r = Rect{first.x, first.y, first.x, first.y}
for pt in p.points {
d := transform_point(ctm, pt)
r.x0 = min(r.x0, d.x)
r.y0 = min(r.y0, d.y)
r.x1 = max(r.x1, d.x)
r.y1 = max(r.y1, d.y)
}
return
}
@(private = "file")
an_add :: proc(dev: ^Device, rec: An_Rec) {
append(&(^An_Dev)(dev.user).log, rec)
}
@(private = "file")
an_fill :: proc(dev: ^Device, p: ^Path, even_odd: bool, ctm: Matrix, paint: Paint) {
an_add(dev, An_Rec{kind = .Fill, ctm = ctm, box = an_box(p, ctm)})
}
@(private = "file")
an_stroke :: proc(dev: ^Device, p: ^Path, stroke: ^Stroke_State, ctm: Matrix, paint: Paint) {
an_add(dev, An_Rec{kind = .Stroke, ctm = ctm, box = an_box(p, ctm)})
}
@(private = "file")
an_clip :: proc(dev: ^Device, p: ^Path, even_odd: bool, ctm: Matrix) {
an_add(dev, An_Rec{kind = .Clip, ctm = ctm, box = an_box(p, ctm)})
}
@(private = "file")
an_pop_clip :: proc(dev: ^Device) {
an_add(dev, An_Rec{kind = .Pop_Clip})
}
@(private = "file")
an_begin_group :: proc(dev: ^Device, area: Rect, isolated, knockout: bool, blend: Blend_Mode, alpha: f64, soft_mask: ^Soft_Mask) {
an_add(dev, An_Rec{kind = .Begin_Group, area = area})
}
@(private = "file")
an_end_group :: proc(dev: ^Device) {
an_add(dev, An_Rec{kind = .End_Group})
}
@(private = "file")
an_begin_layer :: proc(dev: ^Device, name: string) {
an_add(dev, An_Rec{kind = .Begin_Layer})
}
@(private = "file")
an_end_layer :: proc(dev: ^Device) {
an_add(dev, An_Rec{kind = .End_Layer})
}
@(private = "file")
an_dev_init :: proc(d: ^An_Dev) {
d.log = make([dynamic]An_Rec, 0, 16)
d.dev = Device{
user = d,
fill_path = an_fill,
stroke_path = an_stroke,
clip_path = an_clip,
pop_clip = an_pop_clip,
begin_group = an_begin_group,
end_group = an_end_group,
begin_layer = an_begin_layer,
end_layer = an_end_layer,
}
}
@(private = "file")
an_dev_destroy :: proc(d: ^An_Dev) {
delete(d.log)
d.log = nil
}
@(private = "file")
an_count :: proc(d: ^An_Dev, kind: An_Kind) -> (n: int) {
for e in d.log {
if e.kind == kind {
n += 1
}
}
return
}
@(private = "file")
an_nth :: proc(d: ^An_Dev, kind: An_Kind, n := 0) -> ^An_Rec {
seen := 0
for &e in d.log {
if e.kind != kind {
continue
}
if seen == n {
return &e
}
seen += 1
}
return nil
}
// ---------------------------------------------------------------------------
// Assertions
// ---------------------------------------------------------------------------
@(private = "file")
AN_EPS :: 1e-9
@(private = "file")
an_expect_matrix :: proc(t: ^testing.T, got, want: Matrix, what: string, loc := #caller_location) {
ok := abs(got.a - want.a) <= AN_EPS && abs(got.b - want.b) <= AN_EPS &&
abs(got.c - want.c) <= AN_EPS && abs(got.d - want.d) <= AN_EPS &&
abs(got.e - want.e) <= AN_EPS && abs(got.f - want.f) <= AN_EPS
testing.expectf(t, ok, "%s: matrix %v, wanted %v", what, got, want, loc = loc)
}
@(private = "file")
an_expect_rect :: proc(t: ^testing.T, got, want: Rect, what: string, tol := AN_EPS,
loc := #caller_location) {
ok := abs(got.x0 - want.x0) <= tol && abs(got.y0 - want.y0) <= tol &&
abs(got.x1 - want.x1) <= tol && abs(got.y1 - want.y1) <= tol
testing.expectf(t, ok, "%s: rect %v, wanted %v", what, got, want, loc = loc)
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
// The appearance every placement test draws: a rectangle filling the whole of
// its own /BBox, so the ink the device records is exactly the box the
// algorithm mapped onto the /Rect.
@(private = "file")
AN_FILL_BBOX :: "0 0 100 50 re f"
// an_page writes the one-page skeleton these tests share: catalog 1, page tree
// 2, page 3. Test objects start at 4.
@(private = "file")
an_page :: proc(pb: ^Pdf_Builder, annots: string, catalog_extra := "") {
pb_obj(pb, 1, fmt.tprintf("<< /Type /Catalog /Pages 2 0 R %s >>", catalog_extra))
pb_obj(pb, 2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
pb_obj(pb, 3, fmt.tprintf(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Annots %s >>", annots))
}
@(private = "file")
an_finish :: proc(pb: ^Pdf_Builder) {
at := pb_classic_xref(pb)
pb_str(pb, "trailer\n<< /Size ")
pb_num(pb, len(pb.offs))
pb_str(pb, " /Root 1 0 R >>\n")
pb_startxref(pb, at)
}
// an_ap writes an appearance stream object.
@(private = "file")
an_ap :: proc(pb: ^Pdf_Builder, num: u32, dict_head: string, content := AN_FILL_BBOX) {
pb_stream_obj(pb, num, fmt.tprintf("/Type /XObject /Subtype /Form %s", dict_head),
transmute([]byte)content)
}
@(private = "file")
an_open :: proc(t: ^testing.T, pb: ^Pdf_Builder, loc := #caller_location) -> (^Document, Page) {
an_finish(pb)
doc, err := open_bytes(pb.buf[:])
testing.expectf(t, err == .None, "open failed: %v", err, loc = loc)
if doc == nil {
return nil, Page{}
}
page, perr := page_get(doc, 0)
testing.expectf(t, perr == .None, "page_get failed: %v", perr, loc = loc)
return doc, page
}
// an_one loads the page's annotations and returns the first, for the many
// tests that build exactly one.
@(private = "file")
an_one :: proc(t: ^testing.T, doc: ^Document, page: ^Page, loc := #caller_location) -> (Annot, bool) {
annots, err := annot_load(doc, page)
defer delete(annots)
testing.expectf(t, err == .None, "annot_load failed: %v", err, loc = loc)
if len(annots) != 1 {
testing.expectf(t, false, "loaded %d annotations, wanted 1", len(annots), loc = loc)
return Annot{}, false
}
return annots[0], true
}
// ---------------------------------------------------------------------------
// The appearance matrix -- ISO 32000-2, 12.5.5
// ---------------------------------------------------------------------------
// A 100x50 appearance in a 200x100 rectangle at (20, 30): scaled by two in
// both directions and moved to the rectangle's corner.
@(test)
test_annot_matrix_bbox_to_rect :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [20 30 220 130] /AP << /N 5 0 R >> >>")
an_ap(&pb, 5, "/BBox [0 0 100 50]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
a, ok := an_one(t, doc, &page)
if !ok { return }
form, invoke, mok := annot_matrix(doc, &a, MATRIX_IDENTITY)
testing.expect(t, mok, "annot_matrix refused a well-formed appearance")
an_expect_matrix(t, form, Matrix{2, 0, 0, 2, 20, 30}, "form matrix")
// No /Matrix on the form, so the two are the same here; the rotating case
// below is where they part company.
an_expect_matrix(t, invoke, Matrix{2, 0, 0, 2, 20, 30}, "invoke matrix")
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
fill := an_nth(&dev, .Fill)
testing.expect(t, fill != nil, "appearance did not draw")
if fill == nil { return }
an_expect_matrix(t, fill.ctm, Matrix{2, 0, 0, 2, 20, 30}, "ctm the fill was handed")
// The appearance fills its own /BBox, so its ink must land on exactly the
// annotation's rectangle.
an_expect_rect(t, fill.box, Rect{20, 30, 220, 130}, "ink")
}
// A /Matrix that rotates 90 degrees. The transformed appearance box is 50 wide
// and 100 tall where the /BBox is 100 wide and 50 tall, so a placement that
// skipped the bounding-box step would scale both axes wrongly and land the ink
// outside the rectangle.
@(test)
test_annot_matrix_rotated :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [0 0 50 100] /AP << /N 5 0 R >> >>")
an_ap(&pb, 5, "/BBox [0 0 100 50] /Matrix [0 1 -1 0 0 0]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
a, ok := an_one(t, doc, &page)
if !ok { return }
// By hand: the corners (0,0) (100,0) (0,50) (100,50) rotate to (0,0)
// (0,100) (-50,0) (-50,100), whose bounding box is [-50 0 0 100] -- 50 by
// 100, the same size as the /Rect. A is therefore a pure translation of
// +50 in x, and the full matrix is /Matrix * A.
form, invoke, mok := annot_matrix(doc, &a, MATRIX_IDENTITY)
testing.expect(t, mok, "annot_matrix refused a rotated appearance")
an_expect_matrix(t, invoke, Matrix{1, 0, 0, 1, 50, 0}, "A")
an_expect_matrix(t, form, Matrix{0, 1, -1, 0, 50, 0}, "form matrix")
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
fill := an_nth(&dev, .Fill)
testing.expect(t, fill != nil, "rotated appearance did not draw")
if fill == nil { return }
an_expect_matrix(t, fill.ctm, Matrix{0, 1, -1, 0, 50, 0}, "ctm the fill was handed")
an_expect_rect(t, fill.box, Rect{0, 0, 50, 100}, "ink")
}
// 7.9.5: a rectangle's corners may be given in either order. A /Rect written
// backwards must place the appearance exactly as the same rectangle written
// forwards does.
@(test)
test_annot_rect_corners_reversed :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [220 130 20 30] /AP << /N 5 0 R >> >>")
an_ap(&pb, 5, "/BBox [0 0 100 50]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
a, ok := an_one(t, doc, &page)
if !ok { return }
an_expect_rect(t, a.rect, Rect{20, 30, 220, 130}, "loaded /Rect")
form, _, mok := annot_matrix(doc, &a, MATRIX_IDENTITY)
testing.expect(t, mok, "annot_matrix refused a reversed /Rect")
an_expect_matrix(t, form, Matrix{2, 0, 0, 2, 20, 30}, "form matrix")
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
fill := an_nth(&dev, .Fill)
testing.expect(t, fill != nil, "appearance did not draw")
if fill != nil {
an_expect_rect(t, fill.box, Rect{20, 30, 220, 130}, "ink")
}
}
// The page CTM is the last multiplication of step 4, and a renderer's is
// always a flip: y-up page space to y-down device space.
@(test)
test_annot_page_ctm_applies :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [20 30 220 130] /AP << /N 5 0 R >> >>")
an_ap(&pb, 5, "/BBox [0 0 100 50]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
a, ok := an_one(t, doc, &page)
if !ok { return }
flip := Matrix{1, 0, 0, -1, 0, 400}
form, _, mok := annot_matrix(doc, &a, flip)
testing.expect(t, mok, "annot_matrix refused a flipped page")
an_expect_matrix(t, form, Matrix{2, 0, 0, -2, 20, 370}, "form matrix under the page flip")
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, flip), Error.None)
fill := an_nth(&dev, .Fill)
testing.expect(t, fill != nil, "appearance did not draw")
if fill != nil {
// The rectangle's y range 30..130 in page space is 270..370 in a
// 400-point-tall device space.
an_expect_rect(t, fill.box, Rect{20, 270, 220, 370}, "ink")
}
}
// A /BBox with no width has nothing in it to stretch onto the rectangle, and
// step 3's scale factor would divide by zero.
@(test)
test_annot_degenerate_bbox_draws_nothing :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R 6 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [20 30 220 130] /AP << /N 5 0 R >> >>")
an_ap(&pb, 5, "/BBox [0 0 0 50]")
// A /Matrix that collapses an honest /BBox is the same defect written the
// other way round, and the bounding-box step is where it shows up.
pb_obj(&pb, 6, "<< /Type /Annot /Subtype /Stamp /Rect [20 30 220 130] /AP << /N 7 0 R >> >>")
an_ap(&pb, 7, "/BBox [0 0 100 50] /Matrix [1 0 0 0 0 0]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
annots, err := annot_load(doc, &page)
defer delete(annots)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, len(annots), 2)
if len(annots) != 2 { return }
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
for &a in annots {
_, _, mok := annot_matrix(doc, &a, MATRIX_IDENTITY)
testing.expect(t, !mok, "annot_matrix accepted a degenerate appearance box")
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
}
testing.expectf(t, len(dev.log) == 0, "a degenerate appearance drew %d calls", len(dev.log))
}
// An annotation whose /Rect has no area shows nothing however the appearance
// is scaled -- and a missing /Rect reads as exactly that rectangle.
@(test)
test_annot_empty_rect_draws_nothing :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /AP << /N 5 0 R >> >>")
an_ap(&pb, 5, "/BBox [0 0 100 50]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
a, ok := an_one(t, doc, &page)
if !ok { return }
_, _, mok := annot_matrix(doc, &a, MATRIX_IDENTITY)
testing.expect(t, !mok, "annot_matrix accepted an annotation with no /Rect")
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expectf(t, len(dev.log) == 0, "an annotation with no /Rect drew %d calls", len(dev.log))
}
// /BBox is required and is nonetheless sometimes missing. There is nothing to
// map onto the /Rect then, so the appearance is drawn where its own
// coordinates put it rather than not at all.
@(test)
test_annot_missing_bbox_draws_unplaced :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [20 30 220 130] /AP << /N 5 0 R >> >>")
an_ap(&pb, 5, "/Matrix [1 0 0 1 7 9]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
a, ok := an_one(t, doc, &page)
if !ok { return }
before := len(doc.repairs)
form, _, mok := annot_matrix(doc, &a, MATRIX_IDENTITY)
testing.expect(t, mok, "a form with no /BBox should still be placed")
an_expect_matrix(t, form, Matrix{1, 0, 0, 1, 7, 9}, "form matrix with no /BBox")
testing.expect(t, len(doc.repairs) > before, "a missing /BBox recorded no repair")
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
fill := an_nth(&dev, .Fill)
testing.expect(t, fill != nil, "a form with no /BBox drew nothing")
if fill != nil {
an_expect_rect(t, fill.box, Rect{7, 9, 107, 59}, "ink")
}
// With no /BBox there is no box to clip to either.
testing.expect_value(t, an_count(&dev, .Clip), 0)
}
// A scale factor that overflows to infinity is the finiteness boundary this
// file owes the rest of the library: nothing below the Device line may see a
// NaN, and a 1e300-wide rectangle over a 1e-300-wide box produces one.
@(test)
test_annot_matrix_overflow_rejected :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [0 0 1e300 1e300] /AP << /N 5 0 R >> >>")
an_ap(&pb, 5, "/BBox [0 0 1e-300 1e-300]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
a, ok := an_one(t, doc, &page)
if !ok { return }
_, _, mok := annot_matrix(doc, &a, MATRIX_IDENTITY)
testing.expect(t, !mok, "annot_matrix returned a non-finite placement")
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expectf(t, len(dev.log) == 0, "a non-finite placement drew %d calls", len(dev.log))
}
// ---------------------------------------------------------------------------
// Choosing an appearance -- /AP, /N and /AS
// ---------------------------------------------------------------------------
// The two states a checkbox holds. They are told apart by how many times they
// fill, which is a property of the stream that ran and not of where it landed.
@(private = "file")
AN_STATE_OFF :: "0 0 100 50 re f"
@(private = "file")
AN_STATE_YES :: "0 0 100 50 re f 10 10 20 20 re f"
@(private = "file")
an_states :: proc(pb: ^Pdf_Builder, as_entry: string) {
an_page(pb, "[4 0 R]")
pb_obj(pb, 4, fmt.tprintf(
"<< /Type /Annot /Subtype /Widget /Rect [0 0 100 50] %s "+
"/AP << /N << /Off 5 0 R /Yes 6 0 R >> >> >>", as_entry))
an_ap(pb, 5, "/BBox [0 0 100 50]", AN_STATE_OFF)
an_ap(pb, 6, "/BBox [0 0 100 50]", AN_STATE_YES)
}
// /AS is how a checkbox says which of its two looks it is wearing.
@(test)
test_annot_as_selects_state :: proc(t: ^testing.T) {
Case :: struct { state: string, fills: int }
// A composite literal cannot open a `for ... in`: the parser would read its
// brace as the loop body.
cases := []Case{{"Off", 1}, {"Yes", 2}}
for c in cases {
state, want_fills := c.state, c.fills
pb := pb_make()
defer pb_destroy(&pb)
an_states(&pb, fmt.tprintf("/AS /%s", state))
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { continue }
a, ok := an_one(t, doc, &page)
if !ok { continue }
testing.expectf(t, a.state == state, "/AS %v selected state %q", state, a.state)
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expectf(t, an_count(&dev, .Fill) == want_fills,
"/AS %v drew %d fills, wanted %d", state, an_count(&dev, .Fill), want_fills)
}
}
// The unchecked checkbox: /N holds only /Yes and /AS says /Off. The missing
// entry IS the appearance of the off state, and falling back to the only
// stream present would tick every empty box on the page.
@(test)
test_annot_as_names_missing_state :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Widget /Rect [0 0 100 50] /AS /Off "+
"/AP << /N << /Yes 5 0 R >> >> >>")
an_ap(&pb, 5, "/BBox [0 0 100 50]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
a, ok := an_one(t, doc, &page)
if !ok { return }
testing.expect(t, a.ap == nil, "/AS /Off selected an appearance that is not there")
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expectf(t, len(dev.log) == 0, "an unchecked box drew %d calls", len(dev.log))
}
// With one state and no /AS there is nothing to disambiguate, and viewers all
// draw it. With two there is no way to choose, and drawing the wrong one is
// worse than drawing none.
@(test)
test_annot_no_as_falls_back_to_sole_state :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Widget /Rect [0 0 100 50] "+
"/AP << /N << /Yes 5 0 R >> >> >>")
an_ap(&pb, 5, "/BBox [0 0 100 50]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
a, ok := an_one(t, doc, &page)
if !ok { return }
testing.expect(t, a.ap != nil, "the only appearance state was not selected")
testing.expectf(t, a.state == "Yes", "state %q, wanted Yes", a.state)
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expect_value(t, an_count(&dev, .Fill), 1)
}
@(test)
test_annot_no_as_with_two_states_draws_nothing :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_states(&pb, "")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
before := len(doc.repairs)
a, ok := an_one(t, doc, &page)
if !ok { return }
testing.expect(t, a.ap == nil, "an ambiguous appearance was drawn anyway")
testing.expect(t, len(doc.repairs) > before, "an ambiguous appearance recorded no repair")
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expectf(t, len(dev.log) == 0, "an ambiguous appearance drew %d calls", len(dev.log))
}
// An annotation with no /AP draws nothing: this library does not synthesise
// appearances. It is still loaded, because a link's rectangle is worth having
// whether or not anything is painted in it.
@(test)
test_annot_without_ap_is_loaded_but_not_drawn :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R 5 0 R]")
// A link with a border a synthesising reader would draw.
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Link /Rect [0 0 100 50] /Border [0 0 3] >>")
// Only /N is page content: a /D and an /R are answers to a pointer.
pb_obj(&pb, 5, "<< /Type /Annot /Subtype /Widget /Rect [0 0 100 50] "+
"/AP << /D 6 0 R /R 6 0 R >> >>")
an_ap(&pb, 6, "/BBox [0 0 100 50]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
annots, err := annot_load(doc, &page)
defer delete(annots)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, len(annots), 2)
if len(annots) != 2 { return }
testing.expect(t, annots[0].ap == nil, "a /Link with no /AP found an appearance")
testing.expect(t, annots[1].ap == nil, "/D was drawn as if it were /N")
testing.expect(t, annots[0].visible, "an annotation with no /AP is still visible content")
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run_page(doc, &page, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expectf(t, len(dev.log) == 0, "annotations with no /AP drew %d calls", len(dev.log))
}
// ---------------------------------------------------------------------------
// /F flags and subtypes
// ---------------------------------------------------------------------------
@(test)
test_annot_flags_read :: proc(t: ^testing.T) {
testing.expect_value(t, annot_flags_read(0), Annot_Flags{})
// The format numbers its bits from 1, so /F 2 is the second bit.
testing.expect_value(t, annot_flags_read(1), Annot_Flags{.Invisible})
testing.expect_value(t, annot_flags_read(2), Annot_Flags{.Hidden})
testing.expect_value(t, annot_flags_read(4), Annot_Flags{.Print})
testing.expect_value(t, annot_flags_read(32), Annot_Flags{.No_View})
testing.expect_value(t, annot_flags_read(6), Annot_Flags{.Hidden, .Print})
testing.expect_value(t, annot_flags_read(0x3ff), Annot_Flags{
.Invisible, .Hidden, .Print, .No_Zoom, .No_Rotate,
.No_View, .Read_Only, .Locked, .Toggle_No_View, .Locked_Contents,
})
// Bits above the ten the format defines are reserved, and a set holding
// members no enumerator names is not something to hand a caller.
testing.expect_value(t, annot_flags_read(1 << 20), Annot_Flags{})
testing.expect_value(t, annot_flags_read(-1), annot_flags_read(0x3ff))
}
// Hidden and NoView suppress the drawing; Invisible does not, because it only
// governs an annotation the reader has no handler for and an appearance stream
// is a handler.
@(test)
test_annot_flags_suppress_drawing :: proc(t: ^testing.T) {
Case :: struct { f: string, subtype: string, draws: bool }
cases := []Case{
{"/F 0", "Stamp", true},
{"/F 1", "Stamp", true}, // Invisible
{"/F 2", "Stamp", false}, // Hidden
{"/F 3", "Stamp", false}, // Invisible + Hidden
{"/F 4", "Stamp", true}, // Print
{"/F 32", "Stamp", false}, // NoView
{"/F 36", "Stamp", false}, // Print + NoView
{"", "Stamp", true},
// A popup is the window a markup annotation opens, never page content.
{"/F 4", "Popup", false},
}
for c in cases {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, fmt.tprintf(
"<< /Type /Annot /Subtype /%s /Rect [0 0 100 50] %s /AP << /N 5 0 R >> >>",
c.subtype, c.f))
an_ap(&pb, 5, "/BBox [0 0 100 50]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { continue }
a, ok := an_one(t, doc, &page)
if !ok { continue }
testing.expectf(t, a.visible == c.draws, "%s /%s: visible %v, wanted %v",
c.f, c.subtype, a.visible, c.draws)
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run(doc, &a, &dev.dev, MATRIX_IDENTITY), Error.None)
drew := an_count(&dev, .Fill) > 0
testing.expectf(t, drew == c.draws, "%s /%s: drew %v, wanted %v",
c.f, c.subtype, drew, c.draws)
}
}
// ---------------------------------------------------------------------------
// Loading a page's /Annots
// ---------------------------------------------------------------------------
@(test)
test_annot_load_page :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
// A null entry and a number are both how a producer leaves a hole in the
// array rather than rewriting it; a direct dictionary is legal and has no
// object number of its own.
an_page(&pb, "[4 0 R null 5 0 R 7 << /Type /Annot /Subtype /Text /Rect [1 2 3 4] >>]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Link /Rect [10 20 110 70] /F 4 >>")
pb_obj(&pb, 5, "<< /Type /Annot /Subtype /Widget /Rect [0 0 100 50] /F 2 "+
"/AP << /N 6 0 R >> >>")
an_ap(&pb, 6, "/BBox [0 0 100 50]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
annots, err := annot_load(doc, &page)
defer delete(annots)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, len(annots), 3)
if len(annots) != 3 { return }
testing.expectf(t, annots[0].subtype == "Link", "subtype %q", annots[0].subtype)
an_expect_rect(t, annots[0].rect, Rect{10, 20, 110, 70}, "link /Rect")
testing.expect_value(t, annots[0].flags, Annot_Flags{.Print})
testing.expect_value(t, annots[0].ref, Ref{4, 0})
testing.expect(t, annots[0].dict != nil, "the annotation dictionary was not kept")
testing.expectf(t, annots[1].subtype == "Widget", "subtype %q", annots[1].subtype)
testing.expect(t, annots[1].ap != nil, "the widget's appearance was not found")
testing.expect(t, !annots[1].visible, "/F 2 left the widget visible")
testing.expect_value(t, annots[1].ref, Ref{5, 0})
// A dictionary written into the array directly has no object number.
testing.expectf(t, annots[2].subtype == "Text", "subtype %q", annots[2].subtype)
testing.expect_value(t, annots[2].ref, Ref{})
}
// A page with no /Annots, and one whose /Annots is not an array, both have no
// annotations. Neither is a failure.
@(test)
test_annot_load_no_annots :: proc(t: ^testing.T) {
sources := []string{"", "/Annots 99 0 R", "/Annots << /Bad true >>", "/Annots []"}
for src in sources {
pb := pb_make()
defer pb_destroy(&pb)
pb_obj(&pb, 1, "<< /Type /Catalog /Pages 2 0 R >>")
pb_obj(&pb, 2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
pb_obj(&pb, 3, fmt.tprintf(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] %s >>", src))
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { continue }
annots, err := annot_load(doc, &page)
defer delete(annots)
testing.expectf(t, err == .None, "%q: annot_load failed: %v", src, err)
testing.expectf(t, len(annots) == 0, "%q: loaded %d annotations", src, len(annots))
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run_page(doc, &page, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expectf(t, len(dev.log) == 0, "%q: drew %d calls", src, len(dev.log))
}
}
// annot_run_page draws the drawable ones in /Annots order, which is the order
// they paint over one another in, and silently steps over the rest.
@(test)
test_annot_run_page_order :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R 5 0 R 6 0 R 7 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [0 0 10 10] /AP << /N 8 0 R >> >>")
pb_obj(&pb, 5, "<< /Type /Annot /Subtype /Stamp /Rect [0 0 10 10] /F 2 /AP << /N 8 0 R >> >>")
pb_obj(&pb, 6, "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>")
pb_obj(&pb, 7, "<< /Type /Annot /Subtype /Stamp /Rect [100 100 110 110] /AP << /N 8 0 R >> >>")
an_ap(&pb, 8, "/BBox [0 0 10 10]", "0 0 10 10 re f")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run_page(doc, &page, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expect_value(t, an_count(&dev, .Fill), 2)
if an_count(&dev, .Fill) != 2 { return }
an_expect_rect(t, an_nth(&dev, .Fill, 0).box, Rect{0, 0, 10, 10}, "first stamp")
an_expect_rect(t, an_nth(&dev, .Fill, 1).box, Rect{100, 100, 110, 110}, "second stamp")
}
// ---------------------------------------------------------------------------
// What the appearance gets from the interpreter
// ---------------------------------------------------------------------------
// A form XObject's /BBox clips its contents (8.10.2), and an appearance stream
// is a form XObject. The clip has to be pushed before the paint and popped
// after it, or every annotation after this one draws through it.
@(test)
test_annot_bbox_clips_and_balances :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [20 30 220 130] /AP << /N 5 0 R >> >>")
// The content spills far outside the /BBox that clips it.
an_ap(&pb, 5, "/BBox [0 0 100 50]", "-500 -500 2000 2000 re f")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run_page(doc, &page, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expect_value(t, len(dev.log), 3)
if len(dev.log) != 3 { return }
testing.expect_value(t, dev.log[0].kind, An_Kind.Clip)
testing.expect_value(t, dev.log[1].kind, An_Kind.Fill)
testing.expect_value(t, dev.log[2].kind, An_Kind.Pop_Clip)
// The clip is the /BBox under the appearance matrix: the annotation's
// rectangle, exactly.
an_expect_rect(t, dev.log[0].box, Rect{20, 30, 220, 130}, "clip")
}
// An appearance with a transparency group composites as one object, which is
// how a stamp with a drop shadow keeps its alpha from being applied twice.
@(test)
test_annot_transparency_group :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [20 30 220 130] /AP << /N 5 0 R >> >>")
an_ap(&pb, 5, "/BBox [0 0 100 50] /Group << /S /Transparency /I true >>")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run_page(doc, &page, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expect_value(t, an_count(&dev, .Begin_Group), 1)
testing.expect_value(t, an_count(&dev, .End_Group), 1)
testing.expect_value(t, an_count(&dev, .Fill), 1)
grp := an_nth(&dev, .Begin_Group)
if grp != nil {
an_expect_rect(t, grp.area, Rect{20, 30, 220, 130}, "group area")
}
}
// An appearance that invokes itself is a file that would otherwise render
// until the stack ends. The interpreter's visited set stops it; what this
// pins is that annotations go through that interpreter rather than around it.
@(test)
test_annot_self_referencing_appearance :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [0 0 100 50] /AP << /N 5 0 R >> >>")
pb_stream_obj(&pb, 5,
"/Type /XObject /Subtype /Form /BBox [0 0 100 50] "+
"/Resources << /XObject << /Me 5 0 R >> >>",
transmute([]byte)string("/Me Do 0 0 100 50 re f"))
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run_page(doc, &page, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expect_value(t, an_count(&dev, .Fill), 1)
}
// An annotation may belong to an optional-content group (12.5.2). One the
// catalog's default configuration switches off is not drawn -- and a run that
// asks for hidden content, which is what text extraction does, draws it.
@(test)
test_annot_optional_content :: proc(t: ^testing.T) {
build :: proc(pb: ^Pdf_Builder) {
an_page(pb, "[4 0 R]",
"/OCProperties << /OCGs [7 0 R] /D << /OFF [7 0 R] >> >>")
pb_obj(pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [0 0 100 50] "+
"/OC 7 0 R /AP << /N 5 0 R >> >>")
an_ap(pb, 5, "/BBox [0 0 100 50]")
pb_obj(pb, 7, "<< /Type /OCG /Name (Watermark) >>")
}
{
pb := pb_make()
defer pb_destroy(&pb)
build(&pb)
doc, page := an_open(t, &pb)
defer close(doc)
if doc != nil {
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run_page(doc, &page, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expectf(t, an_count(&dev, .Fill) == 0,
"a switched-off annotation drew %d fills", an_count(&dev, .Fill))
}
}
{
pb := pb_make()
defer pb_destroy(&pb)
build(&pb)
doc, page := an_open(t, &pb)
defer close(doc)
if doc != nil {
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
opts := Content_Options{ignore_optional_content = true}
testing.expect_value(t, annot_run_page(doc, &page, &dev.dev, MATRIX_IDENTITY, opts),
Error.None)
testing.expect_value(t, an_count(&dev, .Fill), 1)
}
}
}
// An annotation with no /OC must not be wrapped in a layer, or every display
// list gains a pair of calls per annotation.
@(test)
test_annot_without_oc_opens_no_layer :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [0 0 100 50] /AP << /N 5 0 R >> >>")
an_ap(&pb, 5, "/BBox [0 0 100 50]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
testing.expect_value(t, annot_run_page(doc, &page, &dev.dev, MATRIX_IDENTITY), Error.None)
testing.expect_value(t, an_count(&dev, .Begin_Layer), 0)
testing.expect_value(t, an_count(&dev, .End_Layer), 0)
}
// ---------------------------------------------------------------------------
// Damage and caller error
// ---------------------------------------------------------------------------
@(test)
test_annot_invalid_arguments :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Stamp /Rect [0 0 100 50] /AP << /N 5 0 R >> >>")
an_ap(&pb, 5, "/BBox [0 0 100 50]")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
a, ok := an_one(t, doc, &page)
if !ok { return }
nothing, err := annot_load(nil, &page)
testing.expect_value(t, err, Error.Invalid_Argument)
testing.expect(t, nothing == nil, "annot_load returned a slice for a nil Document")
_, err2 := annot_load(doc, nil)
testing.expect_value(t, err2, Error.Invalid_Argument)
testing.expect_value(t, annot_run(nil, &a, nil, MATRIX_IDENTITY), Error.Invalid_Argument)
testing.expect_value(t, annot_run(doc, nil, nil, MATRIX_IDENTITY), Error.Invalid_Argument)
testing.expect_value(t, annot_run_page(doc, nil, nil, MATRIX_IDENTITY), Error.Invalid_Argument)
// A matrix with a NaN or an infinity in it must not reach the geometry.
bad := Matrix{1, 0, 0, 1, 0, max(f64) * 2}
testing.expect_value(t, annot_run(doc, &a, nil, bad), Error.Invalid_Argument)
testing.expect_value(t, annot_run_page(doc, &page, nil, bad), Error.Invalid_Argument)
// A nil Device runs the whole interpreter and draws nothing, which is how
// the annotation layer is measured and how a caller collects its repairs.
testing.expect_value(t, annot_run(doc, &a, nil, MATRIX_IDENTITY), Error.None)
testing.expect_value(t, annot_run_page(doc, &page, nil, MATRIX_IDENTITY), Error.None)
}
// Every one of these is a real shape of damage, and none of them may crash,
// hang, or stop the page.
@(test)
test_annot_malformed :: proc(t: ^testing.T) {
Case :: struct { subtype, annot, ap, content: string, draws: bool }
BOX :: "/BBox [0 0 100 50]"
cases := []Case{
// /AP that is not a dictionary, and /N that is not a stream.
{"/Subtype /Stamp", "/AP 7", BOX, AN_FILL_BBOX, false},
{"/Subtype /Stamp", "/AP << /N 7 >>", BOX, AN_FILL_BBOX, false},
{"/Subtype /Stamp", "/AP << /N (text) >>", BOX, AN_FILL_BBOX, false},
{"/Subtype /Stamp", "/AP << /N << >> >>", BOX, AN_FILL_BBOX, false},
// /Rect that is not a rectangle, or is not four numbers. Entries that
// are not numbers read as zero, which is an empty rectangle.
{"/Subtype /Stamp", "/Rect (nope) /AP << /N 5 0 R >>", BOX, AN_FILL_BBOX, false},
{"/Subtype /Stamp", "/Rect [0 0 100] /AP << /N 5 0 R >>", BOX, AN_FILL_BBOX, false},
{"/Subtype /Stamp", "/Rect [/a /b /c /d] /AP << /N 5 0 R >>", BOX, AN_FILL_BBOX, false},
// A /Subtype that is missing entirely, and one nobody has heard of.
// Neither is a reason not to draw what the file says to draw.
{"", "/Rect [0 0 100 50] /AP << /N 5 0 R >>", BOX, AN_FILL_BBOX, true},
{"/Subtype /Hologram", "/Rect [0 0 100 50] /AP << /N 5 0 R >>", BOX, AN_FILL_BBOX, true},
// A /Matrix too short to be one falls back to the identity, and a
// /BBox with its corners the wrong way round is still a box.
{"/Subtype /Stamp", "/Rect [0 0 100 50] /AP << /N 5 0 R >>",
"/BBox [0 0 100 50] /Matrix [1 0 0]", AN_FILL_BBOX, true},
{"/Subtype /Stamp", "/Rect [0 0 100 50] /AP << /N 5 0 R >>",
"/BBox [100 50 0 0]", AN_FILL_BBOX, true},
// /F that is not a number reads as no flags at all.
{"/Subtype /Stamp", "/Rect [0 0 100 50] /F (2) /AP << /N 5 0 R >>",
BOX, AN_FILL_BBOX, true},
// An appearance with no content in it draws nothing and is not damage.
{"/Subtype /Stamp", "/Rect [0 0 100 50] /AP << /N 5 0 R >>", BOX, "", false},
// Operator soup in the appearance: the interpreter steps over it and
// the fill that follows still lands.
{"/Subtype /Stamp", "/Rect [0 0 100 50] /AP << /N 5 0 R >>", BOX,
"q 1 0 0 rg /Nope gs ] ] } 0 0 100 50 re f Q", true},
}
for c, i in cases {
pb := pb_make()
defer pb_destroy(&pb)
an_page(&pb, "[4 0 R]")
pb_obj(&pb, 4, fmt.tprintf("<< /Type /Annot %s %s >>", c.subtype, c.annot))
an_ap(&pb, 5, c.ap, c.content)
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { continue }
dev: An_Dev
an_dev_init(&dev)
defer an_dev_destroy(&dev)
err := annot_run_page(doc, &page, &dev.dev, MATRIX_IDENTITY)
testing.expectf(t, err == .None, "case %d: annot_run_page returned %v", i, err)
drew := an_count(&dev, .Fill) > 0
testing.expectf(t, drew == c.draws, "case %d (%s | %s): drew %v, wanted %v",
i, c.annot, c.ap, drew, c.draws)
}
}
// An /Annots array far longer than any page has is clamped rather than walked.
@(test)
test_annot_load_clamps :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
refs := make([dynamic]byte)
defer delete(refs)
append(&refs, '[')
for _ in 0 ..< MAX_ANNOTS + 100 {
append(&refs, ..transmute([]byte)string("4 0 R "))
}
append(&refs, ']')
an_page(&pb, string(refs[:]))
pb_obj(&pb, 4, "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>")
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
annots, err := annot_load(doc, &page)
defer delete(annots)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, len(annots), MAX_ANNOTS)
}
// ---------------------------------------------------------------------------
// Corpus
//
// Every annotation of every page of every real PDF in the directory named by
// PDF_ODIN_CORPUS, skipped silently when that is unset. There is no correct
// answer to compare a real annotation against, so what is asserted is the
// contract: loading terminates and never fails, every rectangle and matrix
// that comes out is finite, the device stacks balance, and -- the property the
// whole file exists for -- the appearance's own bounding box lands on exactly
// the annotation's /Rect.
// ---------------------------------------------------------------------------
@(private = "file")
AC_MAX_FILE :: 64 << 20
@(private = "file")
AC_MAX_DEPTH :: 8
@(private = "file")
Ac_Dev :: struct {
dev: Device,
// Live nesting, so an unbalanced pop is caught where it happens.
clip, group, layer: int,
negative: bool,
fills, strokes, images, shades, texts: int,
}
@(private = "file")
ac_self :: proc(dev: ^Device) -> ^Ac_Dev { return (^Ac_Dev)(dev.user) }
@(private = "file")
ac_fill :: proc(dev: ^Device, p: ^Path, even_odd: bool, ctm: Matrix, paint: Paint) {
ac_self(dev).fills += 1
}
@(private = "file")
ac_stroke :: proc(dev: ^Device, p: ^Path, s: ^Stroke_State, ctm: Matrix, paint: Paint) {
ac_self(dev).strokes += 1
}
@(private = "file")
ac_text :: proc(dev: ^Device, txt: ^Text, ctm: Matrix, paint: Paint) {
ac_self(dev).texts += len(txt.items)
}
@(private = "file")
ac_ignore_text :: proc(dev: ^Device, txt: ^Text, ctm: Matrix) {
ac_self(dev).texts += len(txt.items)
}
@(private = "file")
ac_image :: proc(dev: ^Device, img: ^Image, ctm: Matrix, paint: Paint) {
ac_self(dev).images += 1
}
@(private = "file")
ac_shade :: proc(dev: ^Device, sh: ^Shading, ctm: Matrix, paint: Paint) {
ac_self(dev).shades += 1
}
@(private = "file")
ac_clip :: proc(dev: ^Device, p: ^Path, even_odd: bool, ctm: Matrix) {
ac_self(dev).clip += 1
}
@(private = "file")
ac_clip_stroke :: proc(dev: ^Device, p: ^Path, s: ^Stroke_State, ctm: Matrix) {
ac_self(dev).clip += 1
}
@(private = "file")
ac_clip_text :: proc(dev: ^Device, txt: ^Text, ctm: Matrix) {
ac_self(dev).clip += 1
}
@(private = "file")
ac_clip_image_mask :: proc(dev: ^Device, img: ^Image, ctm: Matrix) {
ac_self(dev).clip += 1
}
@(private = "file")
ac_pop_clip :: proc(dev: ^Device) {
d := ac_self(dev)
d.clip -= 1
if d.clip < 0 { d.negative = true }
}
@(private = "file")
ac_begin_group :: proc(dev: ^Device, area: Rect, isolated, knockout: bool, blend: Blend_Mode, alpha: f64, soft_mask: ^Soft_Mask) {
ac_self(dev).group += 1
}
@(private = "file")
ac_end_group :: proc(dev: ^Device) {
d := ac_self(dev)
d.group -= 1
if d.group < 0 { d.negative = true }
}
@(private = "file")
ac_begin_layer :: proc(dev: ^Device, name: string) {
ac_self(dev).layer += 1
}
@(private = "file")
ac_end_layer :: proc(dev: ^Device) {
d := ac_self(dev)
d.layer -= 1
if d.layer < 0 { d.negative = true }
}
@(private = "file")
ac_init :: proc(d: ^Ac_Dev) {
d.dev = Device{
user = d,
fill_path = ac_fill,
stroke_path = ac_stroke,
fill_text = ac_text,
stroke_text = proc(dev: ^Device, txt: ^Text, s: ^Stroke_State, ctm: Matrix, paint: Paint) {
ac_self(dev).texts += len(txt.items)
},
ignore_text = ac_ignore_text,
fill_image = ac_image,
fill_image_mask = ac_image,
fill_shade = ac_shade,
clip_path = ac_clip,
clip_stroke_path = ac_clip_stroke,
clip_text = ac_clip_text,
clip_image_mask = ac_clip_image_mask,
pop_clip = ac_pop_clip,
begin_group = ac_begin_group,
end_group = ac_end_group,
begin_layer = ac_begin_layer,
end_layer = ac_end_layer,
}
}
@(private = "file")
ac_corpus_dir :: proc(allocator := context.allocator) -> string {
raw := os.get_env("PDF_ODIN_CORPUS", allocator)
trimmed := strings.trim_space(raw)
out := strings.clone(trimmed, allocator)
delete(raw, allocator)
return out
}
@(private = "file")
ac_walk :: proc(dir: string, out: ^[dynamic]string, depth := 0) {
if depth > AC_MAX_DEPTH { return }
entries, err := os.read_directory_by_path(dir, -1, context.allocator)
if err != nil { return }
defer os.file_info_slice_delete(entries, context.allocator)
slice.sort_by(entries, proc(a, b: os.File_Info) -> bool { return a.name < b.name })
for e in entries {
if e.type == .Directory {
ac_walk(e.fullpath, out, depth + 1)
continue
}
if e.type != .Regular || e.size > AC_MAX_FILE { continue }
lower := strings.to_lower(e.name, context.temp_allocator)
if !strings.has_suffix(lower, ".pdf") { continue }
append(out, strings.clone(e.fullpath))
}
}
@(test)
test_annot_corpus :: proc(t: ^testing.T) {
dir := ac_corpus_dir()
defer delete(dir)
if dir == "" {
log.info("PDF_ODIN_CORPUS is unset; skipping the annotation corpus test")
return
}
if !os.is_dir(dir) {
log.infof("PDF_ODIN_CORPUS is %q, which is not a directory; skipping", dir)
return
}
paths := make([dynamic]string)
defer {
for path in paths { delete(path) }
delete(paths)
}
ac_walk(dir, &paths)
if len(paths) == 0 {
log.infof("no PDFs under %q; skipping", dir)
return
}
// One arena for the whole sweep, reset between pages: the way a renderer
// is meant to drive this.
arena: virtual.Arena
if aerr := virtual.arena_init_growing(&arena); aerr != nil {
testing.expect(t, false, "could not make a scratch arena")
return
}
defer virtual.arena_destroy(&arena)
opts := Content_Options{allocator = virtual.arena_allocator(&arena)}
total: Ac_Dev
files, pages, n_annots, n_ap, n_drawn, n_hidden, n_states, n_oc, n_placed := 0, 0, 0, 0, 0, 0, 0, 0, 0
for path in paths {
doc, err := open_file(path)
if err != .None || doc == nil { continue }
defer close(doc)
files += 1
name := filepath.base(path)
for i in 0 ..< page_count(doc) {
page, perr := page_get(doc, i)
if perr != .None { continue }
pages += 1
annots, aerr := annot_load(doc, &page, context.allocator)
defer delete(annots)
testing.expectf(t, aerr == .None, "%s page %d: annot_load returned %v", name, i, aerr)
for &a in annots {
n_annots += 1
testing.expectf(t, rect_is_finite(a.rect), "%s page %d: /Rect %v is not finite",
name, i, a.rect)
if a.ap == nil { continue }
n_ap += 1
if a.state != "" { n_states += 1 }
if dict_get_raw(a.dict, "OC") != nil { n_oc += 1 }
if !a.visible {
n_hidden += 1
continue
}
// The placement invariant, in page space: the appearance's own
// bounding box, mapped by the matrix the algorithm produced,
// must be the annotation's rectangle. Everything in 12.5.5 is
// in service of this one equality.
form, _, ok := annot_matrix(doc, &a, MATRIX_IDENTITY)
if !ok { continue }
n_drawn += 1
testing.expectf(t, matrix_is_finite(form),
"%s page %d: appearance matrix %v is not finite", name, i, form)
bbox, has_bbox := object_to_rect(doc, dict_get(doc, a.ap.dict, "BBox"))
if !has_bbox || rect_is_empty(bbox) { continue }
got := transform_rect(form, bbox)
tol := 1e-6 * max(1, abs(a.rect.x0), abs(a.rect.y0),
abs(a.rect.x1), abs(a.rect.y1))
placed := abs(got.x0 - a.rect.x0) <= tol && abs(got.y0 - a.rect.y0) <= tol &&
abs(got.x1 - a.rect.x1) <= tol && abs(got.y1 - a.rect.y1) <= tol
testing.expectf(t, placed, "%s page %d: appearance landed on %v, not on /Rect %v",
name, i, got, a.rect)
n_placed += 1
}
dev: Ac_Dev
ac_init(&dev)
flip := Matrix{1, 0, 0, -1, -page.media_box.x0, page.media_box.y1}
rerr := annot_run_page(doc, &page, &dev.dev, flip, opts)
testing.expectf(t, rerr == .None, "%s page %d: annot_run_page returned %v", name, i, rerr)
testing.expectf(t, dev.clip == 0, "%s page %d: %d clips left open", name, i, dev.clip)
testing.expectf(t, dev.group == 0, "%s page %d: %d groups left open", name, i, dev.group)
testing.expectf(t, dev.layer == 0, "%s page %d: %d layers left open", name, i, dev.layer)
testing.expectf(t, !dev.negative, "%s page %d: a device stack went negative", name, i)
total.fills += dev.fills
total.strokes += dev.strokes
total.texts += dev.texts
total.images += dev.images
total.shades += dev.shades
free_all(virtual.arena_allocator(&arena))
}
}
log.infof("annotation corpus: %d files, %d pages, %d annotations, %d with an appearance "+
"(%d state-keyed, %d optional-content, %d hidden), %d placed on their /Rect; "+
"drew %d fills, %d strokes, %d glyphs, %d images, %d shadings",
files, pages, n_annots, n_ap, n_states, n_oc, n_hidden, n_placed,
total.fills, total.strokes, total.texts, total.images, total.shades)
testing.expect(t, pages > 0, "no page of the corpus could be read")
if n_ap == 0 {
// Worth saying out loud rather than passing quietly: a corpus of
// exports from word processors is all /Link annotations with no
// appearance at all, so this sweep has exercised the loading path and
// the "no /AP, draw nothing" path and none of the drawing. The unit
// tests above are then the only thing holding the placement algorithm.
log.warn("no annotation in this corpus has an appearance stream; " +
"the drawing path was not exercised")
} else {
testing.expect(t, n_drawn > 0, "every appearance in the corpus was refused a placement")
testing.expect(t, n_placed > 0, "no appearance in the corpus landed on its /Rect")
testing.expect(t, total.fills + total.strokes + total.texts + total.images + total.shades > 0,
"appearances were found and none of them painted anything")
}
}
// ---------------------------------------------------------------------------
// A page is not one run
// ---------------------------------------------------------------------------
// Every annotation's appearance stream is its own `run_content`, so anything
// bounded per RUN is really bounded per run TIMES MAX_ANNOTS. The soft-mask
// budget is the one that costs memory -- a Soft_Mask outlives the operator
// that installed it -- so it is counted on the state the whole page shares,
// and hitting it here is what proves the annotations are counted with the
// page's content rather than each starting fresh.
@(test)
test_annot_soft_masks_share_the_page_budget :: proc(t: ^testing.T) {
pb := pb_make()
defer pb_destroy(&pb)
N :: MAX_SOFT_MASKS + 64
refs := strings.builder_make()
defer strings.builder_destroy(&refs)
strings.write_string(&refs, "[")
for i in 0 ..< N {
fmt.sbprintf(&refs, "%d 0 R ", 8 + i)
}
strings.write_string(&refs, "]")
an_page(&pb, strings.to_string(refs))
// 4: the appearance, which installs one soft mask. 5: the ExtGState that
// names it. 6: the mask's group.
pb_stream_obj(&pb, 4,
"/Type /XObject /Subtype /Form /BBox [0 0 100 50] " +
"/Resources << /ExtGState << /S 5 0 R >> >>",
transmute([]byte)string("/S gs 0 0 100 50 re f"))
pb_obj(&pb, 5, "<< /Type /ExtGState /SMask << /S /Luminosity /G 6 0 R >> >>")
pb_stream_obj(&pb, 6,
"/Type /XObject /Subtype /Form /BBox [0 0 20 20] " +
"/Group << /S /Transparency /CS /DeviceGray >>",
transmute([]byte)string("0 0 20 20 re f"))
pb_obj(&pb, 7, "<< >>")
// Each annotation sits somewhere else, so each install is a mask under a
// CTM of its own that the "same ExtGState, same CTM" cache cannot answer.
for i in 0 ..< N {
body := fmt.tprintf(
"<< /Type /Annot /Subtype /Widget /F 4 /Rect [%d 0 %d 50] /AP << /N 4 0 R >> >>",
i % 300, i % 300 + 100)
pb_obj(&pb, u32(8 + i), body)
}
doc, page := an_open(t, &pb)
defer close(doc)
if doc == nil { return }
repairs := len(doc.repairs)
bmp, err := render_page(&page, Render_Options{})
defer bitmap_destroy(&bmp)
testing.expect_value(t, err, Error.None)
testing.expect(t, len(doc.repairs) > repairs,
"more annotations than MAX_SOFT_MASKS must exhaust the PAGE's budget")
// And nothing the render built stays behind: a viewer re-renders the same
// page at every zoom step, and every one of those masks used to be a
// Document allocation that only `close` reclaimed.
before := document_memory_used(doc)
bmp2, err2 := render_page(&page, Render_Options{})
defer bitmap_destroy(&bmp2)
testing.expect_value(t, err2, Error.None)
growth := document_memory_used(doc) - before
testing.expectf(t, growth < 1 << 16, "the second render added %d bytes", growth)
}