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 } } } }