1697 lines
64 KiB
Odin
1697 lines
64 KiB
Odin
package pdf
|
|
|
|
import "core:fmt"
|
|
import "core:strings"
|
|
import "core:testing"
|
|
|
|
// Tests for the public rendering API.
|
|
//
|
|
// This is the first thing in the library that produces a picture, so it is
|
|
// tested as pictures: a small PDF is built with the `pb_*` builders, rendered,
|
|
// and the PIXELS are asserted -- where the ink is, what colour it is, and that
|
|
// everywhere else is untouched. A geometry bug that a matrix comparison would
|
|
// let through (an off-by-one flip, a rotation the wrong way round, a crop box
|
|
// applied twice) moves the ink to a corner these tests name, and they fail.
|
|
//
|
|
// `pt_ink` is why that is short to write: it reports the bounding box of every
|
|
// pixel that differs from the background, so "the square is at x 10..60, y
|
|
// 140..190 and nowhere else" is one assertion rather than forty thousand.
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Building a page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// pt_build assembles a one-page document: catalog, page tree, page, and a
|
|
// content stream. `page_extra` is spliced into the page dictionary, and
|
|
// `extra` supplies the bodies of objects 5, 6, ... for tests that need a font,
|
|
// a form or an annotation.
|
|
// Pt_Obj is one of the extra objects a test needs. A stream object is spelled
|
|
// out rather than written by hand so that its /Length is always the length the
|
|
// payload actually has -- a test that renders nothing because it miscounted a
|
|
// dictionary is a test that proves nothing.
|
|
@(private = "file")
|
|
Pt_Obj :: struct {
|
|
body: string,
|
|
payload: string,
|
|
stream: bool,
|
|
}
|
|
|
|
@(private = "file")
|
|
pt_plain :: proc(body: string) -> Pt_Obj {
|
|
return Pt_Obj{body = body}
|
|
}
|
|
|
|
@(private = "file")
|
|
pt_stream :: proc(dict_head, payload: string) -> Pt_Obj {
|
|
return Pt_Obj{body = dict_head, payload = payload, stream = true}
|
|
}
|
|
|
|
@(private = "file")
|
|
pt_build :: proc(page_extra: string, content: string, extra: []Pt_Obj = nil,
|
|
catalog_extra := "") -> Pdf_Builder {
|
|
pb := pb_make()
|
|
catalog := strings.concatenate({"<< /Type /Catalog /Pages 2 0 R ", catalog_extra, " >>"})
|
|
defer delete(catalog)
|
|
pb_obj(&pb, 1, catalog)
|
|
pb_obj(&pb, 2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
|
|
|
|
body := strings.concatenate({"<< /Type /Page /Parent 2 0 R /Contents 4 0 R ", page_extra, " >>"})
|
|
defer delete(body)
|
|
pb_obj(&pb, 3, body)
|
|
pb_stream_obj(&pb, 4, "", transmute([]byte)content)
|
|
|
|
for o, i in extra {
|
|
if o.stream {
|
|
pb_stream_obj(&pb, u32(5 + i), o.body, transmute([]byte)o.payload)
|
|
} else {
|
|
pb_obj(&pb, u32(5 + i), o.body)
|
|
}
|
|
}
|
|
|
|
at := pb_classic_xref(&pb)
|
|
pb_trailer(&pb, "<< /Size 9 /Root 1 0 R >>", at)
|
|
return pb
|
|
}
|
|
|
|
// pt_open opens what pt_build made, walking the page tree for real.
|
|
@(private = "file")
|
|
pt_open :: proc(t: ^testing.T, pb: ^Pdf_Builder, loc := #caller_location) -> ^Document {
|
|
doc, err := open_bytes(pb.buf[:], Open_Options{})
|
|
testing.expect_value(t, err, Error.None, loc = loc)
|
|
return doc
|
|
}
|
|
|
|
// pt_page builds, opens and fetches page 0 in one step. The builder is
|
|
// destroyed by the caller through `pt_done`, because the Document borrows its
|
|
// bytes.
|
|
@(private = "file")
|
|
Pt_Fixture :: struct {
|
|
pb: Pdf_Builder,
|
|
doc: ^Document,
|
|
page: Page,
|
|
}
|
|
|
|
@(private = "file")
|
|
pt_fixture :: proc(t: ^testing.T, page_extra: string, content: string,
|
|
extra: []Pt_Obj = nil, catalog_extra := "",
|
|
loc := #caller_location) -> (f: Pt_Fixture, ok: bool) {
|
|
f.pb = pt_build(page_extra, content, extra, catalog_extra)
|
|
f.doc = pt_open(t, &f.pb, loc)
|
|
if f.doc == nil {
|
|
pb_destroy(&f.pb)
|
|
return f, false
|
|
}
|
|
if !testing.expectf(t, page_count(f.doc) == 1, "wanted 1 page, got %d", page_count(f.doc), loc = loc) {
|
|
close(f.doc)
|
|
pb_destroy(&f.pb)
|
|
return f, false
|
|
}
|
|
err: Error
|
|
f.page, err = page_get(f.doc, 0)
|
|
testing.expect_value(t, err, Error.None, loc = loc)
|
|
return f, true
|
|
}
|
|
|
|
@(private = "file")
|
|
pt_done :: proc(f: ^Pt_Fixture) {
|
|
close(f.doc)
|
|
pb_destroy(&f.pb)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Looking at pixels
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@(private = "file")
|
|
pt_px :: proc(bmp: ^Bitmap, x, y: int) -> [4]u8 {
|
|
off, ok := bitmap_pixel_offset(bmp, x, y)
|
|
if !ok {
|
|
return {}
|
|
}
|
|
out: [4]u8
|
|
for c in 0 ..< bmp.n {
|
|
out[c] = bmp.pixels[off + c]
|
|
}
|
|
return out
|
|
}
|
|
|
|
@(private = "file")
|
|
pt_expect_px :: proc(t: ^testing.T, bmp: ^Bitmap, x, y: int, want: [4]u8, what: string,
|
|
loc := #caller_location) {
|
|
got := pt_px(bmp, x, y)
|
|
testing.expectf(t, got == want, "%s: pixel (%d,%d) is %v, wanted %v", what, x, y, got, want,
|
|
loc = loc)
|
|
}
|
|
|
|
// Pt_Box is a half-open pixel rectangle: x0..x1-1 inclusive.
|
|
@(private = "file")
|
|
Pt_Box :: struct {
|
|
x0, y0, x1, y1: int,
|
|
}
|
|
|
|
// pt_ink is the bounding box of every pixel that is not `bg`. An empty result
|
|
// is {0,0,0,0}, which no real box can be.
|
|
@(private = "file")
|
|
pt_ink :: proc(bmp: ^Bitmap, bg: [4]u8) -> (box: Pt_Box, count: int) {
|
|
first := true
|
|
for y in 0 ..< bmp.height {
|
|
for x in 0 ..< bmp.width {
|
|
if pt_px(bmp, x, y) == bg {
|
|
continue
|
|
}
|
|
count += 1
|
|
if first {
|
|
box = Pt_Box{x, y, x + 1, y + 1}
|
|
first = false
|
|
continue
|
|
}
|
|
box.x0 = min(box.x0, x)
|
|
box.y0 = min(box.y0, y)
|
|
box.x1 = max(box.x1, x + 1)
|
|
box.y1 = max(box.y1, y + 1)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
@(private = "file")
|
|
PT_WHITE :: [4]u8{255, 255, 255, 255}
|
|
@(private = "file")
|
|
PT_BLUE :: [4]u8{0, 0, 255, 255}
|
|
@(private = "file")
|
|
PT_RED :: [4]u8{255, 0, 0, 255}
|
|
|
|
@(private = "file")
|
|
pt_expect_ink :: proc(t: ^testing.T, bmp: ^Bitmap, want: Pt_Box, color: [4]u8,
|
|
what: string, loc := #caller_location) {
|
|
box, count := pt_ink(bmp, PT_WHITE)
|
|
testing.expectf(t, box == want, "%s: ink box %v, wanted %v", what, box, want, loc = loc)
|
|
// Every pixel inside the box must be the colour, and the count must be the
|
|
// box's area -- which is what rules out a box that is right only because
|
|
// a stray pixel in the far corner stretched it.
|
|
area := (want.x1 - want.x0) * (want.y1 - want.y0)
|
|
testing.expectf(t, count == area, "%s: %d inked pixels, wanted %d", what, count, area, loc = loc)
|
|
if area > 0 {
|
|
pt_expect_px(t, bmp, want.x0, want.y0, color, what, loc)
|
|
pt_expect_px(t, bmp, want.x1 - 1, want.y1 - 1, color, what, loc)
|
|
pt_expect_px(t, bmp, (want.x0 + want.x1) / 2, (want.y0 + want.y1) / 2, color, what, loc)
|
|
}
|
|
}
|
|
|
|
// The square every geometry test below draws: 50 x 50 user units with its
|
|
// lower-left corner at (10,10), which is the corner PDF calls the bottom-left
|
|
// and a bitmap calls the bottom-left too -- only after the flip.
|
|
@(private = "file")
|
|
PT_SQUARE :: "0 0 1 rg 10 10 50 50 re f"
|
|
|
|
// The appearance stream the annotation tests use: a 10 x 10 form that fills
|
|
// its whole /BBox red, so wherever it is fitted the result is a solid
|
|
// rectangle whose edges are exactly the annotation's /Rect.
|
|
@(private = "file")
|
|
PT_ANNOT_FORM := Pt_Obj{
|
|
body = "/Type /XObject /Subtype /Form /BBox [0 0 10 10]",
|
|
payload = "1 0 0 rg 0 0 10 10 re f",
|
|
stream = true,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The base matrix
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@(private = "file")
|
|
pt_expect_point :: proc(t: ^testing.T, m: Matrix, p: Point, want: Point, what: string,
|
|
loc := #caller_location) {
|
|
got := transform_point(m, p)
|
|
ok := abs(got.x - want.x) < 1e-9 && abs(got.y - want.y) < 1e-9
|
|
testing.expectf(t, ok, "%s: %v maps to %v, wanted %v", what, p, got, want, loc = loc)
|
|
}
|
|
|
|
@(test)
|
|
test_page_base_matrix_flip :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 100]", "")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
m := page_base_matrix(&f.page)
|
|
// y-up, origin bottom-left -> y-down, origin top-left.
|
|
pt_expect_point(t, m, {0, 0}, {0, 100}, "user origin is the bottom-left pixel")
|
|
pt_expect_point(t, m, {0, 100}, {0, 0}, "the top-left corner is the device origin")
|
|
pt_expect_point(t, m, {200, 100}, {200, 0}, "top-right")
|
|
pt_expect_point(t, m, {200, 0}, {200, 100}, "bottom-right")
|
|
|
|
// The caller's ctm sits on top of the flip, not instead of it.
|
|
m2 := page_base_matrix(&f.page, scale_matrix(2, 3))
|
|
pt_expect_point(t, m2, {0, 0}, {0, 300}, "scaled bottom-left")
|
|
pt_expect_point(t, m2, {200, 100}, {400, 0}, "scaled top-right")
|
|
|
|
// A matrix that is not a matrix must not silently blank the page.
|
|
nan := Matrix{1, 0, 0, 1, 0, 0}
|
|
nan.e = f64(0h7FF8000000000000)
|
|
m3 := page_base_matrix(&f.page, nan)
|
|
testing.expect(t, matrix_is_finite(m3), "a non-finite ctm must be dropped, not propagated")
|
|
testing.expect(t, page_base_matrix(nil) == MATRIX_IDENTITY, "no page, no transform")
|
|
}
|
|
|
|
@(test)
|
|
test_page_base_matrix_crop_and_unit :: proc(t: ^testing.T) {
|
|
// The origin is the CROP box's corner, not the media box's.
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200] /CropBox [50 40 150 140]", "")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
m := page_base_matrix(&f.page)
|
|
pt_expect_point(t, m, {50, 40}, {0, 100}, "crop bottom-left is the bottom-left pixel")
|
|
pt_expect_point(t, m, {150, 140}, {100, 0}, "crop top-right is the device origin")
|
|
|
|
// /UserUnit scales the page: page_size honours it, so the matrix must too.
|
|
g, ok2 := pt_fixture(t, "/MediaBox [0 0 100 100] /UserUnit 3", "")
|
|
if !ok2 {
|
|
return
|
|
}
|
|
defer pt_done(&g)
|
|
mu := page_base_matrix(&g.page)
|
|
pt_expect_point(t, mu, {0, 0}, {0, 300}, "UserUnit 3 makes the page 300 units tall")
|
|
pt_expect_point(t, mu, {100, 100}, {300, 0}, "UserUnit 3 makes the page 300 units wide")
|
|
}
|
|
|
|
@(test)
|
|
test_page_base_matrix_rotations :: proc(t: ^testing.T) {
|
|
// A 200 x 100 page: landscape becomes portrait and back.
|
|
Case :: struct {
|
|
rotate: int,
|
|
// Where the user-space corners (0,0) and (200,100) land.
|
|
ll, tr: Point,
|
|
w, h: int,
|
|
}
|
|
cases := []Case{
|
|
{0, {0, 100}, {200, 0}, 200, 100},
|
|
{90, {0, 0}, {100, 200}, 100, 200},
|
|
{180, {200, 0}, {0, 100}, 200, 100},
|
|
{270, {100, 200}, {0, 0}, 100, 200},
|
|
}
|
|
|
|
for c in cases {
|
|
extra := fmt.tprintf("/MediaBox [0 0 200 100] /Rotate %d", c.rotate)
|
|
f, ok := pt_fixture(t, extra, "")
|
|
if !ok {
|
|
continue
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
m := page_base_matrix(&f.page)
|
|
pt_expect_point(t, m, {0, 0}, c.ll, fmt.tprintf("rotate %d: user (0,0)", c.rotate))
|
|
pt_expect_point(t, m, {200, 100}, c.tr, fmt.tprintf("rotate %d: user (200,100)", c.rotate))
|
|
|
|
// Every rotation flips handedness exactly once -- that is the y-flip
|
|
// and nothing else. A rotation built the wrong way round would show
|
|
// up here as +1.
|
|
testing.expectf(t, abs(matrix_determinant(m) + 1) < 1e-9,
|
|
"rotate %d: determinant %v, wanted -1", c.rotate, matrix_determinant(m))
|
|
|
|
w, h, serr := page_render_size(&f.page, MATRIX_IDENTITY)
|
|
testing.expect_value(t, serr, Error.None)
|
|
testing.expectf(t, w == c.w && h == c.h, "rotate %d: size %dx%d, wanted %dx%d",
|
|
c.rotate, w, h, c.w, c.h)
|
|
}
|
|
}
|
|
|
|
// page_size is the documented way to ask how big a page is; the renderer must
|
|
// not have a second opinion.
|
|
@(test)
|
|
test_page_render_size_agrees_with_page_size :: proc(t: ^testing.T) {
|
|
for extra in ([]string{
|
|
"/MediaBox [0 0 200 100]",
|
|
"/MediaBox [0 0 200 100] /Rotate 90",
|
|
"/MediaBox [0 0 200 100] /Rotate 270",
|
|
"/MediaBox [0 0 612 792] /CropBox [10 20 300 400]",
|
|
"/MediaBox [0 0 100 100] /UserUnit 2",
|
|
}) {
|
|
f, ok := pt_fixture(t, extra, "")
|
|
if !ok {
|
|
continue
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
for s in ([]f64{1, 2, 0.5, 4}) {
|
|
pw, ph := page_size(&f.page, s)
|
|
rw, rh, err := page_render_size(&f.page, scale_matrix(s, s))
|
|
testing.expect_value(t, err, Error.None)
|
|
testing.expectf(t, rw == pw && rh == ph,
|
|
"%s at %vx: render_page %dx%d, page_size %dx%d",
|
|
extra, s, rw, rh, pw, ph)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pixels
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@(test)
|
|
test_render_page_square :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200]", PT_SQUARE)
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
testing.expectf(t, bmp.width == 200 && bmp.height == 200, "size %dx%d", bmp.width, bmp.height)
|
|
testing.expect_value(t, bmp.n, 4)
|
|
testing.expect(t, !bmp.premultiplied, "the bitmap handed back is straight alpha")
|
|
|
|
// The square is at user (10,10)-(60,60), the BOTTOM-left of the page, so
|
|
// in the bitmap it is at the bottom: y 140..190. A missing flip would put
|
|
// it at y 10..60 and this is the assertion that catches it.
|
|
pt_expect_ink(t, &bmp, Pt_Box{10, 140, 60, 190}, PT_BLUE, "square")
|
|
pt_expect_px(t, &bmp, 0, 0, PT_WHITE, "top-left corner is background")
|
|
pt_expect_px(t, &bmp, 30, 30, PT_WHITE, "above the square is background")
|
|
pt_expect_px(t, &bmp, 199, 199, PT_WHITE, "bottom-right corner is background")
|
|
}
|
|
|
|
@(test)
|
|
test_render_page_scaled :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200]", PT_SQUARE)
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page, Render_Options{ctm = scale_matrix(2, 2)})
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
testing.expectf(t, bmp.width == 400 && bmp.height == 400, "size %dx%d", bmp.width, bmp.height)
|
|
pt_expect_ink(t, &bmp, Pt_Box{20, 280, 120, 380}, PT_BLUE, "square at 2x")
|
|
}
|
|
|
|
@(test)
|
|
test_render_page_rotations :: proc(t: ^testing.T) {
|
|
// The same square, and the corner it must end up in for each /Rotate.
|
|
Case :: struct {
|
|
rotate: int,
|
|
want: Pt_Box,
|
|
what: string,
|
|
}
|
|
cases := []Case{
|
|
{0, {10, 140, 60, 190}, "rotate 0: bottom-left"},
|
|
{90, {10, 10, 60, 60}, "rotate 90: top-left"},
|
|
{180, {140, 10, 190, 60}, "rotate 180: top-right"},
|
|
{270, {140, 140, 190, 190}, "rotate 270: bottom-right"},
|
|
}
|
|
for c in cases {
|
|
extra := fmt.tprintf("/MediaBox [0 0 200 200] /Rotate %d", c.rotate)
|
|
f, ok := pt_fixture(t, extra, PT_SQUARE)
|
|
if !ok {
|
|
continue
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
testing.expectf(t, bmp.width == 200 && bmp.height == 200, "%s: size %dx%d",
|
|
c.what, bmp.width, bmp.height)
|
|
pt_expect_ink(t, &bmp, c.want, PT_BLUE, c.what)
|
|
}
|
|
}
|
|
|
|
// A /Rotate 90 page must come out landscape, not just rotated in place.
|
|
@(test)
|
|
test_render_page_rotate_changes_shape :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 100] /Rotate 90", "0 0 1 rg 0 0 20 10 re f")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
testing.expectf(t, bmp.width == 100 && bmp.height == 200,
|
|
"a 200x100 page rotated 90 is 100x200, got %dx%d", bmp.width, bmp.height)
|
|
// User (0..20, 0..10) with dx = Y, dy = X.
|
|
pt_expect_ink(t, &bmp, Pt_Box{0, 0, 10, 20}, PT_BLUE, "rotated landscape square")
|
|
}
|
|
|
|
@(test)
|
|
test_render_page_crop_box :: proc(t: ^testing.T) {
|
|
// The bitmap is the crop box, and user space is measured from its corner.
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200] /CropBox [50 50 150 150]",
|
|
"0 0 1 rg 60 60 50 50 re f")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
testing.expectf(t, bmp.width == 100 && bmp.height == 100, "size %dx%d", bmp.width, bmp.height)
|
|
pt_expect_ink(t, &bmp, Pt_Box{10, 40, 60, 90}, PT_BLUE, "square inside an offset crop box")
|
|
}
|
|
|
|
// Content outside the crop box is off the bitmap, which is the whole point of
|
|
// cropping: a scanner's registration marks in the margin must not appear.
|
|
@(test)
|
|
test_render_page_crop_box_clips :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200] /CropBox [50 50 150 150]",
|
|
"0 0 1 rg 0 0 20 20 re f")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
testing.expectf(t, bmp.width == 100 && bmp.height == 100,
|
|
"the bitmap is the crop box, got %dx%d", bmp.width, bmp.height)
|
|
_, count := pt_ink(&bmp, PT_WHITE)
|
|
testing.expectf(t, count == 0, "%d pixels drawn outside the crop box", count)
|
|
}
|
|
|
|
@(test)
|
|
test_render_page_empty_and_missing_content :: proc(t: ^testing.T) {
|
|
// A page whose content stream is empty.
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 50 40]", "")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
testing.expectf(t, bmp.width == 50 && bmp.height == 40, "size %dx%d", bmp.width, bmp.height)
|
|
_, count := pt_ink(&bmp, PT_WHITE)
|
|
testing.expect_value(t, count, 0)
|
|
|
|
// And a page with no /Contents key at all: blank, not an error.
|
|
pb := pb_make()
|
|
pb_obj(&pb, 1, "<< /Type /Catalog /Pages 2 0 R >>")
|
|
pb_obj(&pb, 2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
|
|
pb_obj(&pb, 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 30 20] >>")
|
|
at := pb_classic_xref(&pb)
|
|
pb_trailer(&pb, "<< /Size 4 /Root 1 0 R >>", at)
|
|
defer pb_destroy(&pb)
|
|
|
|
doc := pt_open(t, &pb)
|
|
if doc == nil {
|
|
return
|
|
}
|
|
defer close(doc)
|
|
page, perr := page_get(doc, 0)
|
|
testing.expect_value(t, perr, Error.None)
|
|
|
|
bmp2, err2 := render_page(&page)
|
|
testing.expect_value(t, err2, Error.None)
|
|
defer bitmap_destroy(&bmp2)
|
|
testing.expectf(t, bmp2.width == 30 && bmp2.height == 20, "size %dx%d", bmp2.width, bmp2.height)
|
|
_, c2 := pt_ink(&bmp2, PT_WHITE)
|
|
testing.expect_value(t, c2, 0)
|
|
}
|
|
|
|
// A page that is 90% drawable draws 90%.
|
|
@(test)
|
|
test_render_page_corrupt_content :: proc(t: ^testing.T) {
|
|
corrupt := strings.concatenate({
|
|
PT_SQUARE,
|
|
// Operator soup: stray delimiters, an unbalanced Q, an unknown
|
|
// operator with operands, and a truncated string.
|
|
"\n]] >> 4 5 zzzz Q Q Q\n1 0 0 rg 100 100 50 (unterminated",
|
|
})
|
|
defer delete(corrupt)
|
|
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200]", corrupt)
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
before := len(f.doc.repairs)
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
// Everything before the damage is still on the page.
|
|
pt_expect_ink(t, &bmp, Pt_Box{10, 140, 60, 190}, PT_BLUE, "square before the corruption")
|
|
testing.expect(t, len(f.doc.repairs) > before,
|
|
"a corrupt content stream must record a repair")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Options
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@(test)
|
|
test_render_page_background_and_alpha :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 40 40]", "0 0 1 rg 10 10 20 20 re f")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
// A named opaque background.
|
|
green := [4]u8{0, 128, 0, 255}
|
|
b1, e1 := render_page(&f.page, Render_Options{background = green})
|
|
testing.expect_value(t, e1, Error.None)
|
|
defer bitmap_destroy(&b1)
|
|
pt_expect_px(t, &b1, 0, 0, green, "background")
|
|
pt_expect_px(t, &b1, 20, 20, PT_BLUE, "square over the background")
|
|
|
|
// alpha: the page starts transparent and stays transparent where nothing
|
|
// painted. The square is opaque and its colour survives the round trip
|
|
// through premultiplication.
|
|
b2, e2 := render_page(&f.page, Render_Options{alpha = true})
|
|
testing.expect_value(t, e2, Error.None)
|
|
defer bitmap_destroy(&b2)
|
|
pt_expect_px(t, &b2, 0, 0, [4]u8{0, 0, 0, 0}, "transparent background")
|
|
pt_expect_px(t, &b2, 20, 20, PT_BLUE, "opaque square on a transparent page")
|
|
|
|
// A translucent background is stored straight, not premultiplied.
|
|
half := [4]u8{200, 100, 50, 128}
|
|
b3, e3 := render_page(&f.page, Render_Options{alpha = true, background = half})
|
|
testing.expect_value(t, e3, Error.None)
|
|
defer bitmap_destroy(&b3)
|
|
got := pt_px(&b3, 0, 0)
|
|
// Premultiply-then-unpremultiply is lossy by at most one step of 255/128.
|
|
for c in 0 ..< 3 {
|
|
testing.expectf(t, abs(int(got[c]) - int(half[c])) <= 2,
|
|
"translucent background component %d is %d, wanted about %d",
|
|
c, got[c], half[c])
|
|
}
|
|
testing.expect_value(t, got[3], u8(128))
|
|
|
|
// Without `alpha`, a background with a transparent alpha is forced opaque:
|
|
// an opaque page cannot start out see-through.
|
|
b4, e4 := render_page(&f.page, Render_Options{background = [4]u8{10, 20, 30, 0}})
|
|
testing.expect_value(t, e4, Error.None)
|
|
defer bitmap_destroy(&b4)
|
|
pt_expect_px(t, &b4, 0, 0, [4]u8{10, 20, 30, 255}, "opaque page background")
|
|
}
|
|
|
|
@(test)
|
|
test_render_page_too_large :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 20000 20000]", "")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
// 20000 x 20000 is 4 x 10^8 pixels, past MAX_BITMAP_PIXELS.
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.Bitmap_Too_Large)
|
|
testing.expect(t, bmp.pixels == nil, "nothing is allocated for a refused bitmap")
|
|
|
|
// And a scale that would overflow the multiplication rather than the
|
|
// product must be refused too, not wrapped.
|
|
bmp2, err2 := render_page(&f.page, Render_Options{ctm = scale_matrix(1e30, 1e30)})
|
|
testing.expect_value(t, err2, Error.Bitmap_Too_Large)
|
|
testing.expect(t, bmp2.pixels == nil, "")
|
|
|
|
// A modest scale still works.
|
|
bmp3, err3 := render_page(&f.page, Render_Options{ctm = scale_matrix(0.01, 0.01)})
|
|
testing.expect_value(t, err3, Error.None)
|
|
defer bitmap_destroy(&bmp3)
|
|
testing.expectf(t, bmp3.width == 200 && bmp3.height == 200, "size %dx%d", bmp3.width, bmp3.height)
|
|
}
|
|
|
|
@(test)
|
|
test_render_page_arguments :: proc(t: ^testing.T) {
|
|
_, err := render_page(nil)
|
|
testing.expect_value(t, err, Error.Invalid_Argument)
|
|
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 20 20]", "")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
nan := MATRIX_IDENTITY
|
|
nan.a = f64(0h7FF8000000000000)
|
|
_, e2 := render_page(&f.page, Render_Options{ctm = nan})
|
|
testing.expect_value(t, e2, Error.Invalid_Argument)
|
|
|
|
testing.expect_value(t, render_page_to(&f.page, nil), Error.Invalid_Argument)
|
|
empty: Bitmap
|
|
testing.expect_value(t, render_page_to(&f.page, &empty), Error.Invalid_Argument)
|
|
testing.expect_value(t, run_page(nil, nil, MATRIX_IDENTITY), Error.Invalid_Argument)
|
|
testing.expect_value(t, run_page(&f.page, nil, nan), Error.Invalid_Argument)
|
|
|
|
_, dlerr := page_display_list(nil, MATRIX_IDENTITY)
|
|
testing.expect_value(t, dlerr, Error.Invalid_Argument)
|
|
_, terr := extract_text(nil)
|
|
testing.expect_value(t, terr, Error.Invalid_Argument)
|
|
|
|
// A nil Device runs the whole interpreter and draws nothing, which is how
|
|
// a caller asks only for the repairs a page produces.
|
|
testing.expect_value(t, run_page(&f.page, nil, MATRIX_IDENTITY), Error.None)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// render_page_to
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@(test)
|
|
test_render_page_to_matches_render_page :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 60 50]", "0 0 1 rg 10 10 20 20 re f 1 0 0 rg 35 5 10 40 re f")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
a, ea := render_page(&f.page)
|
|
testing.expect_value(t, ea, Error.None)
|
|
defer bitmap_destroy(&a)
|
|
|
|
b, berr := bitmap_make(a.width, a.height, 4)
|
|
testing.expect_value(t, berr, Error.None)
|
|
defer bitmap_destroy(&b)
|
|
testing.expect_value(t, render_page_to(&f.page, &b), Error.None)
|
|
|
|
same := len(a.pixels) == len(b.pixels)
|
|
if same {
|
|
for i in 0 ..< len(a.pixels) {
|
|
if a.pixels[i] != b.pixels[i] {
|
|
same = false
|
|
testing.expectf(t, false, "byte %d differs: %d vs %d", i, a.pixels[i], b.pixels[i])
|
|
break
|
|
}
|
|
}
|
|
}
|
|
testing.expect(t, same, "render_page_to must paint what render_page paints")
|
|
}
|
|
|
|
// The transform is applied literally, so a translation selects a tile rather
|
|
// than being cancelled out. That is what makes render_page_to useful for
|
|
// rendering a big page in pieces.
|
|
@(test)
|
|
test_render_page_to_tile :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200]", PT_SQUARE)
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
// The square is at device (10,140)-(60,190). Ask for the 20x20 tile whose
|
|
// top-left corner is (10,140) and the whole tile must be blue.
|
|
tile, terr := bitmap_make(20, 20, 4)
|
|
testing.expect_value(t, terr, Error.None)
|
|
defer bitmap_destroy(&tile)
|
|
|
|
opts := Render_Options{ctm = translate_matrix(-10, -140)}
|
|
testing.expect_value(t, render_page_to(&f.page, &tile, opts), Error.None)
|
|
pt_expect_ink(t, &tile, Pt_Box{0, 0, 20, 20}, PT_BLUE, "tile inside the square")
|
|
|
|
// A tile the square does not reach is background.
|
|
tile2, terr2 := bitmap_make(20, 20, 4)
|
|
testing.expect_value(t, terr2, Error.None)
|
|
defer bitmap_destroy(&tile2)
|
|
opts2 := Render_Options{ctm = translate_matrix(-100, -10)}
|
|
testing.expect_value(t, render_page_to(&f.page, &tile2, opts2), Error.None)
|
|
_, count := pt_ink(&tile2, PT_WHITE)
|
|
testing.expect_value(t, count, 0)
|
|
}
|
|
|
|
// render_page_to takes the bitmap it is given: grey, grey+alpha and RGB all
|
|
// work, which is what a caller with an existing surface needs.
|
|
@(test)
|
|
test_render_page_to_component_counts :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 40 40]", "0 0 0 rg 10 10 20 20 re f")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
for n in 1 ..= 4 {
|
|
bmp, err := bitmap_make(40, 40, n)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
testing.expect_value(t, render_page_to(&f.page, &bmp), Error.None)
|
|
|
|
corner := pt_px(&bmp, 0, 0)
|
|
inside := pt_px(&bmp, 20, 20)
|
|
testing.expectf(t, corner[0] == 255, "n=%d: white background, got %v", n, corner)
|
|
testing.expectf(t, inside[0] == 0, "n=%d: black square, got %v", n, inside)
|
|
if n == 2 || n == 4 {
|
|
testing.expectf(t, corner[n - 1] == 255, "n=%d: opaque background, got %v", n, corner)
|
|
}
|
|
testing.expect(t, !bmp.premultiplied, "the bitmap is left straight")
|
|
}
|
|
}
|
|
|
|
// A greyscale target has to reduce a colour to one component, and so does the
|
|
// compositor. If they disagree, a page painted in the background's own colour
|
|
// shows a seam down the middle of a flat field. Half of this page is painted
|
|
// by the background and half by the content, in the same blue, and the two
|
|
// halves must be the same byte.
|
|
@(test)
|
|
test_render_page_to_grey_background_matches_content :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 20 20]", "0 0 1 rg 0 0 10 20 re f")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, berr := bitmap_make(20, 20, 1)
|
|
testing.expect_value(t, berr, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
opts := Render_Options{background = [4]u8{0, 0, 255, 255}}
|
|
testing.expect_value(t, render_page_to(&f.page, &bmp, opts), Error.None)
|
|
|
|
painted := pt_px(&bmp, 5, 10)
|
|
background := pt_px(&bmp, 15, 10)
|
|
testing.expectf(t, painted[0] == background[0],
|
|
"painted blue is grey %d, the same blue as a background is %d",
|
|
painted[0], background[0])
|
|
// 0.11 is the blue weight the format gives for luminosity (11.3.5.3).
|
|
testing.expect_value(t, background[0], u8(28))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Text
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@(private = "file")
|
|
PT_HELV :: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
|
|
|
|
// Object 5 is the font in every text test below, so the page dictionary that
|
|
// names it is a constant rather than something each test concatenates.
|
|
@(private = "file")
|
|
PT_TEXT_PAGE :: "/MediaBox [0 0 300 200] /Resources << /Font << /F1 5 0 R >> >>"
|
|
@(private = "file")
|
|
PT_TEXT_PAGE_SQ :: "/MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >>"
|
|
|
|
// The round trip: draw a string with a standard-14 font, extract it back. It
|
|
// catches an enormous class of bugs at once -- the encoding, the widths, the
|
|
// text matrix, the base matrix's flip, and the device's reading order.
|
|
@(test)
|
|
test_extract_text_round_trip :: proc(t: ^testing.T) {
|
|
content := "BT /F1 24 Tf 20 100 Td (Hello World) Tj ET"
|
|
f, ok := pt_fixture(t, PT_TEXT_PAGE,
|
|
content, {pt_plain(PT_HELV)})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
s, err := extract_text(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer delete(s)
|
|
testing.expect_value(t, s, "Hello World")
|
|
}
|
|
|
|
// Two lines, and the one drawn HIGHER on the page comes out first.
|
|
//
|
|
// This pins that both runs reach the device with their positions intact and in
|
|
// the right relation -- a `Td` dropped, a text matrix rebuilt per run, a page
|
|
// run that stopped at the first `ET`. It does NOT pin the y-flip: the text
|
|
// device works out the writing direction from the glyphs themselves and reads
|
|
// an upside-down page in its own order, which is exactly what it should do for
|
|
// a page with /Rotate, and which the pixel tests above are what pin instead.
|
|
@(test)
|
|
test_extract_text_reading_order :: proc(t: ^testing.T) {
|
|
content := "BT /F1 12 Tf 20 120 Td (Top) Tj 0 -15 Td (Bottom) Tj ET"
|
|
f, ok := pt_fixture(t, PT_TEXT_PAGE,
|
|
content, {pt_plain(PT_HELV)})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
s, err := extract_text(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer delete(s)
|
|
|
|
top := strings.index(s, "Top")
|
|
bottom := strings.index(s, "Bottom")
|
|
testing.expectf(t, top >= 0 && bottom > top,
|
|
"wanted Top before Bottom, got %q", s)
|
|
}
|
|
|
|
@(test)
|
|
test_extract_text_empty_page :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 100 100]", "0 0 1 rg 10 10 20 20 re f")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
s, err := extract_text(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer delete(s)
|
|
testing.expect_value(t, s, "")
|
|
}
|
|
|
|
// Invisible text -- render mode 3, the OCR layer under a scan -- paints
|
|
// nothing and extracts everything. Both halves are asserted, because the two
|
|
// devices run the same interpreter and a mistake here would show up as text
|
|
// that is drawn or ink that is not.
|
|
@(test)
|
|
test_extract_text_invisible :: proc(t: ^testing.T) {
|
|
content := "BT 3 Tr /F1 24 Tf 20 100 Td (Scanned) Tj ET"
|
|
f, ok := pt_fixture(t, PT_TEXT_PAGE,
|
|
content, {pt_plain(PT_HELV)})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
s, err := extract_text(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer delete(s)
|
|
testing.expect_value(t, s, "Scanned")
|
|
|
|
bmp, rerr := render_page(&f.page)
|
|
testing.expect_value(t, rerr, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
_, count := pt_ink(&bmp, PT_WHITE)
|
|
testing.expectf(t, count == 0, "invisible text painted %d pixels", count)
|
|
}
|
|
|
|
// Visible text draws where the base matrix and the text matrix put it: on the
|
|
// baseline, above it, at the pen position -- not upside down at the top of the
|
|
// page. A Type 3 font is what makes that assertable in pixels, because its
|
|
// glyphs are content streams and the library has no outlines for the standard
|
|
// 14: this one draws its whole em square, so "where the glyph is" is a
|
|
// rectangle a test can name.
|
|
@(private = "file")
|
|
PT_T3_FONT :: "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] " +
|
|
"/FontMatrix [0.001 0 0 0.001 0 0] /CharProcs << /sq 6 0 R >> " +
|
|
"/Encoding << /Type /Encoding /Differences [65 /sq] >> " +
|
|
"/FirstChar 65 /LastChar 65 /Widths [1000] >>"
|
|
|
|
@(test)
|
|
test_render_page_text_lands_on_the_baseline :: proc(t: ^testing.T) {
|
|
glyph := pt_stream("", "1000 0 0 0 1000 1000 d1 0 0 1000 1000 re f")
|
|
content := "0 0 1 rg BT /F1 20 Tf 20 100 Td (A) Tj ET"
|
|
f, ok := pt_fixture(t, PT_TEXT_PAGE_SQ, content, {pt_plain(PT_T3_FONT), glyph})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
// The em square is 1000 glyph units, which /FontMatrix and a size of 20
|
|
// make 20 device units. Its baseline is user y=100 -- device y=100 -- and
|
|
// it sits ABOVE it, at device y 80..100. Without the flip it would be at
|
|
// y 100..120, and upside down.
|
|
pt_expect_ink(t, &bmp, Pt_Box{20, 80, 40, 100}, PT_BLUE, "a Type 3 glyph on its baseline")
|
|
}
|
|
|
|
// The same glyph, twice: the second is placed by the advance the font declares,
|
|
// which is what proves the text matrix is carried between glyphs rather than
|
|
// rebuilt from the Td.
|
|
@(test)
|
|
test_render_page_text_advances :: proc(t: ^testing.T) {
|
|
glyph := pt_stream("", "1000 0 0 0 1000 1000 d1 0 0 1000 1000 re f")
|
|
content := "0 0 1 rg BT /F1 20 Tf 20 100 Td (AA) Tj ET"
|
|
f, ok := pt_fixture(t, PT_TEXT_PAGE_SQ, content, {pt_plain(PT_T3_FONT), glyph})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
pt_expect_ink(t, &bmp, Pt_Box{20, 80, 60, 100}, PT_BLUE, "two glyphs, one advance apart")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Display lists
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The identity that makes a recording worth having: replaying a page's list
|
|
// into the rasteriser produces the bitmap rasterising it directly produces.
|
|
@(test)
|
|
test_page_display_list_replays_to_the_same_pixels :: proc(t: ^testing.T) {
|
|
content := "0 0 1 rg 10 10 50 50 re f 1 0 0 rg 100 100 40 60 re f 0 1 0 RG 4 w 20 150 m 180 150 l S"
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200]", content)
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
direct, derr := render_page(&f.page)
|
|
testing.expect_value(t, derr, Error.None)
|
|
defer bitmap_destroy(&direct)
|
|
|
|
dl, lerr := page_display_list(&f.page, MATRIX_IDENTITY)
|
|
testing.expect_value(t, lerr, Error.None)
|
|
if dl == nil {
|
|
return
|
|
}
|
|
defer {
|
|
display_list_destroy(dl)
|
|
free(dl)
|
|
}
|
|
testing.expect(t, len(dl.cmds) > 0, "the list must record something")
|
|
testing.expect(t, dl.closed, "the page is finished, so the list is closed")
|
|
testing.expect(t, !dl.overflow, "")
|
|
|
|
replay, rerr := bitmap_make(direct.width, direct.height, 4)
|
|
testing.expect_value(t, rerr, Error.None)
|
|
defer bitmap_destroy(&replay)
|
|
page_fill_background(&replay, PT_WHITE)
|
|
|
|
rd, rderr := raster_device_make(&replay, Raster_Clip{0, 0, replay.width, replay.height},
|
|
context.allocator, f.doc)
|
|
testing.expect_value(t, rderr, Error.None)
|
|
if rd == nil {
|
|
return
|
|
}
|
|
testing.expect_value(t, display_list_run(dl, raster_device_as_device(rd)), Error.None)
|
|
dev_close(raster_device_as_device(rd))
|
|
raster_device_destroy(rd)
|
|
bitmap_unpremultiply(&replay)
|
|
|
|
diff := 0
|
|
for i in 0 ..< min(len(direct.pixels), len(replay.pixels)) {
|
|
if direct.pixels[i] != replay.pixels[i] {
|
|
diff += 1
|
|
}
|
|
}
|
|
testing.expectf(t, diff == 0, "%d of %d bytes differ between a direct render and a replay",
|
|
diff, len(direct.pixels))
|
|
|
|
// The list is in the same device space the bitmap is, so its bounds sit
|
|
// inside the page.
|
|
b := display_list_bounds(dl)
|
|
testing.expectf(t, b.x0 >= -1 && b.y0 >= -1 && b.x1 <= 201 && b.y1 <= 201,
|
|
"list bounds %v are not in page space", b)
|
|
}
|
|
|
|
// A soft mask over a transparency group, through the whole stack: the
|
|
// interpreter hands the OUTER mask to `begin_group` because 11.6.6 makes it
|
|
// the group's RESULT that the mask applies to, the recorder has to carry it,
|
|
// and the replay has to hand the same mask to the second device. A list that
|
|
// dropped it replayed the group solid while the direct render faded it.
|
|
@(test)
|
|
test_page_display_list_replays_a_masked_group :: proc(t: ^testing.T) {
|
|
extra := []Pt_Obj{
|
|
pt_stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] " +
|
|
"/Group << /S /Transparency /CS /DeviceGray >>",
|
|
"0.5 g 0 0 100 100 re f"),
|
|
pt_stream("/Type /XObject /Subtype /Form /BBox [0 0 50 100] " +
|
|
"/Group << /S /Transparency /CS /DeviceRGB >>",
|
|
"0 0 0 rg 0 0 50 100 re f"),
|
|
}
|
|
f, ok := pt_fixture(t,
|
|
"/MediaBox [0 0 100 100] /Resources << /ExtGState << /GS << /Type /ExtGState " +
|
|
"/SMask << /S /Luminosity /G 5 0 R >> >> >> /XObject << /Gp 6 0 R >> >>",
|
|
"q /GS gs /Gp Do Q", extra)
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
direct, derr := render_page(&f.page)
|
|
testing.expect_value(t, derr, Error.None)
|
|
defer bitmap_destroy(&direct)
|
|
// Mid-grey luminosity over black: half the group's result reaches the page.
|
|
pt_expect_px(t, &direct, 25, 50, {127, 127, 127, 255}, "the group under a 50% mask")
|
|
pt_expect_px(t, &direct, 75, 50, PT_WHITE, "outside the group")
|
|
|
|
dl, lerr := page_display_list(&f.page, MATRIX_IDENTITY)
|
|
testing.expect_value(t, lerr, Error.None)
|
|
if dl == nil {
|
|
return
|
|
}
|
|
defer {
|
|
display_list_destroy(dl)
|
|
free(dl)
|
|
}
|
|
|
|
replay, rerr := bitmap_make(direct.width, direct.height, 4)
|
|
testing.expect_value(t, rerr, Error.None)
|
|
defer bitmap_destroy(&replay)
|
|
page_fill_background(&replay, PT_WHITE)
|
|
|
|
rd, rderr := raster_device_make(&replay, Raster_Clip{0, 0, replay.width, replay.height},
|
|
context.allocator, f.doc)
|
|
testing.expect_value(t, rderr, Error.None)
|
|
if rd == nil {
|
|
return
|
|
}
|
|
testing.expect_value(t, display_list_run(dl, raster_device_as_device(rd)), Error.None)
|
|
dev_close(raster_device_as_device(rd))
|
|
raster_device_destroy(rd)
|
|
bitmap_unpremultiply(&replay)
|
|
|
|
diff := 0
|
|
for i in 0 ..< min(len(direct.pixels), len(replay.pixels)) {
|
|
if direct.pixels[i] != replay.pixels[i] {
|
|
diff += 1
|
|
}
|
|
}
|
|
testing.expectf(t, diff == 0, "%d of %d bytes differ between a direct render and a replay",
|
|
diff, len(direct.pixels))
|
|
}
|
|
|
|
// The list survives the recorder: everything it points at was copied.
|
|
@(test)
|
|
test_page_display_list_owns_its_copies :: proc(t: ^testing.T) {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 100 100]", "0 0 1 rg 10 10 20 20 re f")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
dl, err := page_display_list(&f.page, scale_matrix(2, 2))
|
|
testing.expect_value(t, err, Error.None)
|
|
if dl == nil {
|
|
return
|
|
}
|
|
defer {
|
|
display_list_destroy(dl)
|
|
free(dl)
|
|
}
|
|
|
|
found := false
|
|
for cmd in dl.cmds {
|
|
#partial switch v in cmd {
|
|
case Display_Fill_Path:
|
|
found = true
|
|
testing.expect(t, v.path != nil && len(v.path.points) > 0, "the path was copied")
|
|
// 2x on top of the flip: the square's user (10,10) is device
|
|
// (20,160) on a 200-tall bitmap.
|
|
r := path_tight_bounds(v.path, v.ctm)
|
|
testing.expectf(t, abs(r.x0 - 20) < 1e-6 && abs(r.y1 - 180) < 1e-6,
|
|
"recorded fill at %v", r)
|
|
}
|
|
}
|
|
testing.expect(t, found, "the fill must be recorded")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// run_page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// run_page runs the content and closes the device; it does not draw
|
|
// annotations, which is what separates it from render_page.
|
|
@(test)
|
|
test_run_page_into_a_device :: proc(t: ^testing.T) {
|
|
annot := "<< /Type /Annot /Subtype /Square /Rect [100 100 150 150] /AP << /N 6 0 R >> >>"
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200] /Annots [5 0 R]", PT_SQUARE,
|
|
{pt_plain(annot), PT_ANNOT_FORM})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
ld, lerr := list_device_make()
|
|
testing.expect_value(t, lerr, Error.None)
|
|
if ld == nil {
|
|
return
|
|
}
|
|
defer list_device_destroy(ld)
|
|
|
|
testing.expect_value(t, run_page(&f.page, list_device_as_device(ld), MATRIX_IDENTITY), Error.None)
|
|
dl := list_device_result(ld)
|
|
testing.expect(t, dl.closed, "run_page closes the device when the page is finished")
|
|
|
|
fills := 0
|
|
for cmd in dl.cmds {
|
|
#partial switch _ in cmd {
|
|
case Display_Fill_Path:
|
|
fills += 1
|
|
}
|
|
}
|
|
testing.expectf(t, fills == 1, "run_page draws the content only, got %d fills", fills)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Annotations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
@(test)
|
|
test_render_page_annotation_appearance :: proc(t: ^testing.T) {
|
|
// The form's 10x10 /BBox is fitted to the annotation's 50x50 /Rect, so the
|
|
// whole rectangle comes out red wherever /Rect puts it.
|
|
annot := "<< /Type /Annot /Subtype /Square /Rect [100 100 150 150] /AP << /N 6 0 R >> >>"
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200] /Annots [5 0 R]", "",
|
|
{pt_plain(annot), PT_ANNOT_FORM})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
pt_expect_ink(t, &bmp, Pt_Box{100, 50, 150, 100}, PT_RED, "annotation appearance")
|
|
|
|
// Turned off by the option.
|
|
b2, e2 := render_page(&f.page, Render_Options{no_annotations = true})
|
|
testing.expect_value(t, e2, Error.None)
|
|
defer bitmap_destroy(&b2)
|
|
_, count := pt_ink(&b2, PT_WHITE)
|
|
testing.expectf(t, count == 0, "no_annotations still drew %d pixels", count)
|
|
}
|
|
|
|
@(test)
|
|
test_render_page_annotation_flags_and_kinds :: proc(t: ^testing.T) {
|
|
Case :: struct {
|
|
annot: string,
|
|
drawn: bool,
|
|
what: string,
|
|
}
|
|
cases := []Case{
|
|
{"<< /Type /Annot /Subtype /Square /Rect [100 100 150 150] /F 2 /AP << /N 6 0 R >> >>",
|
|
false, "hidden"},
|
|
{"<< /Type /Annot /Subtype /Square /Rect [100 100 150 150] /F 32 /AP << /N 6 0 R >> >>",
|
|
false, "noview"},
|
|
{"<< /Type /Annot /Subtype /Popup /Rect [100 100 150 150] /AP << /N 6 0 R >> >>",
|
|
false, "popup"},
|
|
{"<< /Type /Annot /Subtype /Square /Rect [100 100 150 150] >>",
|
|
false, "no appearance stream"},
|
|
{"<< /Type /Annot /Subtype /Square /Rect [100 100 100 150] /AP << /N 6 0 R >> >>",
|
|
false, "empty rectangle"},
|
|
{"<< /Type /Annot /Subtype /Square /Rect [100 100 150 150] /F 4 /AP << /N 6 0 R >> >>",
|
|
true, "print flag set"},
|
|
// /AP /N as a dictionary of states, selected by /AS.
|
|
{"<< /Type /Annot /Subtype /Widget /Rect [100 100 150 150] /AS /On /AP << /N << /On 6 0 R /Off 7 0 R >> >> >>",
|
|
true, "state selected by /AS"},
|
|
// Same, with /AS naming the state that draws nothing.
|
|
{"<< /Type /Annot /Subtype /Widget /Rect [100 100 150 150] /AS /Off /AP << /N << /On 6 0 R /Off 7 0 R >> >> >>",
|
|
false, "the other state"},
|
|
// A single-state dictionary with no /AS: every viewer draws it.
|
|
{"<< /Type /Annot /Subtype /Widget /Rect [100 100 150 150] /AP << /N << /On 6 0 R >> >> >>",
|
|
true, "single state, no /AS"},
|
|
}
|
|
|
|
empty_form := pt_stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", "")
|
|
for c in cases {
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200] /Annots [5 0 R]", "",
|
|
{pt_plain(c.annot), PT_ANNOT_FORM, empty_form})
|
|
if !ok {
|
|
continue
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
_, count := pt_ink(&bmp, PT_WHITE)
|
|
if c.drawn {
|
|
testing.expectf(t, count == 2500, "%s: %d pixels drawn, wanted 2500", c.what, count)
|
|
} else {
|
|
testing.expectf(t, count == 0, "%s: %d pixels drawn, wanted none", c.what, count)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Two annotations, and each must be drawn under its own clip: the /BBox clip
|
|
// the first one pushes has to be popped before the second runs, or the second
|
|
// is clipped to the first one's rectangle and never appears.
|
|
@(test)
|
|
test_render_page_two_annotations :: proc(t: ^testing.T) {
|
|
a := "<< /Type /Annot /Subtype /Square /Rect [10 10 60 60] /AP << /N 6 0 R >> >>"
|
|
b := "<< /Type /Annot /Subtype /Square /Rect [120 120 170 170] /AP << /N 6 0 R >> >>"
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200] /Annots [5 0 R 7 0 R]", "",
|
|
{pt_plain(a), PT_ANNOT_FORM, pt_plain(b)})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
_, count := pt_ink(&bmp, PT_WHITE)
|
|
testing.expectf(t, count == 5000, "%d pixels drawn, wanted two 50x50 squares", count)
|
|
pt_expect_px(t, &bmp, 35, 165, PT_RED, "the first annotation")
|
|
pt_expect_px(t, &bmp, 145, 55, PT_RED, "the second annotation")
|
|
pt_expect_px(t, &bmp, 90, 110, PT_WHITE, "between them")
|
|
}
|
|
|
|
// A form XObject's /BBox clips its contents, and an appearance stream is a
|
|
// form. This one paints well outside its box, and only the box may show.
|
|
@(test)
|
|
test_render_page_annotation_bbox_clips :: proc(t: ^testing.T) {
|
|
form := pt_stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]",
|
|
"1 0 0 rg -5 -5 20 20 re f")
|
|
annot := "<< /Type /Annot /Subtype /Square /Rect [100 100 150 150] /AP << /N 6 0 R >> >>"
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200] /Annots [5 0 R]", "", {pt_plain(annot), form})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
// Unclipped this would be user 75..175, which is device x 75..175,
|
|
// y 25..125 -- four times the area.
|
|
pt_expect_ink(t, &bmp, Pt_Box{100, 50, 150, 100}, PT_RED, "appearance clipped to its /BBox")
|
|
}
|
|
|
|
// /Matrix rotates the appearance inside its box, and the fitted result still
|
|
// fills /Rect exactly (ISO 32000-2, 12.5.5).
|
|
@(test)
|
|
test_render_page_annotation_matrix :: proc(t: ^testing.T) {
|
|
form := pt_stream("/Type /XObject /Subtype /Form /BBox [0 0 20 10] /Matrix [0 1 -1 0 0 0]",
|
|
"1 0 0 rg 0 0 20 10 re f")
|
|
annot := "<< /Type /Annot /Subtype /Square /Rect [50 50 90 90] /AP << /N 6 0 R >> >>"
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200] /Annots [5 0 R]", "", {pt_plain(annot), form})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
|
|
// The rotated 20x10 box is 10x20, stretched to the 40x40 /Rect: still the
|
|
// whole rectangle, at device y 200-90..200-50.
|
|
pt_expect_ink(t, &bmp, Pt_Box{50, 110, 90, 150}, PT_RED, "rotated appearance fitted to /Rect")
|
|
}
|
|
|
|
// Text inside an annotation's appearance stream is text on the page: a form
|
|
// field's value is what a reader sees, whatever the content stream says.
|
|
//
|
|
// The two appearances also pin where an appearance's resources come from. The
|
|
// first names /FA, which only the appearance's OWN /Resources defines, and it
|
|
// draws. The second names /F1, which only the PAGE defines, and it draws
|
|
// nothing: an annotation is not page content and inherits no resources from it
|
|
// (ISO 32000-2, 12.5.5). That is the one place an appearance stream is not
|
|
// treated exactly like a form XObject reached by `Do`, and it is deliberate --
|
|
// see the note in annot.odin.
|
|
@(test)
|
|
test_extract_text_includes_annotations :: proc(t: ^testing.T) {
|
|
own := pt_stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20] /Resources << /Font << /FA 5 0 R >> >>",
|
|
"BT /FA 12 Tf 2 4 Td (Ownfont) Tj ET")
|
|
inherited := pt_stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20]",
|
|
"BT /F1 12 Tf 2 4 Td (Pagefont) Tj ET")
|
|
annot_a := "<< /Type /Annot /Subtype /Widget /Rect [20 100 120 120] /AP << /N 6 0 R >> >>"
|
|
annot_b := "<< /Type /Annot /Subtype /Widget /Rect [20 40 120 60] /AP << /N 8 0 R >> >>"
|
|
|
|
f, ok := pt_fixture(t,
|
|
"/MediaBox [0 0 200 200] /Annots [7 0 R 9 0 R] /Resources << /Font << /F1 5 0 R >> >>",
|
|
"", {pt_plain(PT_HELV), own, pt_plain(annot_a), inherited, pt_plain(annot_b)})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
s, err := extract_text(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer delete(s)
|
|
testing.expectf(t, strings.contains(s, "Ownfont"),
|
|
"an appearance's own /Resources were not used: %q", s)
|
|
testing.expectf(t, !strings.contains(s, "Pagefont"),
|
|
"an appearance resolved a name out of the page's resources: %q", s)
|
|
}
|
|
|
|
// Optional content the catalog's default configuration switches off is not
|
|
// drawn -- and IS extracted. A layer a viewer hides is still text in the file,
|
|
// and a search that could not find it would be wrong; that split is the whole
|
|
// reason extract_text and render_page do not share one set of interpreter
|
|
// options.
|
|
@(test)
|
|
test_extract_text_hidden_layer :: proc(t: ^testing.T) {
|
|
ocg := "<< /Type /OCG /Name (Watermark) >>"
|
|
content := "/OC /L1 BDC 0 0 1 rg 10 10 50 50 re f BT /F1 24 Tf 20 100 Td (Draft) Tj ET EMC"
|
|
f, ok := pt_fixture(t,
|
|
"/MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> /Properties << /L1 6 0 R >> >>",
|
|
content, {pt_plain(PT_HELV), pt_plain(ocg)},
|
|
"/OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >>")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, rerr := render_page(&f.page)
|
|
testing.expect_value(t, rerr, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
_, count := pt_ink(&bmp, PT_WHITE)
|
|
testing.expectf(t, count == 0, "a switched-off layer painted %d pixels", count)
|
|
|
|
s, terr := extract_text(&f.page)
|
|
testing.expect_value(t, terr, Error.None)
|
|
defer delete(s)
|
|
testing.expect_value(t, s, "Draft")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scratch memory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The Document's arena must not grow with every render: per-page scratch lives
|
|
// in an arena that dies with the call. Rendering the same page repeatedly is
|
|
// the shape of a 900-page render, and the check has teeth because the content
|
|
// stream allocates thousands of times through the interpreter.
|
|
@(test)
|
|
test_render_page_does_not_grow_the_document :: proc(t: ^testing.T) {
|
|
content := "BT /F1 12 Tf 20 100 Td (some text to lay out) Tj ET 0 0 1 rg 10 10 50 50 re f"
|
|
f, ok := pt_fixture(t, PT_TEXT_PAGE_SQ,
|
|
content, {pt_plain(PT_HELV)})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
warm, e0 := render_page(&f.page)
|
|
testing.expect_value(t, e0, Error.None)
|
|
bitmap_destroy(&warm)
|
|
|
|
// Everything the file needs -- the font, the decoded content -- is cached
|
|
// by now, so any further growth is per-render scratch that leaked into the
|
|
// Document.
|
|
before := document_memory_used(f.doc)
|
|
for _ in 0 ..< 8 {
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
bitmap_destroy(&bmp)
|
|
}
|
|
after := document_memory_used(f.doc)
|
|
testing.expectf(t, after == before,
|
|
"eight renders grew the Document arena from %d to %d bytes", before, after)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Robustness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// An appearance stream that draws itself. The interpreter's visited set stops
|
|
// the recursion; what this pins is that entering an appearance from here goes
|
|
// through the same guard rather than around it.
|
|
@(test)
|
|
test_render_page_self_referential_appearance :: proc(t: ^testing.T) {
|
|
form := pt_stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << /XObject << /X0 6 0 R >> >>",
|
|
"1 0 0 rg 0 0 10 10 re f /X0 Do")
|
|
annot := "<< /Type /Annot /Subtype /Square /Rect [100 100 150 150] /AP << /N 6 0 R >> >>"
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 200 200] /Annots [5 0 R]", "", {pt_plain(annot), form})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
// The outer copy is drawn; the inner one is refused.
|
|
pt_expect_ink(t, &bmp, Pt_Box{100, 50, 150, 100}, PT_RED, "a self-referential appearance")
|
|
}
|
|
|
|
// Truncated and bit-flipped files. The property is that reaching the end of
|
|
// this test is possible at all: no crash, no hang, no unbounded allocation,
|
|
// whatever the bytes are. A page is the most attacker-controlled data in a
|
|
// PDF, and render_page is the entry point that walks all of it.
|
|
@(test)
|
|
test_render_page_fuzz :: proc(t: ^testing.T) {
|
|
pb := pt_build("/MediaBox [0 0 40 40] /Resources << /Font << /F1 5 0 R >> >>",
|
|
"0 0 1 rg 5 5 20 20 re f BT /F1 8 Tf 4 30 Td (hi) Tj ET",
|
|
{pt_plain(PT_HELV)})
|
|
defer pb_destroy(&pb)
|
|
original := pb.buf[:]
|
|
|
|
drive :: proc(data: []byte) {
|
|
doc, err := open_bytes(data, Open_Options{})
|
|
if err != .None || doc == nil {
|
|
return
|
|
}
|
|
defer close(doc)
|
|
if page_count(doc) == 0 {
|
|
return
|
|
}
|
|
page, perr := page_get(doc, 0)
|
|
if perr != .None {
|
|
return
|
|
}
|
|
bmp, rerr := render_page(&page, Render_Options{ctm = scale_matrix(0.5, 0.5)})
|
|
if rerr == .None {
|
|
bitmap_destroy(&bmp)
|
|
}
|
|
s, terr := extract_text(&page)
|
|
if terr == .None {
|
|
delete(s)
|
|
}
|
|
dl, lerr := page_display_list(&page, MATRIX_IDENTITY)
|
|
if lerr == .None && dl != nil {
|
|
display_list_destroy(dl)
|
|
free(dl)
|
|
}
|
|
}
|
|
|
|
// Truncation, at every 16th byte.
|
|
for n := 8; n < len(original); n += 16 {
|
|
drive(original[:n])
|
|
}
|
|
|
|
// Bit flips, one byte at a time, over a copy.
|
|
scratch := make([]byte, len(original))
|
|
defer delete(scratch)
|
|
for at := 0; at < len(original); at += 7 {
|
|
copy(scratch, original)
|
|
scratch[at] ~= 0xFF
|
|
drive(scratch)
|
|
}
|
|
|
|
testing.expect(t, true, "reaching this line is the property under test")
|
|
}
|
|
|
|
// An inline image is the one thing a content stream builds that outlives the
|
|
// run: a display list points at it, and a caller replays that list long after
|
|
// the page's scratch arena is gone. Everything the Image reads afterwards has
|
|
// to be Document memory, and its dictionary's KEYS are the part that is easy
|
|
// to miss -- the lexer slices a name that needs no unescaping straight out of
|
|
// the bytes it read, and for an inline image those bytes are the page's
|
|
// content, which is scratch. Decoding reads /Filter out of that dictionary, so
|
|
// a list replayed after the page had finished read unmapped address space and
|
|
// took the process with it.
|
|
@(test)
|
|
test_page_display_list_keeps_an_inline_image :: proc(t: ^testing.T) {
|
|
content := "q 40 0 0 40 10 10 cm BI /W 2 /H 1 /BPC 8 /CS /G /F /AHx ID 00ff> EI Q"
|
|
f, ok := pt_fixture(t, "/MediaBox [0 0 100 100]", content)
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
|
|
dl, lerr := page_display_list(&f.page, MATRIX_IDENTITY)
|
|
testing.expect_value(t, lerr, Error.None)
|
|
if dl == nil {
|
|
return
|
|
}
|
|
defer {
|
|
display_list_destroy(dl)
|
|
free(dl)
|
|
}
|
|
|
|
img: ^Image
|
|
for cmd in dl.cmds {
|
|
if v, is_image := cmd.(Display_Fill_Image); is_image {
|
|
img = v.img
|
|
}
|
|
}
|
|
if !testing.expect(t, img != nil, "the inline image has to reach the list") {
|
|
return
|
|
}
|
|
|
|
// The scratch arena is gone. The dictionary still has to read.
|
|
testing.expect_value(t, dict_int(f.doc, img.stream.dict, "W", 0), 2)
|
|
testing.expect_value(t, dict_int(f.doc, img.stream.dict, "H", 0), 1)
|
|
testing.expect(t, name_is(dict_get(f.doc, img.stream.dict, "F"), "AHx"),
|
|
"the filter name has to survive with it")
|
|
|
|
// And it still decodes, which is what a replay does with it: two pixels,
|
|
// black then white, over the middle of the page.
|
|
replay, rerr := bitmap_make(100, 100, 4)
|
|
testing.expect_value(t, rerr, Error.None)
|
|
defer bitmap_destroy(&replay)
|
|
page_fill_background(&replay, PT_WHITE)
|
|
rd, rderr := raster_device_make(&replay, Raster_Clip{0, 0, 100, 100},
|
|
context.allocator, f.doc)
|
|
testing.expect_value(t, rderr, Error.None)
|
|
if rd == nil {
|
|
return
|
|
}
|
|
testing.expect_value(t, display_list_run(dl, raster_device_as_device(rd)), Error.None)
|
|
dev_close(raster_device_as_device(rd))
|
|
raster_device_destroy(rd)
|
|
bitmap_unpremultiply(&replay)
|
|
|
|
// The dark half of the image, smoothed into the light one -- a 2x1 image
|
|
// stretched across 40 points is a gradient, and `rd_fill_image` says so.
|
|
box, count := pt_ink(&replay, PT_WHITE)
|
|
testing.expectf(t, count > 300, "the replayed image drew %d pixels", count)
|
|
testing.expectf(t, box.x0 == 10 && box.y0 == 50 && box.y1 == 90,
|
|
"the image landed at %v, not on the 40-point square at (10,10)", box)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Three rules that only a whole page shows
|
|
//
|
|
// Each of the three below is enforced one layer at a time by a unit test in
|
|
// `content_test.odin` or `device_raster_test.odin`. They are repeated here as
|
|
// pages because that is the form the defect took: not a wrong return value but
|
|
// a picture, and each one reached the picture through a different layer --
|
|
// the interpreter's clip stack, its marked-content stack, and the device's
|
|
// group compositing.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// A form's /BBox is a clip on its contents (8.10.2), so a box with no area
|
|
// draws nothing at all. Reading "empty" as "no box to clip to" inverted that:
|
|
// the form painted its whole content over the page, unclipped.
|
|
@(test)
|
|
test_render_page_zero_area_form_bbox_draws_nothing :: proc(t: ^testing.T) {
|
|
zero := Pt_Obj{
|
|
body = "/Type /XObject /Subtype /Form /BBox [0 0 0 0]",
|
|
payload = "0 0 20 20 re f",
|
|
stream = true,
|
|
}
|
|
// The same fill under a box that HAS area, which is what says the clip is
|
|
// a real clip and not a blanket refusal to draw the form.
|
|
two := Pt_Obj{
|
|
body = "/Type /XObject /Subtype /Form /BBox [0 0 2 2]",
|
|
payload = "0 0 20 20 re f",
|
|
stream = true,
|
|
}
|
|
page := "/MediaBox [0 0 100 100] /Resources << /XObject << /Zero 5 0 R /Two 6 0 R >> >>"
|
|
|
|
{
|
|
f, ok := pt_fixture(t, page, "/Zero Do", {zero, two})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
pt_expect_px(t, &bmp, 50, 50, PT_WHITE, "the middle of the page")
|
|
pt_expect_ink(t, &bmp, Pt_Box{}, PT_WHITE, "a zero-area /BBox draws nothing")
|
|
}
|
|
{
|
|
f, ok := pt_fixture(t, page, "/Two Do", {zero, two})
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
// Bottom-left after the page flip: PDF y 0..2 is device y 98..100.
|
|
pt_expect_ink(t, &bmp, Pt_Box{0, 98, 2, 100}, [4]u8{0, 0, 0, 255},
|
|
"a 2x2 /BBox clips the same 20x20 fill to 2x2")
|
|
}
|
|
}
|
|
|
|
// A BDC that arrives without its two operands still OPENS a marked-content
|
|
// section. Opening nothing left its EMC to close the ENCLOSING one, so the
|
|
// rest of a hidden optional-content section became visible.
|
|
@(test)
|
|
test_render_page_short_bdc_keeps_hidden_content_hidden :: proc(t: ^testing.T) {
|
|
ocg := pt_plain("<< /Type /OCG /Name (L1) >>")
|
|
cat := "/OCProperties << /OCGs [5 0 R] /D << /OFF [5 0 R] >> >>"
|
|
page := "/MediaBox [0 0 100 100] /Resources << /Properties << /L1 5 0 R >> >>"
|
|
|
|
{
|
|
// Both sections are still open where the fill is, and the outer one is
|
|
// switched off.
|
|
f, ok := pt_fixture(t, page,
|
|
"/OC /L1 BDC /Foo BDC EMC 0 0 1 rg 0 0 100 100 re f EMC", {ocg}, cat)
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
pt_expect_px(t, &bmp, 50, 50, PT_WHITE, "the middle of a page whose only fill is hidden")
|
|
pt_expect_ink(t, &bmp, Pt_Box{}, PT_WHITE, "a short BDC does not end the hidden section")
|
|
}
|
|
{
|
|
// The control: two EMCs really do close both, and the same fill draws.
|
|
f, ok := pt_fixture(t, page,
|
|
"/OC /L1 BDC /Foo BDC EMC EMC 0 0 1 rg 0 0 100 100 re f", {ocg}, cat)
|
|
if !ok {
|
|
return
|
|
}
|
|
defer pt_done(&f)
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
defer bitmap_destroy(&bmp)
|
|
pt_expect_ink(t, &bmp, Pt_Box{0, 0, 100, 100}, PT_BLUE,
|
|
"outside both sections the fill is visible again")
|
|
}
|
|
}
|
|
|
|
// A non-isolated transparency group is seeded with a copy of its backdrop, so
|
|
// its finished bitmap already IS "contents over backdrop". Compositing that
|
|
// over the backdrop a second time blends the backdrop with itself: `C over C`
|
|
// under Normal takes alpha 0.5 to 0.75. /I defaults to false, so this is the
|
|
// path nearly every real group takes, and the backdrop is non-opaque whenever
|
|
// the group is nested inside another one -- as here, where the inner group
|
|
// draws nothing at all and still used to darken the half-red beneath it.
|
|
@(test)
|
|
test_render_page_nested_non_isolated_group_keeps_its_backdrop :: proc(t: ^testing.T) {
|
|
inner := Pt_Obj{
|
|
body = "/Type /XObject /Subtype /Form /BBox [0 0 100 100] " +
|
|
"/Group << /S /Transparency /CS /DeviceRGB >>",
|
|
payload = "q Q",
|
|
stream = true,
|
|
}
|
|
outer :: "/Type /XObject /Subtype /Form /BBox [0 0 100 100] " +
|
|
"/Group << /S /Transparency /CS /DeviceRGB /I true >> " +
|
|
"/Resources << /ExtGState << /GA << /ca 0.5 >> >> /XObject << /In 6 0 R >> >>"
|
|
paint :: "q /GA gs 1 0 0 rg 0 0 100 100 re f Q "
|
|
page :: "/MediaBox [0 0 100 100] /Resources << /XObject << /Ou 5 0 R >> >>"
|
|
|
|
render :: proc(t: ^testing.T, page, ou_payload: string, inner: Pt_Obj) -> (Bitmap, bool) {
|
|
f, ok := pt_fixture(t, page, "/Ou Do",
|
|
{Pt_Obj{body = outer, payload = ou_payload, stream = true}, inner})
|
|
if !ok {
|
|
return {}, false
|
|
}
|
|
defer pt_done(&f)
|
|
bmp, err := render_page(&f.page)
|
|
testing.expect_value(t, err, Error.None)
|
|
return bmp, err == .None
|
|
}
|
|
|
|
with_group, ok1 := render(t, page, paint + "/In Do", inner)
|
|
defer bitmap_destroy(&with_group)
|
|
without, ok2 := render(t, page, paint, inner)
|
|
defer bitmap_destroy(&without)
|
|
if !ok1 || !ok2 {
|
|
return
|
|
}
|
|
|
|
// Half a red over white, and nothing the empty group does may change it.
|
|
pt_expect_px(t, &with_group, 50, 50, [4]u8{255, 127, 127, 255},
|
|
"an empty non-isolated group over a half-transparent backdrop")
|
|
for y in 0 ..< with_group.height {
|
|
for x in 0 ..< with_group.width {
|
|
a := pt_px(&with_group, x, y)
|
|
b := pt_px(&without, x, y)
|
|
if a != b {
|
|
testing.expectf(t, false,
|
|
"(%d,%d): with the empty inner group %v, without it %v", x, y, a, b)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|