1495 lines
57 KiB
Odin
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)
|
|
}
|