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

2945 lines
104 KiB
Odin

package pdf
import "core:fmt"
import "core:log"
import "core:math"
import "core:mem/virtual"
import "core:os"
import "core:path/filepath"
import "core:slice"
import "core:strings"
import "core:testing"
import "core:time"
// Content-stream interpreter tests.
//
// Everything here runs against `Recorder`, a Device that keeps every call it
// is handed. That is deliberately the whole apparatus: the interpreter's
// output IS its sequence of device calls, so a test that pins the calls pins
// the behaviour, and the same recorder is what the three real devices will be
// checked against once they exist.
//
// The fixture document comes from function_test.odin (`cf_open`, `cf_parse`,
// `cf_stream`, `cf_close`). It is opened by scanning and so already carries
// repairs; every assertion about a repair counts the GROWTH of doc.repairs
// rather than its length.
// ---------------------------------------------------------------------------
// The recording device
// ---------------------------------------------------------------------------
@(private = "file")
Rec_Kind :: enum u8 {
Fill_Path,
Stroke_Path,
Clip_Path,
Clip_Stroke_Path,
Clip_Text,
Clip_Image_Mask,
Pop_Clip,
Fill_Text,
Stroke_Text,
Ignore_Text,
Fill_Image,
Fill_Image_Mask,
Fill_Shade,
Begin_Mask,
End_Mask,
Begin_Group,
End_Group,
Begin_Tile,
End_Tile,
Begin_Layer,
End_Layer,
Close,
}
@(private = "file")
Rec :: struct {
kind: Rec_Kind,
even_odd: bool,
ctm: Matrix,
paint: Paint,
stroke: Stroke_State,
verbs: []Path_Verb,
points: []Point,
items: []Text_Item,
font: ^Font,
wmode: int,
mode: Text_Render_Mode,
name: string,
area: Rect,
view: Rect,
xstep: f64,
ystep: f64,
isolated: bool,
knockout: bool,
blend: Blend_Mode,
alpha: f64,
luminosity: bool,
img: ^Image,
shade: ^Shading,
transfer: ^Function,
// A Soft_Mask lives in the run's scratch and does not outlive it, so a
// device that records has to copy it -- exactly as device_list.odin does.
smask: Soft_Mask,
has_smask: bool,
}
@(private = "file")
Recorder :: struct {
dev: Device,
log: [dynamic]Rec,
// What begin_tile answers: 0 means "the device tiles, run the cell once".
tiles: int,
}
@(private = "file")
rec_add :: proc(dev: ^Device, rec: Rec) {
r := (^Recorder)(dev.user)
e := rec
if e.paint.soft_mask != nil {
e.smask = e.paint.soft_mask^
e.has_smask = true
}
append(&r.log, e)
}
@(private = "file")
rec_path :: proc(rec: ^Rec, p: ^Path) {
if p == nil {
return
}
rec.verbs = slice.clone(p.verbs[:])
rec.points = slice.clone(p.points[:])
}
@(private = "file")
rec_text :: proc(rec: ^Rec, txt: ^Text) {
if txt == nil {
return
}
rec.items = slice.clone(txt.items[:])
rec.font = txt.font
rec.wmode = txt.wmode
rec.mode = txt.render_mode
}
@(private = "file")
rec_fill_path :: proc(dev: ^Device, path: ^Path, even_odd: bool, ctm: Matrix, paint: Paint) {
r := Rec{kind = .Fill_Path, even_odd = even_odd, ctm = ctm, paint = paint}
rec_path(&r, path)
rec_add(dev, r)
}
@(private = "file")
rec_stroke_path :: proc(dev: ^Device, path: ^Path, stroke: ^Stroke_State, ctm: Matrix, paint: Paint) {
r := Rec{kind = .Stroke_Path, ctm = ctm, paint = paint}
if stroke != nil {
r.stroke = stroke^
}
rec_path(&r, path)
rec_add(dev, r)
}
@(private = "file")
rec_clip_path :: proc(dev: ^Device, path: ^Path, even_odd: bool, ctm: Matrix) {
r := Rec{kind = .Clip_Path, even_odd = even_odd, ctm = ctm}
rec_path(&r, path)
rec_add(dev, r)
}
@(private = "file")
rec_clip_stroke_path :: proc(dev: ^Device, path: ^Path, stroke: ^Stroke_State, ctm: Matrix) {
r := Rec{kind = .Clip_Stroke_Path, ctm = ctm}
if stroke != nil {
r.stroke = stroke^
}
rec_path(&r, path)
rec_add(dev, r)
}
@(private = "file")
rec_clip_text :: proc(dev: ^Device, txt: ^Text, ctm: Matrix) {
r := Rec{kind = .Clip_Text, ctm = ctm}
rec_text(&r, txt)
rec_add(dev, r)
}
@(private = "file")
rec_clip_image_mask :: proc(dev: ^Device, img: ^Image, ctm: Matrix) {
rec_add(dev, Rec{kind = .Clip_Image_Mask, ctm = ctm, img = img})
}
@(private = "file")
rec_pop_clip :: proc(dev: ^Device) {
rec_add(dev, Rec{kind = .Pop_Clip})
}
@(private = "file")
rec_fill_text :: proc(dev: ^Device, txt: ^Text, ctm: Matrix, paint: Paint) {
r := Rec{kind = .Fill_Text, ctm = ctm, paint = paint}
rec_text(&r, txt)
rec_add(dev, r)
}
@(private = "file")
rec_stroke_text :: proc(dev: ^Device, txt: ^Text, stroke: ^Stroke_State, ctm: Matrix, paint: Paint) {
r := Rec{kind = .Stroke_Text, ctm = ctm, paint = paint}
if stroke != nil {
r.stroke = stroke^
}
rec_text(&r, txt)
rec_add(dev, r)
}
@(private = "file")
rec_ignore_text :: proc(dev: ^Device, txt: ^Text, ctm: Matrix) {
r := Rec{kind = .Ignore_Text, ctm = ctm}
rec_text(&r, txt)
rec_add(dev, r)
}
@(private = "file")
rec_fill_image :: proc(dev: ^Device, img: ^Image, ctm: Matrix, paint: Paint) {
rec_add(dev, Rec{kind = .Fill_Image, ctm = ctm, paint = paint, img = img})
}
@(private = "file")
rec_fill_image_mask :: proc(dev: ^Device, img: ^Image, ctm: Matrix, paint: Paint) {
rec_add(dev, Rec{kind = .Fill_Image_Mask, ctm = ctm, paint = paint, img = img})
}
@(private = "file")
rec_fill_shade :: proc(dev: ^Device, shade: ^Shading, ctm: Matrix, paint: Paint) {
rec_add(dev, Rec{kind = .Fill_Shade, ctm = ctm, paint = paint, shade = shade})
}
@(private = "file")
rec_begin_mask :: proc(dev: ^Device, area: Rect, luminosity: bool, backdrop: Paint) {
rec_add(dev, Rec{kind = .Begin_Mask, area = area, luminosity = luminosity, paint = backdrop})
}
@(private = "file")
rec_end_mask :: proc(dev: ^Device, mask: ^Soft_Mask, transfer: ^Function) {
rec_add(dev, Rec{kind = .End_Mask, transfer = transfer})
}
@(private = "file")
rec_begin_group :: proc(dev: ^Device, area: Rect, isolated, knockout: bool, blend: Blend_Mode, alpha: f64, soft_mask: ^Soft_Mask) {
rec_add(dev, Rec{
kind = .Begin_Group, area = area, isolated = isolated,
knockout = knockout, blend = blend, alpha = alpha,
})
}
@(private = "file")
rec_end_group :: proc(dev: ^Device) {
rec_add(dev, Rec{kind = .End_Group})
}
@(private = "file")
rec_begin_tile :: proc(dev: ^Device, area, view: Rect, xstep, ystep: f64, ctm: Matrix) -> int {
r := (^Recorder)(dev.user)
append(&r.log, Rec{
kind = .Begin_Tile, area = area, view = view,
xstep = xstep, ystep = ystep, ctm = ctm,
})
return r.tiles
}
@(private = "file")
rec_end_tile :: proc(dev: ^Device) {
rec_add(dev, Rec{kind = .End_Tile})
}
@(private = "file")
rec_begin_layer :: proc(dev: ^Device, name: string) {
// The name is scratch memory the run reclaims, so it has to be copied.
rec_add(dev, Rec{kind = .Begin_Layer, name = strings.clone(name)})
}
@(private = "file")
rec_end_layer :: proc(dev: ^Device) {
rec_add(dev, Rec{kind = .End_Layer})
}
@(private = "file")
rec_close :: proc(dev: ^Device) {
rec_add(dev, Rec{kind = .Close})
}
@(private = "file")
rec_init :: proc(rec: ^Recorder) {
rec.log = make([dynamic]Rec, 0, 32)
rec.dev = Device{
user = rec,
fill_path = rec_fill_path,
stroke_path = rec_stroke_path,
clip_path = rec_clip_path,
clip_stroke_path = rec_clip_stroke_path,
clip_text = rec_clip_text,
clip_image_mask = rec_clip_image_mask,
pop_clip = rec_pop_clip,
fill_text = rec_fill_text,
stroke_text = rec_stroke_text,
ignore_text = rec_ignore_text,
fill_image = rec_fill_image,
fill_image_mask = rec_fill_image_mask,
fill_shade = rec_fill_shade,
begin_mask = rec_begin_mask,
end_mask = rec_end_mask,
begin_group = rec_begin_group,
end_group = rec_end_group,
begin_tile = rec_begin_tile,
end_tile = rec_end_tile,
begin_layer = rec_begin_layer,
end_layer = rec_end_layer,
close = rec_close,
}
}
@(private = "file")
rec_destroy :: proc(rec: ^Recorder) {
for &e in rec.log {
delete(e.verbs)
delete(e.points)
delete(e.items)
delete(e.name)
}
delete(rec.log)
rec.log = nil
}
@(private = "file")
rec_count :: proc(rec: ^Recorder, kind: Rec_Kind) -> (n: int) {
for &e in rec.log {
if e.kind == kind {
n += 1
}
}
return
}
// rec_nth is the (0-based) n'th call of a kind, or nil.
@(private = "file")
rec_nth :: proc(rec: ^Recorder, kind: Rec_Kind, n := 0) -> ^Rec {
seen := 0
for &e in rec.log {
if e.kind != kind {
continue
}
if seen == n {
return &e
}
seen += 1
}
return nil
}
// rec_kinds is the whole call sequence, for order assertions.
@(private = "file")
rec_kinds :: proc(rec: ^Recorder, allocator := context.allocator) -> []Rec_Kind {
out := make([]Rec_Kind, len(rec.log), allocator)
for e, i in rec.log {
out[i] = e.kind
}
return out
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
@(private = "file")
CT_EPS :: 1e-9
@(private = "file")
ct_near :: proc(t: ^testing.T, got, want: f64, what: string, tol := CT_EPS,
loc := #caller_location) {
if !(abs(got - want) <= tol) {
testing.expectf(t, false, "%s: got %v, want %v", what, got, want, loc = loc)
}
}
@(private = "file")
ct_point :: proc(t: ^testing.T, p: Point, x, y: f64, what: string, tol := CT_EPS,
loc := #caller_location) {
if !(abs(p.x - x) <= tol && abs(p.y - y) <= tol) {
testing.expectf(t, false, "%s: got (%v, %v), want (%v, %v)",
what, p.x, p.y, x, y, loc = loc)
}
}
// ct_run executes a content stream against a fresh recorder.
@(private = "file")
ct_run :: proc(doc: ^Document, rec: ^Recorder, src: string, res: ^Dict = nil,
ctm := MATRIX_IDENTITY, opts := Content_Options{}) -> Error {
return run_content(doc, &rec.dev, transmute([]byte)src, res, ctm, opts)
}
// A font every code of which is exactly half an em wide. /BaseFont is not one
// of the standard 14, so no AFM metric can slip in ahead of /MissingWidth and
// every advance in these tests is 0.5 * Tfs exactly.
@(private = "file")
CT_FONT :: "<< /Type /Font /Subtype /Type1 /BaseFont /Halfwidth " +
"/FontDescriptor << /Flags 32 /MissingWidth 500 >> >>"
// The same width through a two-byte composite font, so that a code 32 that is
// half of a two-byte code can be told from a single-byte one.
@(private = "file")
CT_FONT0 :: "<< /Type /Font /Subtype /Type0 /BaseFont /Halfwide /Encoding /Identity-H " +
"/DescendantFonts [ << /Type /Font /Subtype /CIDFontType2 /BaseFont /Halfwide " +
"/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> " +
"/DW 500 >> ] >>"
@(private = "file")
CT_TYPE3 :: "<< /Type /Font /Subtype /Type3 /FontMatrix [0.01 0 0 0.01 0 0] " +
"/FontBBox [0 0 100 100] /FirstChar 65 /LastChar 66 /Widths [100 100] " +
"/Encoding << /Type /Encoding /Differences [65 /square 66 /blank] >> >>"
// ct_res builds the resource dictionary the tests share. Everything in it is
// Document memory, so it lives until cf_close.
@(private = "file")
ct_res :: proc(doc: ^Document) -> ^Dict {
res := new_dict(doc.allocator)
fonts := new_dict(doc.allocator)
dict_set(fonts, "F1", cf_parse(doc, CT_FONT))
dict_set(fonts, "F0", cf_parse(doc, CT_FONT0))
t3 := cf_parse(doc, CT_TYPE3)
if t3d, ok := to_dict(t3); ok {
procs := new_dict(doc.allocator)
// A square that fills the whole glyph box, and a glyph that draws
// nothing at all.
dict_set(procs, "square", cf_stream(doc, "<< >>",
transmute([]byte)string("100 0 d0 0 0 100 100 re f")))
dict_set(procs, "blank", cf_stream(doc, "<< >>",
transmute([]byte)string("100 0 d0")))
dict_set(t3d, "CharProcs", procs)
}
dict_set(fonts, "T3", t3)
dict_set(res, "Font", fonts)
xobj := new_dict(doc.allocator)
// A form that invokes itself. Its /Resources are inherited from the page,
// which is what makes /Self resolve inside it.
dict_set(xobj, "Self", cf_stream(doc,
"<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] >>",
transmute([]byte)string("0 0 10 10 re f /Self Do")))
dict_set(xobj, "Box", cf_stream(doc,
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Matrix [2 0 0 2 5 5] >>",
transmute([]byte)string("0 0 4 4 re f")))
dict_set(xobj, "Grp", cf_stream(doc,
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] " +
"/Group << /S /Transparency /I true /K true /CS /DeviceRGB >> >>",
transmute([]byte)string("0 0 4 4 re f")))
// A 2x2 greyscale image and a 2x2 stencil.
dict_set(xobj, "Im", cf_stream(doc,
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 " +
"/ColorSpace /DeviceGray /BitsPerComponent 8 >>",
transmute([]byte)string("\x00\x40\x80\xff")))
dict_set(xobj, "Stencil", cf_stream(doc,
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true >>",
transmute([]byte)string("\x40\x80")))
dict_set(res, "XObject", xobj)
shadings := new_dict(doc.allocator)
dict_set(shadings, "Sh", cf_parse(doc,
"<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 10 0] " +
"/Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >>"))
dict_set(res, "Shading", shadings)
pats := new_dict(doc.allocator)
dict_set(pats, "Tile", cf_stream(doc,
"<< /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] " +
"/XStep 10 /YStep 10 >>",
transmute([]byte)string("0 0 5 5 re f")))
dict_set(pats, "Uncol", cf_stream(doc,
"<< /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] " +
"/XStep 10 /YStep 10 >>",
transmute([]byte)string("1 0 0 rg 0 0 5 5 re f")))
dict_set(pats, "ShadeGS", cf_parse(doc,
"<< /PatternType 2 /ExtGState << /ca 0.25 /BM /Screen >> /Shading " +
"<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 10 0] " +
"/Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >>"))
dict_set(pats, "Shade", cf_parse(doc,
"<< /PatternType 2 /Matrix [1 0 0 1 3 4] /Shading " +
"<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 10 0] " +
"/Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >>"))
dict_set(res, "Pattern", pats)
egs := new_dict(doc.allocator)
dict_set(egs, "G1", cf_parse(doc,
"<< /Type /ExtGState /LW 4 /LC 1 /LJ 2 /ML 3 /CA 0.25 /ca 0.5 " +
"/BM /Multiply /D [[3 2] 1] >>"))
dict_set(egs, "GNone", cf_parse(doc, "<< /Type /ExtGState /SMask /None >>"))
smask_form := cf_stream(doc,
"<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] " +
"/Group << /S /Transparency /CS /DeviceGray >> >>",
transmute([]byte)string("0 0 20 20 re f"))
smd := new_dict(doc.allocator)
dict_set(smd, "S", Name("Luminosity"))
dict_set(smd, "G", smask_form)
gsm := new_dict(doc.allocator)
dict_set(gsm, "SMask", smd)
dict_set(egs, "GMask", gsm)
dict_set(res, "ExtGState", egs)
cspaces := new_dict(doc.allocator)
dict_set(cspaces, "Sep", cf_parse(doc,
"[/Separation /Spot /DeviceRGB << /FunctionType 2 /Domain [0 1] " +
"/C0 [0 0 0] /C1 [1 0 0] /N 1 >>]"))
dict_set(cspaces, "PatRGB", cf_parse(doc, "[/Pattern /DeviceRGB]"))
dict_set(res, "ColorSpace", cspaces)
return res
}
// ---------------------------------------------------------------------------
// Graphics state: q, Q, cm
// ---------------------------------------------------------------------------
@(test)
test_content_q_restores_ctm :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, "q 2 0 0 2 0 0 cm 0 0 1 1 re f Q 0 0 1 1 re f"), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 2)
inner := rec_nth(&rec, .Fill_Path, 0)
outer := rec_nth(&rec, .Fill_Path, 1)
if inner == nil || outer == nil { return }
ct_near(t, inner.ctm.a, 2, "inside q the CTM is scaled")
ct_near(t, outer.ctm.a, 1, "after Q the CTM is restored")
}
@(test)
test_content_cm_composes_in_order :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// CTM = operand * CTM, so the second `cm` is applied FIRST to user space:
// a point at (1,0) lands at 2*1 + 10 = 12 under `2 0 0 2 0 0` then
// `1 0 0 1 10 0`... which is 2*(1+10) = 22 the other way round.
testing.expect_value(t, ct_run(doc, &rec, "2 0 0 2 0 0 cm 1 0 0 1 10 0 cm 0 0 1 1 re f"), Error.None)
e := rec_nth(&rec, .Fill_Path)
if e == nil { return }
ct_point(t, transform_point(e.ctm, Point{1, 0}), 22, 0, "cm order")
}
@(test)
test_content_Q_underflow_is_ignored :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
before := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec, "Q Q 1 0 0 1 5 0 cm 0 0 1 1 re f"), Error.None)
testing.expect(t, len(doc.repairs) > before, "a stray Q must be recorded as a repair")
// And the page still draws, in the state the strays did not damage.
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
e := rec_nth(&rec, .Fill_Path)
if e == nil { return }
ct_near(t, e.ctm.e, 5, "the cm after the stray Qs still applies")
}
// A `q` past the depth cap saves nothing, so its `Q` must restore nothing. If
// the overflow were not counted, the first `Q` would pop an outer scope's
// state and everything after it would draw in the wrong one.
@(test)
test_content_q_overflow_does_not_pop :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
over :: 6
b := strings.builder_make()
defer strings.builder_destroy(&b)
strings.write_string(&b, "1 0 0 1 5 0 cm ")
for _ in 0 ..< MAX_GSTATE_DEPTH + over {
strings.write_string(&b, "q ")
}
strings.write_string(&b, "1 0 0 1 7 0 cm ")
for _ in 0 ..< over {
strings.write_string(&b, "Q ")
}
strings.write_string(&b, "0 0 1 1 re f ")
for _ in 0 ..< MAX_GSTATE_DEPTH {
strings.write_string(&b, "Q ")
}
strings.write_string(&b, "0 0 1 1 re f")
testing.expect_value(t, ct_run(doc, &rec, strings.to_string(b)), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 2)
first := rec_nth(&rec, .Fill_Path, 0)
second := rec_nth(&rec, .Fill_Path, 1)
if first == nil || second == nil { return }
ct_near(t, first.ctm.e, 12, "the Qs matching the overflowed qs restore nothing")
ct_near(t, second.ctm.e, 5, "the real Qs restore the state their qs saved")
}
@(test)
test_content_stroke_state_operators :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"5 w 1 J 2 j 7 M [3 2] 1 d 0 0 10 10 re S"), Error.None)
e := rec_nth(&rec, .Stroke_Path)
if e == nil {
testing.expect(t, false, "S did not reach the device")
return
}
ct_near(t, e.stroke.line_width, 5, "w")
testing.expect_value(t, e.stroke.line_cap, Line_Cap.Round)
testing.expect_value(t, e.stroke.line_join, Line_Join.Bevel)
ct_near(t, e.stroke.miter_limit, 7, "M")
testing.expect_value(t, e.stroke.dash_count, 2)
ct_near(t, e.stroke.dash[0], 3, "dash[0]")
ct_near(t, e.stroke.dash[1], 2, "dash[1]")
ct_near(t, e.stroke.dash_phase, 1, "dash phase")
}
@(test)
test_content_non_finite_cm_is_rejected :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// The product overflows to infinity, which must never reach the geometry.
before := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec,
"1e300 0 0 1e300 0 0 cm 1e300 0 0 1e300 0 0 cm 0 0 1 1 re f"), Error.None)
testing.expect(t, len(doc.repairs) > before, "an unusable cm must be recorded")
e := rec_nth(&rec, .Fill_Path)
if e == nil { return }
testing.expect(t, matrix_is_finite(e.ctm), "the CTM must stay finite")
ct_near(t, e.ctm.a, 1e300, "an unusable cm leaves the CTM as it was", 1e290)
}
// ---------------------------------------------------------------------------
// Path construction and painting
// ---------------------------------------------------------------------------
@(test)
test_content_path_operators :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"1 2 m 3 4 l 5 6 7 8 9 10 c 11 12 13 14 v 15 16 17 18 y h f"), Error.None)
e := rec_nth(&rec, .Fill_Path)
if e == nil {
testing.expect(t, false, "f did not reach the device")
return
}
want := []Path_Verb{.Move, .Line, .Curve, .Curve, .Curve, .Close}
testing.expect(t, slice.equal(e.verbs, want), "verb sequence")
if len(e.points) < 11 { return }
ct_point(t, e.points[0], 1, 2, "m")
ct_point(t, e.points[1], 3, 4, "l")
ct_point(t, e.points[4], 9, 10, "c end point")
// `v` takes the current point as its first control point.
ct_point(t, e.points[5], 9, 10, "v implied control point")
ct_point(t, e.points[7], 13, 14, "v end point")
// `y` repeats its end point as the second control point.
ct_point(t, e.points[9], 17, 18, "y second control point")
ct_point(t, e.points[10], 17, 18, "y end point")
}
@(test)
test_content_re_winds_counter_clockwise :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, "10 20 30 40 re f"), Error.None)
e := rec_nth(&rec, .Fill_Path)
if e == nil || len(e.points) < 4 { return }
ct_point(t, e.points[0], 10, 20, "re origin")
ct_point(t, e.points[1], 40, 20, "re second corner")
ct_point(t, e.points[2], 40, 60, "re third corner")
ct_point(t, e.points[3], 10, 60, "re fourth corner")
}
@(test)
test_content_painting_operators :: proc(t: ^testing.T) {
Case :: struct {
src: string,
fills: int,
strokes: int,
even_odd: bool,
closed: bool,
}
cases := []Case{
{"0 0 1 1 re S", 0, 1, false, false},
{"0 0 m 1 1 l s", 0, 1, false, true},
{"0 0 1 1 re f", 1, 0, false, false},
{"0 0 1 1 re F", 1, 0, false, false},
{"0 0 1 1 re f*", 1, 0, true, false},
{"0 0 1 1 re B", 1, 1, false, false},
{"0 0 1 1 re B*", 1, 1, true, false},
{"0 0 m 1 1 l b", 1, 1, false, true},
{"0 0 m 1 1 l b*",1, 1, true, true},
{"0 0 1 1 re n", 0, 0, false, false},
}
for c in cases {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, c.src), Error.None)
testing.expectf(t, rec_count(&rec, .Fill_Path) == c.fills,
"%s: %d fills, want %d", c.src, rec_count(&rec, .Fill_Path), c.fills)
testing.expectf(t, rec_count(&rec, .Stroke_Path) == c.strokes,
"%s: %d strokes, want %d", c.src, rec_count(&rec, .Stroke_Path), c.strokes)
if c.fills > 0 {
e := rec_nth(&rec, .Fill_Path)
if e != nil {
testing.expectf(t, e.even_odd == c.even_odd, "%s: fill rule", c.src)
if c.closed && len(e.verbs) > 0 {
testing.expectf(t, e.verbs[len(e.verbs) - 1] == .Close,
"%s: the path must be closed before painting", c.src)
}
}
}
}
}
// ---------------------------------------------------------------------------
// Clipping: W and W* arm a clip that the NEXT painting operator installs
// ---------------------------------------------------------------------------
@(test)
test_content_clip_takes_effect_after_the_paint :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// `f` paints first and only then installs the clip.
testing.expect_value(t, ct_run(doc, &rec, "0 0 10 10 re W f"), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
// The trailing pop is the interpreter balancing the device's clip stack
// at the end of the stream, which it owes whether or not a Q ever came.
testing.expect(t, slice.equal(kinds, []Rec_Kind{.Fill_Path, .Clip_Path, .Pop_Clip}),
"the fill must reach the device before the clip it arms")
}
@(test)
test_content_clip_is_not_installed_at_W :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// `sh` is not a path-painting operator, so the armed clip is still
// pending when it runs: the shading must be painted unclipped and the
// clip must arrive only with the `n`.
testing.expect_value(t, ct_run(doc, &rec, "0 0 10 10 re W /Sh sh n", ct_res(doc)), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds, []Rec_Kind{.Fill_Shade, .Clip_Path, .Pop_Clip}),
"W must not clip until a painting operator runs")
}
@(test)
test_content_clip_rule_and_pop_on_Q :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, "q 0 0 10 10 re W* n Q 0 0 1 1 re f"), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds, []Rec_Kind{.Clip_Path, .Pop_Clip, .Fill_Path}),
"Q must pop exactly the clips its q was holding")
e := rec_nth(&rec, .Clip_Path)
if e == nil { return }
testing.expect(t, e.even_odd, "W* clips with the even-odd rule")
}
@(test)
test_content_clip_survives_to_end_of_stream :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// No Q at all: the interpreter still has to leave the device's clip stack
// as it found it.
testing.expect_value(t, ct_run(doc, &rec, "0 0 10 10 re W n 0 0 1 1 re f"), Error.None)
testing.expect_value(t, rec_count(&rec, .Clip_Path), 1)
testing.expect_value(t, rec_count(&rec, .Pop_Clip), 1)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, kinds[len(kinds) - 1] == .Pop_Clip, "the clip is popped last, not early")
}
// ---------------------------------------------------------------------------
// Colour
// ---------------------------------------------------------------------------
@(test)
test_content_device_colour_operators :: proc(t: ^testing.T) {
Case :: struct {
src: string,
rgb: [3]f64,
n: int,
}
cases := []Case{
{"0.5 g 0 0 1 1 re f", {0.5, 0.5, 0.5}, 1},
{"1 0 0 rg 0 0 1 1 re f", {1, 0, 0}, 3},
{"0 1 1 0 k 0 0 1 1 re f", {1, 0, 0}, 4},
}
for c in cases {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, c.src), Error.None)
e := rec_nth(&rec, .Fill_Path)
if e == nil {
testing.expectf(t, false, "%s did not paint", c.src)
continue
}
testing.expectf(t, e.paint.color.n == c.n, "%s: %d components", c.src, e.paint.color.n)
for i in 0 ..< 3 {
ct_near(t, e.paint.rgb[i], c.rgb[i], c.src, 1e-6)
}
}
}
@(test)
test_content_stroke_colour_is_separate :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, "1 0 0 rg 0 0 1 RG 0 0 1 1 re B"), Error.None)
f := rec_nth(&rec, .Fill_Path)
s := rec_nth(&rec, .Stroke_Path)
if f == nil || s == nil { return }
ct_near(t, f.paint.rgb[0], 1, "fill red", 1e-6)
ct_near(t, s.paint.rgb[2], 1, "stroke blue", 1e-6)
ct_near(t, s.paint.rgb[0], 0, "stroke has no red", 1e-6)
}
@(test)
test_content_named_colourspace_and_scn :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// A Separation whose tint transform takes 1 to pure red.
testing.expect_value(t, ct_run(doc, &rec, "/Sep cs 1 scn 0 0 1 1 re f", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Path)
if e == nil {
testing.expect(t, false, "the separation fill did not paint")
return
}
testing.expect_value(t, e.paint.color.n, 1)
ct_near(t, e.paint.rgb[0], 1, "separation red", 1e-6)
ct_near(t, e.paint.rgb[1], 0, "separation green", 1e-6)
}
// Selecting a colour space resets the colour to that space's initial value,
// so a `cs` after a red `rg` paints black, not red (8.6.8).
@(test)
test_content_cs_resets_the_colour :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, "1 0 0 rg /DeviceRGB cs 0 0 1 1 re f"), Error.None)
e := rec_nth(&rec, .Fill_Path)
if e == nil { return }
ct_near(t, e.paint.rgb[0], 0, "cs must reset the colour to black", 1e-6)
}
@(test)
test_content_alpha_and_blend_from_extgstate :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, "/G1 gs 0 0 10 10 re B", ct_res(doc)), Error.None)
f := rec_nth(&rec, .Fill_Path)
s := rec_nth(&rec, .Stroke_Path)
if f == nil || s == nil {
testing.expect(t, false, "the ExtGState fill/stroke did not paint")
return
}
ct_near(t, f.paint.alpha, 0.5, "ca is the fill alpha")
ct_near(t, s.paint.alpha, 0.25, "CA is the stroke alpha")
testing.expect_value(t, f.paint.blend, Blend_Mode.Multiply)
ct_near(t, s.stroke.line_width, 4, "LW")
testing.expect_value(t, s.stroke.line_cap, Line_Cap.Round)
testing.expect_value(t, s.stroke.line_join, Line_Join.Bevel)
testing.expect_value(t, s.stroke.dash_count, 2)
ct_near(t, s.stroke.dash_phase, 1, "the /D phase")
}
// ---------------------------------------------------------------------------
// Text
//
// The fixture font is exactly half an em wide for every code, so at 10 point
// every advance below is 5.0 and the expected positions are arithmetic.
// ---------------------------------------------------------------------------
@(private = "file")
ct_origin :: proc(items: []Text_Item, i: int) -> Point {
if i < 0 || i >= len(items) {
return Point{-1e30, -1e30}
}
return transform_point(items[i].trm, Point{0, 0})
}
@(test)
test_content_text_matrix_sequence :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// Td sets the line matrix; each glyph advances the text matrix alone, so
// T* returns to where the LINE started rather than to where it ended.
testing.expect_value(t, ct_run(doc, &rec,
"BT /F1 10 Tf 20 TL 100 200 Td (AB) Tj T* (C) Tj ET", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Text), 2)
first := rec_nth(&rec, .Fill_Text, 0)
second := rec_nth(&rec, .Fill_Text, 1)
if first == nil || second == nil { return }
testing.expect_value(t, len(first.items), 2)
ct_point(t, ct_origin(first.items, 0), 100, 200, "first glyph", 1e-9)
ct_point(t, ct_origin(first.items, 1), 105, 200, "second glyph advances by 0.5 em")
testing.expect_value(t, len(second.items), 1)
ct_point(t, ct_origin(second.items, 0), 100, 180, "T* moves the line, not the pen")
}
@(test)
test_content_TD_sets_leading :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// TD is Td with TL set to -ty, so the following T* moves by the same
// amount again.
testing.expect_value(t, ct_run(doc, &rec,
"BT /F1 10 Tf 10 -20 TD (A) Tj T* (B) Tj ET", ct_res(doc)), Error.None)
a := rec_nth(&rec, .Fill_Text, 0)
b := rec_nth(&rec, .Fill_Text, 1)
if a == nil || b == nil { return }
ct_point(t, ct_origin(a.items, 0), 10, -20, "TD")
ct_point(t, ct_origin(b.items, 0), 10, -40, "T* after TD")
}
@(test)
test_content_Tm_scales_glyph_and_advance :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"BT /F1 10 Tf 2 0 0 2 30 40 Tm (AB) Tj ET", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Text)
if e == nil || len(e.items) < 2 { return }
ct_point(t, ct_origin(e.items, 0), 30, 40, "Tm places the first glyph")
// The advance is 5 in text space, and Tm doubles everything.
ct_point(t, ct_origin(e.items, 1), 40, 40, "Tm scales the advance")
// The rendering matrix folds in the font matrix (1/1000), the size and Tm.
ct_near(t, e.items[0].trm.a, 0.02, "trm scale")
ct_near(t, e.items[0].advance, 5, "advance is unscaled text space")
}
@(test)
test_content_Tz_scales_horizontally_only :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"BT /F1 10 Tf 200 Tz (AB) Tj ET", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Text)
if e == nil || len(e.items) < 2 { return }
// Th multiplies the horizontal scale of the glyph and the advance, and
// nothing else: putting it in `d` as well is the classic Tz bug.
ct_near(t, e.items[0].trm.a, 0.02, "Tz scales the glyph horizontally")
ct_near(t, e.items[0].trm.d, 0.01, "Tz must not scale the glyph vertically")
ct_point(t, ct_origin(e.items, 1), 10, 0, "Tz scales the advance")
ct_near(t, e.items[0].advance, 5, "the recorded advance is before Th")
}
@(test)
test_content_Ts_raises_the_glyph_not_the_pen :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"BT /F1 10 Tf 7 Ts (AB) Tj ET", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Text)
if e == nil || len(e.items) < 2 { return }
ct_point(t, ct_origin(e.items, 0), 0, 7, "Ts lifts the glyph")
ct_point(t, ct_origin(e.items, 1), 5, 7, "the rise does not accumulate along the line")
}
// 9.3.3: word spacing applies to a single-byte code 32 and to nothing else.
@(test)
test_content_word_spacing_single_byte :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"BT /F1 10 Tf 3 Tw 1 Tc ( A) Tj ET", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Text)
if e == nil || len(e.items) < 2 { return }
testing.expect(t, e.items[0].is_space, "a one-byte 32 is a space")
// 0.5 em + Tc + Tw = 5 + 1 + 3.
ct_point(t, ct_origin(e.items, 1), 9, 0, "word spacing applies to a one-byte space")
}
@(test)
test_content_word_spacing_not_on_two_byte_code :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// <0020> in an Identity-H font is CID 32, an ordinary glyph, and stretching
// it would space CJK text at random.
testing.expect_value(t, ct_run(doc, &rec,
"BT /F0 10 Tf 3 Tw 1 Tc <00200041> Tj ET", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Text)
if e == nil {
testing.expect(t, false, "the composite font did not draw")
return
}
testing.expect_value(t, len(e.items), 2)
if len(e.items) < 2 { return }
testing.expect(t, !e.items[0].is_space, "a two-byte 0x0020 is not a space")
// 0.5 em + Tc only.
ct_point(t, ct_origin(e.items, 1), 6, 0, "no word spacing on a two-byte code")
}
// A positive TJ number pulls the following glyphs CLOSER; a negative one
// pushes them apart. Getting the sign wrong reverses every kern in the file.
@(test)
test_content_TJ_number_direction :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"BT /F1 10 Tf [(A) -1000 (B) 200 (C)] TJ ET", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Text), 3)
a := rec_nth(&rec, .Fill_Text, 0)
b := rec_nth(&rec, .Fill_Text, 1)
c := rec_nth(&rec, .Fill_Text, 2)
if a == nil || b == nil || c == nil { return }
ct_point(t, ct_origin(a.items, 0), 0, 0, "first glyph")
// 5 (advance) + 1000/1000 * 10 (the negative number moves right).
ct_point(t, ct_origin(b.items, 0), 15, 0, "a negative TJ number opens a gap")
// 15 + 5 - 200/1000 * 10.
ct_point(t, ct_origin(c.items, 0), 18, 0, "a positive TJ number closes one")
}
@(test)
test_content_TJ_adjustment_obeys_Tz :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"BT /F1 10 Tf 200 Tz [(A) -1000 (B)] TJ ET", ct_res(doc)), Error.None)
b := rec_nth(&rec, .Fill_Text, 1)
if b == nil { return }
// (5 + 10) * 2.
ct_point(t, ct_origin(b.items, 0), 30, 0, "the horizontal scale applies to TJ too")
}
@(test)
test_content_quote_operators :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// ' is T* then Tj. " additionally sets word and character spacing.
testing.expect_value(t, ct_run(doc, &rec,
"BT /F1 10 Tf 12 TL 5 100 Td (A) ' 3 1 (X A) \" ET", ct_res(doc)), Error.None)
a := rec_nth(&rec, .Fill_Text, 0)
b := rec_nth(&rec, .Fill_Text, 1)
if a == nil || b == nil { return }
ct_point(t, ct_origin(a.items, 0), 5, 88, "' moves down one line first")
ct_point(t, ct_origin(b.items, 0), 5, 76, "\" moves down another line")
if len(b.items) < 3 { return }
// X: 5 + 1 (Tc). space: 5 + 1 + 3 (Tw).
ct_point(t, ct_origin(b.items, 1), 11, 76, "\" sets the character spacing")
ct_point(t, ct_origin(b.items, 2), 20, 76, "\" sets the word spacing")
}
@(test)
test_content_render_modes :: proc(t: ^testing.T) {
Case :: struct {
mode: string,
fill: int,
stroke: int,
ignore: int,
clip: int,
}
cases := []Case{
{"0", 1, 0, 0, 0},
{"1", 0, 1, 0, 0},
{"2", 1, 1, 0, 0},
{"3", 0, 0, 1, 0},
{"4", 1, 0, 0, 1},
{"5", 0, 1, 0, 1},
{"6", 1, 1, 0, 1},
{"7", 0, 0, 1, 1},
}
for c in cases {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
src := strings.concatenate({"BT /F1 10 Tf ", c.mode, " Tr (A) Tj ET"})
defer delete(src)
testing.expect_value(t, ct_run(doc, &rec, src, ct_res(doc)), Error.None)
testing.expectf(t, rec_count(&rec, .Fill_Text) == c.fill, "Tr %s: fills", c.mode)
testing.expectf(t, rec_count(&rec, .Stroke_Text) == c.stroke, "Tr %s: strokes", c.mode)
testing.expectf(t, rec_count(&rec, .Ignore_Text) == c.ignore, "Tr %s: ignores", c.mode)
testing.expectf(t, rec_count(&rec, .Clip_Text) == c.clip, "Tr %s: clips", c.mode)
}
}
// Mode 3 draws nothing and must still be handed over: an OCR layer under a
// scan is written that way and is the whole of what such a page extracts.
@(test)
test_content_invisible_text_still_reaches_the_device :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"BT /F1 10 Tf 3 Tr 40 50 Td (AB) Tj ET", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Ignore_Text)
if e == nil {
testing.expect(t, false, "invisible text must still reach ignore_text")
return
}
testing.expect_value(t, len(e.items), 2)
testing.expect_value(t, e.mode, Text_Render_Mode.Invisible)
ct_point(t, ct_origin(e.items, 0), 40, 50, "invisible text is still positioned")
}
@(test)
test_content_text_clip_is_installed_at_ET :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// The clip is the union of everything shown in a clipping mode between BT
// and ET, and only arrives at ET -- after both showing operators.
testing.expect_value(t, ct_run(doc, &rec,
"q BT /F1 10 Tf 7 Tr (AB) Tj (CD) Tj ET Q 0 0 1 1 re f", ct_res(doc)), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds,
[]Rec_Kind{.Ignore_Text, .Ignore_Text, .Clip_Text, .Pop_Clip, .Fill_Path}),
"the text clip is installed once, at ET, and popped by Q")
e := rec_nth(&rec, .Clip_Text)
if e == nil { return }
testing.expect_value(t, len(e.items), 4)
}
@(test)
test_content_missing_font_draws_nothing :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
before := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec,
"BT /Nope 10 Tf (A) Tj ET 0 0 1 1 re f", ct_res(doc)), Error.None)
testing.expect(t, len(doc.repairs) > before, "a missing font is a repair")
testing.expect_value(t, rec_count(&rec, .Fill_Text), 0)
// And the rest of the page still draws.
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
// ---------------------------------------------------------------------------
// Type 3 fonts: a glyph is a content stream
// ---------------------------------------------------------------------------
@(test)
test_content_type3_glyph_runs_its_content_stream :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// /FontMatrix is 1/100, so a 100-unit glyph box is one em, and at 10 point
// the glyph's content runs under a CTM of 0.1.
testing.expect_value(t, ct_run(doc, &rec,
"BT /T3 10 Tf (A) Tj ET", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Ignore_Text), 1)
e := rec_nth(&rec, .Fill_Path)
if e == nil {
testing.expect(t, false, "the Type 3 glyph description did not run")
return
}
ct_near(t, e.ctm.a, 0.1, "the glyph runs under font matrix x size")
// The glyph's own square is 100 units, which is 10 device units here.
ct_point(t, transform_point(e.ctm, Point{100, 100}), 10, 10, "glyph box")
}
@(test)
test_content_type3_advance_uses_the_font_matrix :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// A Type 3 width is in the font's own glyph space: 100 units through a
// 1/100 matrix is one em, so at 10 point the advance is 10, not 1.
testing.expect_value(t, ct_run(doc, &rec,
"BT /T3 10 Tf (AB) Tj ET", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Ignore_Text)
if e == nil || len(e.items) < 2 { return }
ct_near(t, e.items[0].advance, 10, "Type 3 advance")
ct_point(t, ct_origin(e.items, 1), 10, 0, "the second glyph")
// The second glyph draws nothing, so only the first left a mark.
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
@(test)
test_content_type3_invisible_mode_draws_nothing :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"BT /T3 10 Tf 3 Tr (A) Tj ET", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Ignore_Text), 1)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 0)
}
// ---------------------------------------------------------------------------
// Form XObjects
// ---------------------------------------------------------------------------
@(test)
test_content_form_that_invokes_itself_terminates :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
before := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec, "/Self Do", ct_res(doc)), Error.None)
testing.expect(t, len(doc.repairs) > before, "the recursion must be recorded")
// Exactly one pass through the form's body.
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds, []Rec_Kind{.Clip_Path, .Fill_Path, .Pop_Clip}),
"the form's BBox clip must be pushed and popped exactly once")
}
@(test)
test_content_form_matrix_and_bbox :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, "/Box Do", ct_res(doc)), Error.None)
clip := rec_nth(&rec, .Clip_Path)
fill := rec_nth(&rec, .Fill_Path)
if clip == nil || fill == nil {
testing.expect(t, false, "the form did not run")
return
}
// /Matrix [2 0 0 2 5 5] composes in front of the invoking CTM.
ct_near(t, fill.ctm.a, 2, "the form matrix scales the contents")
ct_near(t, fill.ctm.e, 5, "the form matrix translates the contents")
// The BBox is clipped in the form's own space, under the same matrix.
if len(clip.points) >= 3 {
ct_point(t, transform_point(clip.ctm, clip.points[2]), 25, 25, "BBox corner")
}
}
@(test)
test_content_form_inherits_resources :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// /Self names itself out of the page's resources, which is only possible
// because a form with no /Resources inherits the invoking stream's.
testing.expect_value(t, ct_run(doc, &rec, "/Self Do", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
@(test)
test_content_transparency_group :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, "/G1 gs /Grp Do", ct_res(doc)), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds,
[]Rec_Kind{.Clip_Path, .Begin_Group, .Fill_Path, .End_Group, .Pop_Clip}),
"the group brackets the form's contents inside its BBox clip")
g := rec_nth(&rec, .Begin_Group)
inner := rec_nth(&rec, .Fill_Path)
if g == nil || inner == nil { return }
testing.expect(t, g.isolated, "/I")
testing.expect(t, g.knockout, "/K")
ct_near(t, g.alpha, 0.5, "the group takes the current fill alpha")
testing.expect_value(t, g.blend, Blend_Mode.Multiply)
ct_point(t, Point{g.area.x0, g.area.y0}, 0, 0, "group area")
ct_point(t, Point{g.area.x1, g.area.y1}, 10, 10, "group area")
// 11.6.6: the contents composite into the group with the parameters reset.
ct_near(t, inner.paint.alpha, 1, "the group's contents are opaque inside it")
testing.expect_value(t, inner.paint.blend, Blend_Mode.Normal)
}
// ---------------------------------------------------------------------------
// Images
// ---------------------------------------------------------------------------
@(test)
test_content_image_xobject :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, "q 8 0 0 8 3 4 cm /Im Do Q", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Image)
if e == nil || e.img == nil {
testing.expect(t, false, "the image did not reach the device")
return
}
testing.expect_value(t, e.img.width, 2)
testing.expect_value(t, e.img.height, 2)
// An image always covers the unit square of the CTM (8.9.5.2).
ct_point(t, transform_point(e.ctm, Point{1, 1}), 11, 12, "the image's far corner")
}
@(test)
test_content_image_mask_takes_the_fill_colour :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, "1 0 0 rg /Stencil Do", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Image), 0)
e := rec_nth(&rec, .Fill_Image_Mask)
if e == nil {
testing.expect(t, false, "the stencil did not reach fill_image_mask")
return
}
testing.expect(t, e.paint.is_mask, "a stencil paints a shape, not a colour")
ct_near(t, e.paint.rgb[0], 1, "the stencil takes the current fill colour", 1e-6)
}
@(test)
test_content_ignore_image_hint :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
rec.dev.hints = {.Ignore_Image}
// Decoding a photograph to extract the text off the page it sits on is
// pure waste, and the hint is what lets the interpreter skip it.
testing.expect_value(t, ct_run(doc, &rec, "/Im Do 0 0 1 1 re f", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Image), 0)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
// ---------------------------------------------------------------------------
// Inline images
// ---------------------------------------------------------------------------
@(test)
test_content_inline_image_unfiltered :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
src := "BI /W 2 /H 2 /CS /G /BPC 8 ID \x00\x40\x80\xff EI 0 0 1 1 re f"
testing.expect_value(t, ct_run(doc, &rec, src, ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Image)
if e == nil || e.img == nil {
testing.expect(t, false, "the inline image did not reach the device")
return
}
testing.expect_value(t, e.img.width, 2)
testing.expect_value(t, e.img.height, 2)
// The operator after EI must still be read: a mis-measured image
// desynchronises the whole rest of the page.
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
// The bytes "EI" occur inside real image data. Unfiltered data has exactly
// one possible length, and computing it is the only way to be right.
@(test)
test_content_inline_image_data_containing_EI :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
before := len(doc.repairs)
// Four grey samples that spell " EI ", followed by the real EI.
src := "BI /W 4 /H 1 /CS /G /BPC 8 ID EI EI 0 0 1 1 re f"
testing.expect_value(t, ct_run(doc, &rec, src, ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Image)
if e == nil || e.img == nil {
testing.expect(t, false, "the image ended at the EI inside its own data")
return
}
testing.expect_value(t, e.img.width, 4)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
testing.expect_value(t, len(doc.repairs), before)
}
// Exactly one whitespace byte separates ID from the data (8.9.7). Consuming
// none, or two, shifts every sample by a byte -- which the length arithmetic
// then papers over, so only the pixels show it.
@(test)
test_content_inline_image_ID_separator :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// One mid-grey sample. A shift by one takes the separator space (0x20)
// for the pixel instead.
testing.expect_value(t, ct_run(doc, &rec,
"BI /W 1 /H 1 /CS /G /BPC 8 ID \x80 EI", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Image)
if e == nil || e.img == nil {
testing.expect(t, false, "the inline image did not load")
return
}
bmp, derr := image_decode(doc, e.img)
testing.expect_value(t, derr, Error.None)
if bmp == nil || len(bmp.pixels) < 1 {
testing.expect(t, false, "the inline image did not decode")
return
}
testing.expect_value(t, bmp.pixels[0], u8(0x80))
}
@(test)
test_content_inline_image_filtered :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// Filtered data has no computable length, so the end is scanned for: the
// EI must be preceded by whitespace and followed by whitespace or the end.
src := "BI /W 2 /H 2 /CS /G /BPC 8 /F /AHx ID 004080ff> EI 0 0 1 1 re f"
testing.expect_value(t, ct_run(doc, &rec, src, ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Image)
if e == nil || e.img == nil {
testing.expect(t, false, "the filtered inline image did not load")
return
}
testing.expect_value(t, e.img.width, 2)
bmp, derr := image_decode(doc, e.img)
testing.expect_value(t, derr, Error.None)
testing.expect(t, bmp != nil, "the hex data decoded")
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
@(test)
test_content_inline_image_explicit_length :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// PDF 2.0's /L removes the guesswork even for filtered data.
src := "BI /W 2 /H 2 /CS /G /BPC 8 /F /AHx /L 9 ID 004080ff> EI 0 0 1 1 re f"
testing.expect_value(t, ct_run(doc, &rec, src, ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Image), 1)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
@(test)
test_content_inline_image_truncated :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// No EI at all. The run has to end rather than spin, and the damage has
// to be recorded.
before := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec,
"0 0 1 1 re f BI /W 2 /H 2 /CS /G /BPC 8 ID \x00\x40", ct_res(doc)), Error.None)
testing.expect(t, len(doc.repairs) > before, "a truncated inline image is a repair")
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
@(test)
test_content_inline_image_no_ID :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// A BI whose dictionary never reaches ID must not eat the rest of the page.
before := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec, "BI /W 2 /H 2 EI 0 0 1 1 re f", ct_res(doc)), Error.None)
testing.expect(t, len(doc.repairs) > before, "")
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
// ---------------------------------------------------------------------------
// Shadings and patterns
// ---------------------------------------------------------------------------
@(test)
test_content_sh_paints_through_the_current_clip :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// `sh` has no bounding box and no matrix of its own: it paints under the
// live CTM, over whatever the clip allows.
testing.expect_value(t, ct_run(doc, &rec, "2 0 0 2 9 9 cm /Sh sh", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Shade)
if e == nil || e.shade == nil {
testing.expect(t, false, "sh did not reach the device")
return
}
testing.expect_value(t, e.shade.kind, Shading_Kind.Axial)
ct_near(t, e.ctm.a, 2, "sh uses the current CTM")
ct_near(t, e.ctm.e, 9, "sh uses the current CTM")
}
@(test)
test_content_ignore_shade_hint :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
rec.dev.hints = {.Ignore_Shade}
testing.expect_value(t, ct_run(doc, &rec, "/Sh sh 0 0 1 1 re f", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Shade), 0)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
// A shading pattern paints through the shape as a clip, with the pattern
// matrix anchored to the stream's default space -- NOT to the CTM of the fill.
@(test)
test_content_shading_pattern_fill :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"/Pattern cs /Shade scn 0 0 10 10 re f", ct_res(doc)), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds, []Rec_Kind{.Clip_Path, .Fill_Shade, .Pop_Clip}),
"a shading pattern is a shading clipped to the shape")
e := rec_nth(&rec, .Fill_Shade)
if e == nil { return }
ct_point(t, transform_point(e.ctm, Point{0, 0}), 3, 4, "the pattern matrix")
}
// Pattern space is anchored to the page, not to the fill: the same pattern
// under a different CTM must land on exactly the same lattice, or the texture
// slides around under a transformed shape.
@(test)
test_content_pattern_space_ignores_the_fill_ctm :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"/Pattern cs /Shade scn 0 0 10 10 re f " +
"q 5 0 0 5 100 100 cm /Pattern cs /Shade scn 0 0 10 10 re f Q", ct_res(doc)), Error.None)
a := rec_nth(&rec, .Fill_Shade, 0)
b := rec_nth(&rec, .Fill_Shade, 1)
if a == nil || b == nil {
testing.expect(t, false, "both patterned fills must paint")
return
}
testing.expect(t, a.ctm == b.ctm, "the pattern matrix must not follow the CTM of the fill")
}
@(test)
test_content_tiling_pattern_device_tiles :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
rec.tiles = 0 // "I will place the copies; run the cell once"
testing.expect_value(t, ct_run(doc, &rec,
"/Pattern cs /Tile scn 0 0 20 20 re f", ct_res(doc)), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds, []Rec_Kind{
.Clip_Path, // the shape
.Begin_Tile,
.Clip_Path, // the cell's BBox
.Fill_Path, // the cell's content
.Pop_Clip,
.End_Tile,
.Pop_Clip,
}), "a device that tiles gets the cell exactly once")
bt := rec_nth(&rec, .Begin_Tile)
if bt == nil { return }
ct_near(t, bt.xstep, 10, "xstep")
ct_near(t, bt.ystep, 10, "ystep")
ct_point(t, Point{bt.view.x1, bt.view.y1}, 10, 10, "the cell's bbox")
ct_point(t, Point{bt.area.x1, bt.area.y1}, 20, 20, "the area to cover")
}
@(test)
test_content_tiling_pattern_interpreter_places_cells :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
rec.tiles = 4 // "I cannot tile; place four copies for me"
testing.expect_value(t, ct_run(doc, &rec,
"/Pattern cs /Tile scn 0 0 20 20 re f", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Begin_Tile), 1)
testing.expect_value(t, rec_count(&rec, .End_Tile), 1)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 4)
// Every cell is placed by a translation of the same matrix, so no two
// land on top of one another.
a := rec_nth(&rec, .Fill_Path, 0)
b := rec_nth(&rec, .Fill_Path, 1)
if a == nil || b == nil { return }
ct_near(t, a.ctm.a, 1, "cells share the pattern's scale")
ct_near(t, b.ctm.a, 1, "cells share the pattern's scale")
testing.expect(t, a.ctm.e != b.ctm.e || a.ctm.f != b.ctm.f, "cells must not coincide")
}
// /PaintType 2: the cell is a stencil, and every colour operator inside it is
// ignored in favour of the colour given with the pattern name.
@(test)
test_content_uncoloured_tiling_pattern_takes_the_callers_colour :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
rec.tiles = 1
testing.expect_value(t, ct_run(doc, &rec,
"/PatRGB cs 0 1 0 /Uncol scn 0 0 20 20 re f", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Path)
if e == nil {
testing.expect(t, false, "the uncoloured cell did not paint")
return
}
// The cell says `1 0 0 rg`; the caller said green.
ct_near(t, e.paint.rgb[0], 0, "the cell's own red must be ignored", 1e-6)
ct_near(t, e.paint.rgb[1], 1, "the cell paints in the caller's colour", 1e-6)
}
@(test)
test_content_pattern_without_a_name_paints_nothing :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// A Pattern colour space with no pattern selected has nothing to paint
// with, and must not fall back to black.
testing.expect_value(t, ct_run(doc, &rec, "/Pattern cs 0 0 10 10 re f", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 0)
testing.expect_value(t, rec_count(&rec, .Fill_Shade), 0)
}
// ---------------------------------------------------------------------------
// Soft masks
// ---------------------------------------------------------------------------
@(test)
test_content_soft_mask_is_rendered_at_install_time :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, "/GMask gs 0 0 1 1 re f", ct_res(doc)), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds, []Rec_Kind{
.Begin_Mask, .Clip_Path, .Fill_Path, .Pop_Clip, .End_Mask, .Fill_Path,
}), "the mask's group is drawn between begin_mask and end_mask")
bm := rec_nth(&rec, .Begin_Mask)
painted := rec_nth(&rec, .Fill_Path, 1)
if bm == nil || painted == nil { return }
testing.expect(t, bm.luminosity, "/S /Luminosity")
ct_point(t, Point{bm.area.x1, bm.area.y1}, 20, 20, "the mask's area is its BBox")
testing.expect(t, painted.has_smask, "the paint carries the installed mask")
if painted.has_smask {
testing.expect(t, painted.smask.luminosity, "")
}
}
@(test)
test_content_smask_none_clears_it :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"/GMask gs /GNone gs 0 0 1 1 re f", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Path, 1)
if e == nil { return }
testing.expect(t, e.paint.soft_mask == nil, "/SMask /None must clear the mask")
}
@(test)
test_content_soft_mask_is_restored_by_Q :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"q /GMask gs Q 0 0 1 1 re f", ct_res(doc)), Error.None)
// The last fill is the page's, after the mask went out of scope.
e := rec_nth(&rec, .Fill_Path, 1)
if e == nil { return }
testing.expect(t, e.paint.soft_mask == nil, "Q must take the soft mask with it")
}
// ---------------------------------------------------------------------------
// Marked content and optional content
// ---------------------------------------------------------------------------
// ct_optional_content adds an /OCProperties to the catalog with `off` switched
// off, and returns resources whose /Properties name both groups.
@(private = "file")
ct_optional_content :: proc(doc: ^Document, res: ^Dict) -> (on_ocg, off_ocg: ^Dict) {
on_ocg, _ = to_dict(cf_parse(doc, "<< /Type /OCG /Name (Visible) >>"))
off_ocg, _ = to_dict(cf_parse(doc, "<< /Type /OCG /Name (Hidden) >>"))
props := new_dict(doc.allocator)
dict_set(props, "L1", on_ocg)
dict_set(props, "L2", off_ocg)
dict_set(res, "Properties", props)
off_list := new_array(doc.allocator)
array_push(off_list, off_ocg)
cfg := new_dict(doc.allocator)
dict_set(cfg, "OFF", off_list)
ocp := new_dict(doc.allocator)
dict_set(ocp, "D", cfg)
dict_set(doc.catalog, "OCProperties", ocp)
return
}
@(test)
test_content_bmc_emc_do_not_disturb_drawing :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"/Span BMC 0 0 1 1 re f EMC /P << /MCID 0 >> BDC 0 0 1 1 re f EMC"), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 2)
testing.expect_value(t, rec_count(&rec, .Begin_Layer), 0)
}
@(test)
test_content_visible_layer_brackets_its_content :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
ct_optional_content(doc, res)
testing.expect_value(t, ct_run(doc, &rec, "/OC /L1 BDC 0 0 1 1 re f EMC", res), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds, []Rec_Kind{.Begin_Layer, .Fill_Path, .End_Layer}), "")
e := rec_nth(&rec, .Begin_Layer)
if e == nil { return }
testing.expect_value(t, e.name, "Visible")
}
@(test)
test_content_hidden_layer_is_skipped :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
ct_optional_content(doc, res)
testing.expect_value(t, ct_run(doc, &rec,
"/OC /L2 BDC 0 0 1 1 re f /Im Do BT /F1 10 Tf (A) Tj ET EMC 0 0 1 1 re f", res), Error.None)
// Only the fill after EMC survives.
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
testing.expect_value(t, rec_count(&rec, .Fill_Image), 0)
testing.expect_value(t, rec_count(&rec, .Fill_Text), 0)
testing.expect_value(t, rec_count(&rec, .Begin_Layer), 0)
}
// The hidden region ends at the MATCHING EMC, so a nested section inside it
// must not turn drawing back on.
@(test)
test_content_hidden_layer_nesting :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
ct_optional_content(doc, res)
testing.expect_value(t, ct_run(doc, &rec,
"/OC /L2 BDC /Span BMC 0 0 1 1 re f EMC 0 0 1 1 re f EMC 0 0 1 1 re f", res), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
@(test)
test_content_optional_content_can_be_ignored :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
ct_optional_content(doc, res)
// Hidden text is still text: an extractor asks for all of it.
err := ct_run(doc, &rec, "/OC /L2 BDC 0 0 1 1 re f EMC", res, MATRIX_IDENTITY,
Content_Options{ignore_optional_content = true})
testing.expect_value(t, err, Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
@(test)
test_content_stray_EMC_is_ignored :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
before := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec, "EMC EMC 0 0 1 1 re f"), Error.None)
testing.expect(t, len(doc.repairs) > before, "a stray EMC is a repair")
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
// A layer left open at the end of the stream still has to be closed, or the
// device's layer stack never unwinds.
@(test)
test_content_unclosed_layer_is_closed_at_the_end :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
ct_optional_content(doc, res)
testing.expect_value(t, ct_run(doc, &rec, "/OC /L1 BDC 0 0 1 1 re f", res), Error.None)
testing.expect_value(t, rec_count(&rec, .Begin_Layer), 1)
testing.expect_value(t, rec_count(&rec, .End_Layer), 1)
}
// ---------------------------------------------------------------------------
// Robustness
//
// Content streams are the most attacker-controlled data in a PDF and the most
// malformed. Nothing below may abort a page.
// ---------------------------------------------------------------------------
@(test)
test_content_unknown_operator_is_skipped :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
before := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec,
"0 0 1 1 re f /Foo 1 2 (x) frobnicate 0 0 1 1 re f"), Error.None)
testing.expect(t, len(doc.repairs) > before, "an unknown operator is a repair")
// It takes its operands with it, so the next operator starts clean.
testing.expect_value(t, rec_count(&rec, .Fill_Path), 2)
}
@(test)
test_content_BX_EX_permits_unknown_operators :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
before := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec,
"BX 1 2 frobnicate ] >> EX 0 0 1 1 re f"), Error.None)
testing.expect_value(t, len(doc.repairs), before)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
// The same operator outside the bracket is damage again.
testing.expect_value(t, ct_run(doc, &rec, "frobnicate"), Error.None)
testing.expect(t, len(doc.repairs) > before, "EX must end the compatibility section")
}
@(test)
test_content_truncated_stream_ends_cleanly :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// Ends inside a text object, inside a q, with a clip open and an operator
// half-written.
testing.expect_value(t, ct_run(doc, &rec,
"q 0 0 10 10 re W n 0 0 1 1 re f BT /F1 10 Tf 7 Tr (A) T", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
// Every clip pushed is popped, including the text clip the missing ET owed.
pushes := rec_count(&rec, .Clip_Path) + rec_count(&rec, .Clip_Text)
testing.expect_value(t, rec_count(&rec, .Pop_Clip), pushes)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, kinds[len(kinds) - 1] == .Pop_Clip, "the clip stack unwinds last")
}
@(test)
test_content_unclosed_q_unwinds :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
before := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec, "q q q 0 0 10 10 re W n 0 0 1 1 re f"), Error.None)
testing.expect(t, len(doc.repairs) > before, "an unclosed q is a repair")
testing.expect_value(t, rec_count(&rec, .Clip_Path), 1)
testing.expect_value(t, rec_count(&rec, .Pop_Clip), 1)
}
// The operand stack is fixed, and an operator reads its arguments from the
// END of it, so a stream that pushes hundreds still runs with the right ones.
@(test)
test_content_operand_stack_keeps_the_newest :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
b := strings.builder_make()
defer strings.builder_destroy(&b)
for i in 1 ..= MAX_CONTENT_OPERANDS + 8 {
strings.write_int(&b, i)
strings.write_byte(&b, ' ')
}
strings.write_string(&b, "re f")
testing.expect_value(t, ct_run(doc, &rec, strings.to_string(b)), Error.None)
e := rec_nth(&rec, .Fill_Path)
if e == nil || len(e.points) < 1 {
testing.expect(t, false, "the overloaded operator did not run")
return
}
last :: MAX_CONTENT_OPERANDS + 8
ct_point(t, e.points[0], f64(last - 3), f64(last - 2), "the last four operands are the ones used")
}
@(test)
test_content_too_few_operands_is_skipped :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
before := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec, "1 2 re f 0 0 1 1 re f"), Error.None)
testing.expect(t, len(doc.repairs) > before, "a short operator is a repair")
// The `f` still runs, on an empty path, and the next rectangle is intact.
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
e := rec_nth(&rec, .Fill_Path)
if e == nil || len(e.points) < 1 { return }
ct_point(t, e.points[0], 0, 0, "the following operator is unaffected")
}
@(test)
test_content_fuel_bounds_the_run :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
b := strings.builder_make()
defer strings.builder_destroy(&b)
for _ in 0 ..< 200 {
strings.write_string(&b, "0 0 1 1 re f ")
}
// Five rectangles is 30 tokens; a budget of 40 must stop well short of 200.
err := ct_run(doc, &rec, strings.to_string(b), nil, MATRIX_IDENTITY,
Content_Options{max_ops = 40})
testing.expect_value(t, err, Error.None)
n := rec_count(&rec, .Fill_Path)
testing.expect(t, n > 0 && n < 20, "the budget must bound the run without emptying it")
}
@(test)
test_content_nil_device_and_empty_input :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
// A run with no sink executes everything and draws nothing. It is how the
// interpreter is measured, and it must not crash on a nil device.
testing.expect_value(t, run_content(doc, nil,
transmute([]byte)string("q 1 0 0 1 2 2 cm 0 0 1 1 re W n /Im Do Q BT /F1 9 Tf (hi) Tj ET"),
ct_res(doc), MATRIX_IDENTITY), Error.None)
testing.expect_value(t, run_content(doc, nil, nil, nil, MATRIX_IDENTITY), Error.None)
testing.expect_value(t, run_content(nil, nil, transmute([]byte)string("f"), nil, MATRIX_IDENTITY),
Error.Invalid_Argument)
bad := Matrix{1, 0, 0, 1, 0, math.INF_F64}
testing.expect_value(t, run_content(doc, nil, transmute([]byte)string("f"), nil, bad),
Error.Invalid_Argument)
}
@(test)
test_content_mutually_recursive_forms_terminate :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
xobj := dict_dict(doc, res, "XObject")
dict_set(xobj, "Ping", cf_stream(doc,
"<< /Subtype /Form /BBox [0 0 10 10] >>", transmute([]byte)string("/Pong Do")))
dict_set(xobj, "Pong", cf_stream(doc,
"<< /Subtype /Form /BBox [0 0 10 10] >>", transmute([]byte)string("/Ping Do 0 0 1 1 re f")))
testing.expect_value(t, ct_run(doc, &rec, "/Ping Do", res), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
testing.expect_value(t, rec_count(&rec, .Clip_Path), rec_count(&rec, .Pop_Clip))
}
@(test)
test_content_type3_glyph_that_shows_itself_terminates :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
// A glyph description that shows its own character through its own font.
t3, _ := to_dict(dict_get(doc, dict_dict(doc, res, "Font"), "T3"))
procs := dict_dict(doc, t3, "CharProcs")
dict_set(procs, "square", cf_stream(doc, "<< >>",
transmute([]byte)string("100 0 d0 0 0 100 100 re f BT /T3 10 Tf (A) Tj ET")))
t3res := new_dict(doc.allocator)
dict_set(t3res, "Font", dict_get_raw(res, "Font"))
dict_set(t3, "Resources", t3res)
testing.expect_value(t, ct_run(doc, &rec, "BT /T3 10 Tf (A) Tj ET", res), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
@(test)
test_content_d0_d1_outside_a_glyph_are_harmless :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// d1 declares a stencil, and outside a Type 3 glyph description that
// would silently swallow every colour operator on the page.
testing.expect_value(t, ct_run(doc, &rec,
"100 0 0 0 100 100 d1 1 0 0 rg 0 0 1 1 re f"), Error.None)
e := rec_nth(&rec, .Fill_Path)
if e == nil { return }
ct_near(t, e.paint.rgb[0], 1, "colour still works after a stray d1", 1e-6)
testing.expect_value(t, rec_count(&rec, .Clip_Path), 0)
}
// Inside a glyph description d1 does take effect: the whole glyph paints in
// the colour that was current when it was shown.
@(test)
test_content_d1_ignores_colour_inside_a_glyph :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
t3, _ := to_dict(dict_get(doc, dict_dict(doc, res, "Font"), "T3"))
procs := dict_dict(doc, t3, "CharProcs")
dict_set(procs, "square", cf_stream(doc, "<< >>",
transmute([]byte)string("100 0 0 0 100 100 d1 0 0 1 rg 0 0 100 100 re f")))
testing.expect_value(t, ct_run(doc, &rec, "1 0 0 rg BT /T3 10 Tf (A) Tj ET", res), Error.None)
e := rec_nth(&rec, .Fill_Path)
if e == nil {
testing.expect(t, false, "the glyph did not draw")
return
}
ct_near(t, e.paint.rgb[0], 1, "d1 keeps the caller's colour", 1e-6)
ct_near(t, e.paint.rgb[2], 0, "the glyph's own blue must be ignored", 1e-6)
// The declared bounding box clips the glyph.
testing.expect_value(t, rec_count(&rec, .Clip_Path), 1)
testing.expect_value(t, rec_count(&rec, .Pop_Clip), 1)
}
@(test)
test_content_text_operators_outside_BT :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
// Producers omit BT. The text matrix starts as the identity either way,
// so showing text still works rather than being dropped.
testing.expect_value(t, ct_run(doc, &rec,
"/F1 10 Tf 5 5 Td (A) Tj ET", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Text)
if e == nil {
testing.expect(t, false, "text without BT must still draw")
return
}
ct_point(t, ct_origin(e.items, 0), 5, 5, "")
}
@(test)
test_content_junk_bytes_do_not_stop_the_page :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"0 0 1 1 re f ] } ) >> << /A 1 >> [1 2 3] % a comment\n 0 0 1 1 re f"), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 2)
}
// Truncating a rich stream at every offset must never crash, hang or leave the
// device's clip stack unbalanced. Reaching the end of this test is the property.
@(test)
test_content_truncation_fuzz :: proc(t: ^testing.T) {
SRC :: "q 2 0 0 2 1 1 cm /G1 gs 0 0 10 10 re W n 1 0 0 rg /Pattern cs /Tile scn " +
"0 0 20 20 re f BT /F1 10 Tf 1 Tc 2 Tw 120 Tz 3 Ts 4 Tr [(AB) -300 (C)] TJ ET " +
"/Sh sh /Box Do /Im Do BI /W 2 /H 2 /CS /G /BPC 8 ID \x01\x02\x03\x04 EI " +
"/OC /L1 BDC 0 0 1 1 re f EMC Q"
src := SRC
for cut in 0 ..= len(src) {
doc := cf_open(t)
rec: Recorder
rec_init(&rec)
err := ct_run(doc, &rec, src[:cut], ct_res(doc))
testing.expectf(t, err == .None, "truncation at %d returned %v", cut, err)
pushes := rec_count(&rec, .Clip_Path) + rec_count(&rec, .Clip_Stroke_Path) +
rec_count(&rec, .Clip_Text) + rec_count(&rec, .Clip_Image_Mask)
testing.expectf(t, pushes == rec_count(&rec, .Pop_Clip),
"truncation at %d left the clip stack unbalanced", cut)
testing.expectf(t, rec_count(&rec, .Begin_Layer) == rec_count(&rec, .End_Layer),
"truncation at %d left a layer open", cut)
testing.expectf(t, rec_count(&rec, .Begin_Tile) == rec_count(&rec, .End_Tile),
"truncation at %d left a tile open", cut)
testing.expectf(t, rec_count(&rec, .Begin_Group) == rec_count(&rec, .End_Group),
"truncation at %d left a group open", cut)
rec_destroy(&rec)
cf_close(t, doc)
}
testing.expect(t, true, "every truncation was survived")
}
// The same, byte by byte: one flipped byte anywhere must not change that.
@(test)
test_content_bit_flip_fuzz :: proc(t: ^testing.T) {
SRC :: "q 1 0 0 1 2 2 cm 0 0 10 10 re W n BT /F1 12 Tf (hello) Tj ET " +
"/Box Do BI /W 2 /H 2 /CS /G /BPC 8 ID \x01\x02\x03\x04 EI Q"
buf := make([]byte, len(SRC))
defer delete(buf)
for i in 0 ..< len(SRC) {
for bit in 0 ..< 3 {
copy(buf, transmute([]byte)string(SRC))
buf[i] ~= 1 << u8(bit * 3)
doc := cf_open(t)
rec: Recorder
rec_init(&rec)
err := run_content(doc, &rec.dev, buf, ct_res(doc), MATRIX_IDENTITY)
testing.expectf(t, err == .None, "flip at %d/%d returned %v", i, bit, err)
pushes := rec_count(&rec, .Clip_Path) + rec_count(&rec, .Clip_Stroke_Path) +
rec_count(&rec, .Clip_Text) + rec_count(&rec, .Clip_Image_Mask)
testing.expectf(t, pushes == rec_count(&rec, .Pop_Clip),
"flip at %d/%d left the clip stack unbalanced", i, bit)
rec_destroy(&rec)
cf_close(t, doc)
}
}
testing.expect(t, true, "every single-bit flip was survived")
}
// A tiling pattern replays its cell thousands of times, and every replay is a
// fresh run of the interpreter. Scratch memory has to be reused between them:
// the run's allocator is an arena and reclaims nothing until the end, so a
// runner that allocated its own operand stack per cell would cost tens of
// megabytes for one fill.
@(test)
test_content_scratch_memory_is_reused_between_runs :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
rec.tiles = 4096
arena: virtual.Arena
if aerr := virtual.arena_init_growing(&arena); aerr != nil {
testing.expect(t, false, "could not make an arena")
return
}
defer virtual.arena_destroy(&arena)
err := ct_run(doc, &rec, "/Pattern cs /Tile scn 0 0 2000 2000 re f", ct_res(doc),
MATRIX_IDENTITY, Content_Options{allocator = virtual.arena_allocator(&arena)})
testing.expect_value(t, err, Error.None)
cells := rec_count(&rec, .Fill_Path)
testing.expect(t, cells > 1000, "the cell must actually have been replayed")
used := int(arena.total_used)
testing.expectf(t, used < 4 << 20, "%d bytes of scratch for %d cells", used, cells)
}
// ---------------------------------------------------------------------------
// Corpus
//
// The interpreter driven over every page of every real PDF in the directory
// named by PDF_ODIN_CORPUS, and skipped silently when that is unset. There is
// no correct answer to compare a real page against -- that is what the tests
// above are for -- so what is asserted here is the contract the three devices
// will be written against: the run terminates, it never returns an error, and
// the clip, layer, group and tile stacks it hands the device are balanced when
// it does. That is exactly the class of bug a corpus finds and a unit test
// cannot.
// ---------------------------------------------------------------------------
@(private = "file")
CC_MAX_FILE :: 64 << 20
@(private = "file")
CC_MAX_DEPTH :: 8
@(private = "file")
Cc_Dev :: struct {
dev: Device,
// Live nesting, so an unbalanced pop is caught where it happens.
clip: int,
layer: int,
group: int,
tile: int,
mask: int,
// The worst each reached, and whether anything ever went negative.
negative: bool,
// Totals, reported at the end so a silent no-op run cannot pass for a
// successful one.
fills, strokes, texts, glyphs, images, shades: int,
}
@(private = "file")
cc :: proc(dev: ^Device) -> ^Cc_Dev { return (^Cc_Dev)(dev.user) }
@(private = "file")
cc_fill_path :: proc(dev: ^Device, path: ^Path, even_odd: bool, ctm: Matrix, paint: Paint) {
cc(dev).fills += 1
}
@(private = "file")
cc_stroke_path :: proc(dev: ^Device, path: ^Path, stroke: ^Stroke_State, ctm: Matrix, paint: Paint) {
cc(dev).strokes += 1
}
@(private = "file")
cc_clip_path :: proc(dev: ^Device, path: ^Path, even_odd: bool, ctm: Matrix) {
cc(dev).clip += 1
}
@(private = "file")
cc_clip_stroke :: proc(dev: ^Device, path: ^Path, stroke: ^Stroke_State, ctm: Matrix) {
cc(dev).clip += 1
}
@(private = "file")
cc_clip_text :: proc(dev: ^Device, txt: ^Text, ctm: Matrix) {
cc(dev).clip += 1
}
@(private = "file")
cc_clip_image_mask :: proc(dev: ^Device, img: ^Image, ctm: Matrix) {
cc(dev).clip += 1
}
@(private = "file")
cc_pop_clip :: proc(dev: ^Device) {
d := cc(dev)
d.clip -= 1
if d.clip < 0 { d.negative = true }
}
@(private = "file")
cc_text :: proc(dev: ^Device, txt: ^Text, ctm: Matrix, paint: Paint) {
d := cc(dev)
d.texts += 1
d.glyphs += len(txt.items)
}
@(private = "file")
cc_stroke_text :: proc(dev: ^Device, txt: ^Text, stroke: ^Stroke_State, ctm: Matrix, paint: Paint) {
cc(dev).texts += 1
}
@(private = "file")
cc_ignore_text :: proc(dev: ^Device, txt: ^Text, ctm: Matrix) {
d := cc(dev)
d.texts += 1
d.glyphs += len(txt.items)
}
@(private = "file")
cc_image :: proc(dev: ^Device, img: ^Image, ctm: Matrix, paint: Paint) {
cc(dev).images += 1
}
@(private = "file")
cc_shade :: proc(dev: ^Device, shade: ^Shading, ctm: Matrix, paint: Paint) {
cc(dev).shades += 1
}
@(private = "file")
cc_begin_mask :: proc(dev: ^Device, area: Rect, luminosity: bool, backdrop: Paint) {
cc(dev).mask += 1
}
@(private = "file")
cc_end_mask :: proc(dev: ^Device, mask: ^Soft_Mask, transfer: ^Function) {
d := cc(dev)
d.mask -= 1
if d.mask < 0 { d.negative = true }
}
@(private = "file")
cc_begin_group :: proc(dev: ^Device, area: Rect, isolated, knockout: bool, blend: Blend_Mode, alpha: f64, soft_mask: ^Soft_Mask) {
cc(dev).group += 1
}
@(private = "file")
cc_end_group :: proc(dev: ^Device) {
d := cc(dev)
d.group -= 1
if d.group < 0 { d.negative = true }
}
@(private = "file")
cc_begin_tile :: proc(dev: ^Device, area, view: Rect, xstep, ystep: f64, ctm: Matrix) -> int {
cc(dev).tile += 1
return 0
}
@(private = "file")
cc_end_tile :: proc(dev: ^Device) {
d := cc(dev)
d.tile -= 1
if d.tile < 0 { d.negative = true }
}
@(private = "file")
cc_begin_layer :: proc(dev: ^Device, name: string) {
cc(dev).layer += 1
}
@(private = "file")
cc_end_layer :: proc(dev: ^Device) {
d := cc(dev)
d.layer -= 1
if d.layer < 0 { d.negative = true }
}
@(private = "file")
cc_init :: proc(d: ^Cc_Dev) {
d.dev = Device{
user = d,
fill_path = cc_fill_path,
stroke_path = cc_stroke_path,
clip_path = cc_clip_path,
clip_stroke_path = cc_clip_stroke,
clip_text = cc_clip_text,
clip_image_mask = cc_clip_image_mask,
pop_clip = cc_pop_clip,
fill_text = cc_text,
stroke_text = cc_stroke_text,
ignore_text = cc_ignore_text,
fill_image = cc_image,
fill_image_mask = cc_image,
fill_shade = cc_shade,
begin_mask = cc_begin_mask,
end_mask = cc_end_mask,
begin_group = cc_begin_group,
end_group = cc_end_group,
begin_tile = cc_begin_tile,
end_tile = cc_end_tile,
begin_layer = cc_begin_layer,
end_layer = cc_end_layer,
}
}
@(private = "file")
cc_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")
cc_walk :: proc(dir: string, out: ^[dynamic]string, depth := 0) {
if depth > CC_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 {
cc_walk(e.fullpath, out, depth + 1)
continue
}
if e.type != .Regular || e.size > CC_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_content_corpus :: proc(t: ^testing.T) {
dir := cc_corpus_dir()
defer delete(dir)
if dir == "" {
log.info("PDF_ODIN_CORPUS is unset; skipping the content 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)
}
cc_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. That is how a
// renderer is meant to drive the interpreter, and it is the difference
// between a 900-page document costing one page's scratch and 900.
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: Cc_Dev
files, pages, peak := 0, 0, 0
for path in paths {
doc, err := open_file(path)
if err != .None || doc == nil { continue }
defer close(doc)
files += 1
for i in 0 ..< page_count(doc) {
page, perr := page_get(doc, i)
if perr != .None { continue }
data, cerr := page_content(&page, context.allocator)
defer delete(data)
if cerr != .None || len(data) == 0 { continue }
dev: Cc_Dev
cc_init(&dev)
// The page flip a renderer would use, so the geometry is real.
flip := Matrix{1, 0, 0, -1, -page.media_box.x0, page.media_box.y1}
rerr := run_content(doc, &dev.dev, data, page.resources, flip, opts)
name := filepath.base(path)
testing.expectf(t, rerr == .None, "%s page %d: run_content 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.negative, "%s page %d: a device stack went negative", name, i)
testing.expectf(t, dev.layer == 0, "%s page %d: %d layers left open", name, i, dev.layer)
testing.expectf(t, dev.group == 0, "%s page %d: %d groups left open", name, i, dev.group)
testing.expectf(t, dev.tile == 0, "%s page %d: %d tiles left open", name, i, dev.tile)
testing.expectf(t, dev.mask == 0, "%s page %d: %d masks left open", name, i, dev.mask)
total.fills += dev.fills
total.strokes += dev.strokes
total.texts += dev.texts
total.glyphs += dev.glyphs
total.images += dev.images
total.shades += dev.shades
pages += 1
peak = max(peak, int(arena.total_used))
free_all(virtual.arena_allocator(&arena))
}
}
log.infof("content corpus: %d files, %d pages, %d fills, %d strokes, %d text runs, " +
"%d glyphs, %d images, %d shadings, peak scratch %d KiB",
files, pages, total.fills, total.strokes, total.texts,
total.glyphs, total.images, total.shades, peak / 1024)
// A sweep that drew nothing would pass every assertion above.
testing.expect(t, total.glyphs > 0, "the corpus produced no glyphs at all")
testing.expect(t, total.fills + total.strokes > 0, "the corpus produced no path painting")
}
// 8.7.4.3: a shading pattern's own /ExtGState is put into effect while it
// paints, and replaces the compositing parameters rather than compounding
// with them.
@(test)
test_content_shading_pattern_extgstate :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"/G1 gs /Pattern cs /ShadeGS scn 0 0 10 10 re f", ct_res(doc)), Error.None)
e := rec_nth(&rec, .Fill_Shade)
if e == nil {
testing.expect(t, false, "the shading pattern did not paint")
return
}
// /G1 set ca 0.5 and Multiply; the pattern's own state says 0.25 and Screen.
ct_near(t, e.paint.alpha, 0.25, "the pattern's ExtGState alpha wins")
testing.expect_value(t, e.paint.blend, Blend_Mode.Screen)
}
// A BDC that arrives without its two operands still OPENS a section. If it did
// not, the matching EMC would close the ENCLOSING one: the /OC section here
// would end at the first EMC, the fill would stop being hidden, and the rest of
// the outer section would draw. `content_mc_begin` has the machinery for a BDC
// it cannot record -- the `mc_over` counter -- and the short-operand path used
// to return before reaching it.
@(test)
test_content_short_bdc_still_pairs_with_its_emc :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
ct_optional_content(doc, res)
// The inner BDC is one operand short, so the first EMC must close IT.
testing.expect_value(t, ct_run(doc, &rec,
"/OC /L2 BDC /Foo BDC EMC 0 0 1 1 re f EMC", res), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 0)
}
// The same stream over a VISIBLE layer: the fill belongs inside the layer, so
// end_layer must not arrive before it.
@(test)
test_content_short_bdc_does_not_close_a_layer_early :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
ct_optional_content(doc, res)
testing.expect_value(t, ct_run(doc, &rec,
"/OC /L1 BDC /Foo BDC EMC 0 0 1 1 re f EMC", res), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds, []Rec_Kind{.Begin_Layer, .Fill_Path, .End_Layer}),
"the fill is inside the layer it was written inside")
}
// The same nesting with a WELL-FORMED inner BDC, which is the behaviour the
// anonymous section above has to imitate: the first EMC closes the INNER
// section and the second the outer one, so the fill between them is still
// inside the hidden section and the one after both of them is not.
@(test)
test_content_nested_bdc_pairs_innermost_first :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
ct_optional_content(doc, res)
testing.expect_value(t, ct_run(doc, &rec,
"/OC /L2 BDC /P << /MCID 0 >> BDC EMC 0 0 1 1 re f EMC 0 0 1 1 re f", res), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}
// And over a VISIBLE layer, where the device layer's span is what is at stake:
// the inner EMC must not end it early.
@(test)
test_content_nested_bdc_brackets_a_visible_layer :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := ct_res(doc)
ct_optional_content(doc, res)
testing.expect_value(t, ct_run(doc, &rec,
"/OC /L1 BDC /P << /MCID 0 >> BDC EMC 0 0 1 1 re f EMC", res), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds, []Rec_Kind{.Begin_Layer, .Fill_Path, .End_Layer}),
"the inner EMC closes the inner section, not the layer")
}
// 8.10.2 makes /BBox a clip on the form's contents, so a degenerate box means
// nothing is drawn. Skipping the clip because the box has no area meant the
// opposite: the form painted its whole content, unclipped, over the page.
@(test)
test_content_form_with_a_zero_area_bbox_is_clipped_away :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
res := new_dict(doc.allocator)
xo := new_dict(doc.allocator)
dict_set(xo, "Zero", cf_stream(doc,
"<< /Type /XObject /Subtype /Form /BBox [0 0 0 0] >>",
transmute([]byte)string("0 0 20 20 re f")))
dict_set(xo, "Flat", cf_stream(doc,
"<< /Type /XObject /Subtype /Form /BBox [0 0 100 0] >>",
transmute([]byte)string("0 0 20 20 re f")))
dict_set(res, "XObject", xo)
testing.expect_value(t, ct_run(doc, &rec, "/Zero Do /Flat Do", res), Error.None)
kinds := rec_kinds(&rec)
defer delete(kinds)
testing.expect(t, slice.equal(kinds, []Rec_Kind{
.Clip_Path, .Fill_Path, .Pop_Clip,
.Clip_Path, .Fill_Path, .Pop_Clip,
}), "a degenerate /BBox is pushed as a clip like any other")
}
// ---------------------------------------------------------------------------
// What one page may spend
//
// A page is not one run -- the content stream is one and every annotation's
// appearance stream is another -- and the things below are bounded per PAGE
// on the shared state, because a per-run cap would let MAX_ANNOTS of them
// multiply it. Each of these is a small file that used to cost minutes or
// gigabytes.
// ---------------------------------------------------------------------------
// The same ExtGState applied again under the same CTM is the same mask.
// Rebuilding it costs a slot of the page's budget, an allocation, and -- since
// the device would not recognise the new one -- a whole re-render of the
// mask's group: 3000 `/S gs` measured 57 seconds at 1x.
@(test)
test_content_repeated_gs_renders_one_soft_mask :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
N :: 3000
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
for _ in 0 ..< N {
strings.write_string(&b, "/GMask gs ")
}
strings.write_string(&b, "0 0 1 1 re f")
start := time.now()
testing.expect_value(t, ct_run(doc, &rec, strings.to_string(b), ct_res(doc)), Error.None)
ms := time.duration_milliseconds(time.since(start))
testing.expectf(t, rec_count(&rec, .Begin_Mask) == 1,
"%d gs installed the same mask %d times", N, rec_count(&rec, .Begin_Mask))
// The first fill is the mask group's own; the second is the page's.
e := rec_nth(&rec, .Fill_Path, 1)
testing.expect(t, e != nil && e.has_smask, "and the mask is still in force")
// Not a performance bound: one re-render per `gs` is 57 seconds.
testing.expectf(t, ms < 5000, "%d gs took %.0f ms", N, ms)
}
// The cache is keyed by the CTM as well, because the CTM in force when a mask
// is installed is where the mask lands (11.6.5.2). The same ExtGState under a
// different CTM is a different mask and has to be drawn again.
@(test)
test_content_soft_mask_cache_respects_the_ctm :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec,
"/GMask gs 1 0 0 1 30 0 cm /GMask gs 0 0 1 1 re f", ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Begin_Mask), 2)
a := rec_nth(&rec, .Begin_Mask, 0)
c := rec_nth(&rec, .Begin_Mask, 1)
if a == nil || c == nil { return }
testing.expectf(t, c.area.x0 - a.area.x0 == 30,
"the second mask is drawn where the second CTM puts it: %v then %v", a.area, c.area)
}
// A Soft_Mask outlives the operator that installed it -- a `Q` can bring it
// back -- but not the run: it belongs to the interpreter's scratch, which the
// page reclaims. On the Document it would be reclaimed only by `close`, so a
// viewer re-rendering the same page at every zoom step would add a thousand
// of them per render for as long as the file was open.
@(test)
test_content_soft_masks_do_not_grow_the_document :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
// A different CTM every time, so every one of these is a mask the cache
// above cannot answer and the interpreter has to build.
for _ in 0 ..< MAX_SOFT_MASKS {
strings.write_string(&b, "1 0 0 1 0.1 0 cm /GMask gs ")
}
src := strings.to_string(b)
run :: proc(t: ^testing.T, doc: ^Document, res: ^Dict, src: string) -> int {
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
testing.expect_value(t, ct_run(doc, &rec, src, res), Error.None)
return rec_count(&rec, .Begin_Mask)
}
// ct_res builds its dictionaries in the Document arena, so it is built
// once, here, rather than inside what is being measured. The first run
// warms everything the Document legitimately caches with it: the mask
// form's decoded stream, its colour space, its group dictionary.
res := ct_res(doc)
testing.expect_value(t, run(t, doc, res, src), MAX_SOFT_MASKS)
before := document_memory_used(doc)
testing.expect_value(t, run(t, doc, res, src), MAX_SOFT_MASKS)
growth := document_memory_used(doc) - before
testing.expectf(t, growth < 8192,
"rendering the same content twice added %d bytes of Document memory", growth)
}
// `BI ID EI` is two tokens, so the token budget alone paid for two million
// inline images from a 37 KB file -- each with a dictionary in the Document
// arena, which only `close` reclaims. Nothing drawable comes out of any of
// them.
@(test)
test_content_empty_inline_images_cost_nothing :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
N :: 200_000
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
for _ in 0 ..< N {
strings.write_string(&b, "BI ID EI ")
}
// The stream must still be in step after all of them.
strings.write_string(&b, "0 0 1 1 re f")
before := document_memory_used(doc)
start := time.now()
testing.expect_value(t, ct_run(doc, &rec, strings.to_string(b), ct_res(doc)), Error.None)
ms := time.duration_milliseconds(time.since(start))
growth := document_memory_used(doc) - before
testing.expect_value(t, rec_count(&rec, .Fill_Image), 0)
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
testing.expectf(t, growth < 1 << 20, "%d inline images added %d bytes", N, growth)
testing.expectf(t, ms < 5000, "%d inline images took %.0f ms", N, ms)
}
// And the ones that ARE images are counted, because what they cost the
// Document is the same whether they draw or not. A refused one still has to
// consume exactly its own bytes: these carry `EI` inside their sample data --
// which is why the length is computed and not scanned for -- and drawing
// operators after it, so a resynchronisation that lands anywhere but the end
// paints something the file never asked for.
@(test)
test_content_inline_images_are_capped_per_page :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
rec: Recorder
rec_init(&rec)
defer rec_destroy(&rec)
PAYLOAD :: " EI 1 0 0 rg 5 5 10 10 re f "
one := fmt.tprintf("BI /W %d /H 1 /BPC 8 /CS /G ID %sEI ", len(PAYLOAD), PAYLOAD)
N :: MAX_INLINE_IMAGES + 100
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
for _ in 0 ..< N {
strings.write_string(&b, one)
}
strings.write_string(&b, "0 0 1 1 re f")
repairs := len(doc.repairs)
testing.expect_value(t, ct_run(doc, &rec, strings.to_string(b), ct_res(doc)), Error.None)
testing.expect_value(t, rec_count(&rec, .Fill_Image), MAX_INLINE_IMAGES)
testing.expect(t, len(doc.repairs) > repairs, "hitting the cap is recorded as a Repair")
// One fill: the marker at the end. Any more came out of an image's pixels.
testing.expect_value(t, rec_count(&rec, .Fill_Path), 1)
}