Files

1091 lines
37 KiB
Odin
Raw Permalink Normal View History

2026-09-25 23:32:29 +02:00
package pdf
import "core:math"
import "core:strings"
import "core:testing"
// The text device is tested the way the interpreter will drive it: by handing
// it Text runs whose items carry a `trm`. Nothing here needs a Document -- a
// Font is a plain struct, and `trm` is the only geometry the device reads --
// which keeps these tests independent of content.odin.
//
// Every run is at size 10 with 500/1000 glyphs unless it says otherwise, so
// one glyph advances 5 device units, an em is 10, and the space threshold
// (0.20 em) is 2.
@(private)
tdt_font_simple :: proc(f: ^Font, width: f64 = 500) {
f.kind = .Type1
f.mtx = Matrix{0.001, 0, 0, 0.001, 0, 0}
for c in 0 ..< FONT_CODES {
f.code_width[c] = width
}
}
// tdt_tm is the text matrix a page's base matrix produces for upright text:
// x to the right, y down the device, which is the flip render_page installs.
@(private)
tdt_tm :: proc(x, y, size: f64) -> Matrix {
return Matrix{size, 0, 0, -size, x, y}
}
@(private)
tdt_item :: proc(f: ^Font, tm: Matrix, r: rune) -> Text_Item {
return Text_Item{
trm = matrix_mul(font_matrix(f), tm),
code = 65,
cid = 65,
unicode = r,
advance = 5,
}
}
@(private)
tdt_show :: proc(td: ^Text_Device, f: ^Font, items: []Text_Item, wmode := 0) {
txt := text_make()
defer text_destroy(&txt)
txt.font = f
txt.wmode = wmode
for it in items {
append(&txt.items, it)
}
dev_fill_text(text_device_as_device(td), &txt, MATRIX_IDENTITY, Paint{})
}
// tdt_line shows an ASCII string with one x position per byte.
@(private)
tdt_line :: proc(td: ^Text_Device, f: ^Font, s: string, xs: []f64, y, size: f64) {
items := make([dynamic]Text_Item, 0, len(s))
defer delete(items)
for i in 0 ..< len(s) {
x := i < len(xs) ? xs[i] : f64(i) * size * 0.5
append(&items, tdt_item(f, tdt_tm(x, y, size), rune(s[i])))
}
tdt_show(td, f, items[:])
}
// tdt_run shows an ASCII string laid out on one baseline at a fixed step.
@(private)
tdt_run :: proc(td: ^Text_Device, f: ^Font, s: string, x0, y, size, step: f64) {
xs := make([dynamic]f64, 0, len(s))
defer delete(xs)
for i in 0 ..< len(s) {
append(&xs, x0 + f64(i) * step)
}
tdt_line(td, f, s, xs[:], y, size)
}
@(private)
tdt_text :: proc(t: ^testing.T, td: ^Text_Device) -> string {
s := text_device_result(td)
return s
}
// ---------------------------------------------------------------------------
@(test)
test_text_device_lifecycle_and_hints :: proc(t: ^testing.T) {
td, err := text_device_make()
testing.expect_value(t, err, Error.None)
testing.expect(t, td != nil, "a text device must be built")
defer text_device_destroy(td)
dev := text_device_as_device(td)
testing.expect(t, dev != nil, "")
// The whole point of the hints: a page whose text sits on a 4000x4000
// photograph must not decode it.
testing.expect(t, !dev_wants_images(dev), "the text device must not want images")
testing.expect(t, !dev_wants_shades(dev), "the text device must not want shadings")
testing.expect(t, .No_Glyph_Outlines in dev.hints, "outlines are never drawn here")
// Every path and image hook stays nil; the interpreter's dev_* wrappers
// are what make that free.
testing.expect(t, dev.fill_path == nil && dev.stroke_path == nil, "")
testing.expect(t, dev.fill_image == nil && dev.fill_shade == nil, "")
testing.expect(t, dev.pop_clip == nil, "a device with no clip stack pops nothing")
testing.expect(t, dev.fill_text != nil && dev.stroke_text != nil && dev.ignore_text != nil, "")
// An empty page extracts to an empty string, not to a newline.
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "")
blocks := text_device_blocks(td)
defer text_blocks_destroy(blocks)
testing.expect_value(t, len(blocks), 0)
text_device_destroy(nil)
testing.expect(t, text_device_as_device(nil) == nil, "")
}
// Glyphs emitted left to right, each advancing by its own width, are one word:
// no spaces invented between letters.
@(test)
test_text_device_joins_glyphs_into_a_word :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
tdt_run(td, &f, "Hello", 0, 100, 10, 5)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "Hello")
}
// The device reads positions, not the order the producer chose to emit them
// in. This is the same word with its glyphs shown in a shuffled order.
@(test)
test_text_device_sorts_out_of_order_glyphs :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
order := [5]int{2, 0, 4, 1, 3}
word := "Hello"
items := make([dynamic]Text_Item, 0, 5)
defer delete(items)
for k in order {
append(&items, tdt_item(&f, tdt_tm(f64(k) * 5, 100, 10), rune(word[k])))
}
tdt_show(td, &f, items[:])
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "Hello")
}
// Producers routinely position words instead of emitting a space character.
// A gap wider than 0.20 em is a word break; anything narrower is kerning.
@(test)
test_text_device_wide_gap_becomes_a_space :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
// "the" ends at 15; "quick" starts at 18, so the gap is 3 = 0.30 em.
xs := [8]f64{0, 5, 10, 18, 23, 28, 33, 38}
tdt_line(td, &f, "thequick", xs[:], 100, 10)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "the quick")
}
@(test)
test_text_device_kerning_gap_is_not_a_space :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
// 0.8 device units is 0.08 em: a wide positive kern, and the largest gap
// that must NOT become a space.
{
td, _ := text_device_make()
defer text_device_destroy(td)
xs := [2]f64{0, 5.8}
tdt_line(td, &f, "AV", xs[:], 100, 10)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "AV")
}
// 2.5 device units is 0.25 em, a word space, and must.
{
td, _ := text_device_make()
defer text_device_destroy(td)
xs := [2]f64{0, 7.5}
tdt_line(td, &f, "AV", xs[:], 100, 10)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "A V")
}
}
// Two baselines a normal leading apart are two lines of one paragraph.
@(test)
test_text_device_two_baselines_are_two_lines :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
tdt_run(td, &f, "abc", 0, 100, 10, 5)
tdt_run(td, &f, "def", 0, 112, 10, 5)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "abc\ndef")
blocks := text_device_blocks(td)
defer text_blocks_destroy(blocks)
testing.expect_value(t, len(blocks), 1)
testing.expect_value(t, len(blocks[0].lines), 2)
}
// Baselines that drift by less than the step tolerance would otherwise chain
// glyph to glyph until a whole page was one line. The total spread of a line
// is bounded as well as the step.
@(test)
test_text_device_drifting_baselines_do_not_chain :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
// 0.3 em of drift per glyph: under the 0.35 em step, over the 0.80 em a
// line is allowed to span in total.
items := make([dynamic]Text_Item, 0, 5)
defer delete(items)
word := "abcde"
for i in 0 ..< len(word) {
append(&items, tdt_item(&f, tdt_tm(f64(i) * 5, 100 + f64(i) * 3, 10), rune(word[i])))
}
tdt_show(td, &f, items[:])
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "abc\nde")
}
// A page rotated 90 degrees carries the rotation in the text matrix, so
// "left to right" is not device x and "top to bottom" is not device y. The
// same code has to read it without a special case.
@(test)
test_text_device_rotated_text_reads_in_order :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
// Writing direction is device -y (up the page); the glyph's own up axis
// points at device -x. Successive lines therefore advance along +x.
tm :: proc(x, y: f64) -> Matrix { return Matrix{0, -10, -10, 0, x, y} }
items := make([dynamic]Text_Item, 0, 6)
defer delete(items)
append(&items, tdt_item(&f, tm(100, 0), 'a'))
append(&items, tdt_item(&f, tm(100, -5), 'b'))
append(&items, tdt_item(&f, tm(100, -10), 'c'))
append(&items, tdt_item(&f, tm(112, 0), 'd'))
append(&items, tdt_item(&f, tm(112, -5), 'e'))
append(&items, tdt_item(&f, tm(112, -10), 'f'))
tdt_show(td, &f, items[:])
s := text_device_result(td)
defer delete(s)
// Read along the baseline, and the columns in the order the rotation puts
// them; reading device x and y instead would give "ad\nbe\ncf".
testing.expect_value(t, s, "abc\ndef")
}
// Render mode 3 paints nothing at all, which is exactly how a scanned page
// stores the OCR layer a user most wants to select. It reaches the device
// through ignore_text and must come out whole.
@(test)
test_text_device_invisible_text_is_extracted :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
txt := text_make()
defer text_destroy(&txt)
txt.font = &f
txt.render_mode = .Invisible
ocr := "OCR"
for i in 0 ..< len(ocr) {
append(&txt.items, tdt_item(&f, tdt_tm(f64(i) * 5, 100, 10), rune(ocr[i])))
}
dev_ignore_text(text_device_as_device(td), &txt, MATRIX_IDENTITY)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "OCR")
}
// Mode 1 is stroked text and is real text; mode 7 is a clip and is still text
// on the page. Both arrive on their own hook.
@(test)
test_text_device_stroked_and_clipped_text_is_extracted :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
stroke := STROKE_STATE_DEFAULT
a := text_make()
defer text_destroy(&a)
a.font = &f
append(&a.items, tdt_item(&f, tdt_tm(0, 100, 10), 'S'))
dev_stroke_text(text_device_as_device(td), &a, &stroke, MATRIX_IDENTITY, Paint{})
b := text_make()
defer text_destroy(&b)
b.font = &f
append(&b.items, tdt_item(&f, tdt_tm(5, 100, 10), 'C'))
dev_clip_text(text_device_as_device(td), &b, MATRIX_IDENTITY)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "SC")
}
// Render mode 2 fills and strokes, and modes 4 to 6 also clip, so one run
// reaches a device that implements every text hook two or three times. It is
// one piece of text.
@(test)
test_text_device_run_seen_twice_is_counted_once :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
stroke := STROKE_STATE_DEFAULT
txt := text_make()
defer text_destroy(&txt)
txt.font = &f
txt.render_mode = .Fill_Stroke_Clip
append(&txt.items, tdt_item(&f, tdt_tm(0, 100, 10), 'A'))
append(&txt.items, tdt_item(&f, tdt_tm(5, 100, 10), 'B'))
dev := text_device_as_device(td)
dev_fill_text(dev, &txt, MATRIX_IDENTITY, Paint{})
dev_stroke_text(dev, &txt, &stroke, MATRIX_IDENTITY, Paint{})
dev_clip_text(dev, &txt, MATRIX_IDENTITY)
// A different run at a different place is not a repeat.
other := text_make()
defer text_destroy(&other)
other.font = &f
append(&other.items, tdt_item(&f, tdt_tm(10, 100, 10), 'C'))
dev_fill_text(dev, &other, MATRIX_IDENTITY, Paint{})
// The glyphs are dropped on arrival, not tidied up at the end: three
// passes over a two-glyph run must not cost six glyphs of memory.
testing.expect_value(t, len(td.glyphs), 3)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "ABC")
}
// A glyph the font resolves no Unicode for must leave a mark. Dropping it
// silently joins the words either side of it and the caller cannot tell.
@(test)
test_text_device_missing_unicode_becomes_replacement :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
items := make([dynamic]Text_Item, 0, 3)
defer delete(items)
append(&items, tdt_item(&f, tdt_tm(0, 100, 10), 'A'))
it := tdt_item(&f, tdt_tm(5, 100, 10), 'x')
it.unicode = -1 // font.odin's "no Unicode at all"
append(&items, it)
append(&items, tdt_item(&f, tdt_tm(10, 100, 10), 'B'))
tdt_show(td, &f, items[:])
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "A�B")
testing.expect_value(t, len(s), 5) // three characters, one of them 3 bytes
}
// More than a line height between two baselines is a paragraph break.
@(test)
test_text_device_blank_line_between_blocks :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
tdt_run(td, &f, "abc", 0, 100, 10, 5)
tdt_run(td, &f, "def", 0, 112, 10, 5) // 1.2 em: same paragraph
tdt_run(td, &f, "ghi", 0, 152, 10, 5) // 4.0 em: a new one
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "abc\ndef\n\nghi")
blocks := text_device_blocks(td)
defer text_blocks_destroy(blocks)
testing.expect_value(t, len(blocks), 2)
testing.expect_value(t, len(blocks[0].lines), 2)
testing.expect_value(t, len(blocks[1].lines), 1)
}
// The structured form is what a viewer needs to draw a selection rectangle.
@(test)
test_text_device_blocks_carry_spans_and_rectangles :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
g: Font
tdt_font_simple(&g)
td, _ := text_device_make()
defer text_device_destroy(td)
items := make([dynamic]Text_Item, 0, 4)
defer delete(items)
append(&items, tdt_item(&f, tdt_tm(0, 100, 10), 'a'))
append(&items, tdt_item(&f, tdt_tm(5, 100, 10), 'b'))
tdt_show(td, &f, items[:])
clear(&items)
// A second font on the same baseline, at a different size.
append(&items, tdt_item(&g, tdt_tm(10, 100, 20), 'C'))
tdt_show(td, &g, items[:])
blocks := text_device_blocks(td)
defer text_blocks_destroy(blocks)
testing.expect_value(t, len(blocks), 1)
testing.expect_value(t, len(blocks[0].lines), 1)
line := blocks[0].lines[0]
testing.expect_value(t, len(line.spans), 2)
testing.expect_value(t, line.spans[0].text, "ab")
testing.expect_value(t, line.spans[0].size, 10)
testing.expect(t, line.spans[0].font == &f, "a span names the font it was set in")
testing.expect_value(t, line.spans[1].text, "C")
testing.expect_value(t, line.spans[1].size, 20)
testing.expect(t, line.spans[1].font == &g, "")
// "ab" spans x 0..10 and, at 0.8 em up and 0.2 em down from a baseline at
// y=100, y 92..102.
r := line.spans[0].rect
testing.expect(t, abs(r.x0 - 0) < 1e-9 && abs(r.x1 - 10) < 1e-9, "span x")
testing.expect(t, abs(r.y0 - 92) < 1e-9 && abs(r.y1 - 102) < 1e-9, "span y")
// The line and block rectangles cover every span.
testing.expect(t, line.rect.x1 >= 20 && line.rect.y0 <= 92, "line rect covers its spans")
testing.expect_value(t, blocks[0].rect, line.rect)
}
// Letter-spacing (Tc) widens every gap in a run equally. Without
// compensation a tracked heading extracts as "S P A C E D".
@(test)
test_text_device_tracking_is_not_word_spaces :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
// Advance 5 plus 2.5 of tracking: every gap is 0.25 em, over the space
// threshold, and none of them is a word break.
xs := [7]f64{0, 7.5, 15, 22.5, 30, 37.5, 50.5}
// ... except the last, which is 8 further on than the tracking explains.
tdt_line(td, &f, "SPACEDX", xs[:], 100, 10)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "SPACED X")
}
// The cap on that compensation is what keeps a table row -- one TJ with wide
// gaps between the cells -- from having every space compensated away.
@(test)
test_text_device_tracking_cap_keeps_table_cells_apart :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
// Five cells, 8 units (0.8 em) of clear space between each.
xs := [5]f64{0, 13, 26, 39, 52}
tdt_line(td, &f, "ABCDE", xs[:], 100, 10)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "A B C D E")
}
// Producers fake bold by drawing the same glyph twice a hair apart. It is one
// letter.
@(test)
test_text_device_fake_bold_is_not_doubled :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
items := make([dynamic]Text_Item, 0, 4)
defer delete(items)
append(&items, tdt_item(&f, tdt_tm(0, 100, 10), 'A'))
append(&items, tdt_item(&f, tdt_tm(0.3, 100, 10), 'A'))
append(&items, tdt_item(&f, tdt_tm(5, 100, 10), 'B'))
append(&items, tdt_item(&f, tdt_tm(5.3, 100, 10), 'B'))
tdt_show(td, &f, items[:])
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "AB")
}
// A real repeated letter is not fake bold: it is a whole advance away.
@(test)
test_text_device_repeated_letter_survives :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
tdt_run(td, &f, "aardvark", 0, 100, 10, 5)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "aardvark")
}
// A composite font's advances come from /W and /DW, not from the 256-entry
// table a simple font uses, and a Type3's come through its /FontMatrix. Get
// either wrong and a line of text turns into a line of spaced letters.
@(test)
test_text_device_composite_and_type3_advances :: proc(t: ^testing.T) {
{
f: Font
f.kind = .Type0
f.mtx = Matrix{0.001, 0, 0, 0.001, 0, 0}
f.default_width = 1000 // a full em per glyph
td, _ := text_device_make()
defer text_device_destroy(td)
tdt_run(td, &f, "CJK", 0, 100, 10, 10)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "CJK")
}
{
// A Type3 whose glyph space is hundredths: /Widths of 50 is half an em.
f: Font
f.kind = .Type3
f.mtx = Matrix{0.01, 0, 0, 0.01, 0, 0}
for c in 0 ..< FONT_CODES {
f.code_width[c] = 50
}
td, _ := text_device_make()
defer text_device_destroy(td)
tdt_run(td, &f, "abc", 0, 100, 10, 5)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "abc")
}
}
// A vertical CMap writes down text space's -y, and its columns advance
// leftwards, so the rightmost column is read first (ISO 32000-2, 9.7.4.3).
@(test)
test_text_device_vertical_writing :: proc(t: ^testing.T) {
f: Font
f.kind = .Type0
f.mtx = Matrix{0.001, 0, 0, 0.001, 0, 0}
f.vertical = true
f.default_width = 1000
f.default_w1 = -1000
td, _ := text_device_make()
defer text_device_destroy(td)
items := make([dynamic]Text_Item, 0, 6)
defer delete(items)
append(&items, tdt_item(&f, tdt_tm(100, 0, 10), 'a'))
append(&items, tdt_item(&f, tdt_tm(100, 10, 10), 'b'))
append(&items, tdt_item(&f, tdt_tm(100, 20, 10), 'c'))
append(&items, tdt_item(&f, tdt_tm(88, 0, 10), 'd'))
append(&items, tdt_item(&f, tdt_tm(88, 10, 10), 'e'))
append(&items, tdt_item(&f, tdt_tm(88, 20, 10), 'f'))
tdt_show(td, &f, items[:], 1)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "abc\ndef")
}
// The device reads the matrices, not Text_Item.advance, precisely so that it
// cannot be desynchronised from where the glyphs actually landed.
@(test)
test_text_device_ignores_the_advance_field :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
items := make([dynamic]Text_Item, 0, 8)
defer delete(items)
xs := [8]f64{0, 5, 10, 18, 23, 28, 33, 38}
word := "thequick"
for i in 0 ..< 8 {
it := tdt_item(&f, tdt_tm(xs[i], 100, 10), rune(word[i]))
it.advance = f64(i) * -1e6 // nonsense, in nonsense units
append(&items, it)
}
tdt_show(td, &f, items[:])
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "the quick")
}
// Content streams are the most malformed data in a PDF. None of this may
// crash, hang or leak, and whatever is still readable must still be read.
@(test)
test_text_device_survives_degenerate_input :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
dev := text_device_as_device(td)
stroke := STROKE_STATE_DEFAULT
dev_fill_text(dev, nil, MATRIX_IDENTITY, Paint{})
dev_stroke_text(dev, nil, &stroke, MATRIX_IDENTITY, Paint{})
dev_ignore_text(dev, nil, MATRIX_IDENTITY)
empty := text_make()
defer text_destroy(&empty)
dev_fill_text(dev, &empty, MATRIX_IDENTITY, Paint{})
// A run with no font at all: font_matrix falls back, widths are zero.
nofont := text_make()
defer text_destroy(&nofont)
append(&nofont.items, Text_Item{trm = Matrix{0.01, 0, 0, -0.01, 0, 0}, unicode = 'Z'})
dev_fill_text(dev, &nofont, MATRIX_IDENTITY, Paint{})
bad := text_make()
defer text_destroy(&bad)
bad.font = &f
nan := math.QNAN_F64
inf := math.INF_F64
append(&bad.items, Text_Item{trm = Matrix{nan, 0, 0, nan, 0, 0}, unicode = 'a'})
append(&bad.items, Text_Item{trm = Matrix{1, 0, 0, 1, inf, 0}, unicode = 'b'})
append(&bad.items, Text_Item{trm = Matrix{0, 0, 0, 0, 5, 5}, unicode = 'c'})
append(&bad.items, tdt_item(&f, tdt_tm(0, 100, 10), 'K'))
dev_fill_text(dev, &bad, MATRIX_IDENTITY, Paint{})
s := text_device_result(td)
defer delete(s)
// The one placeable glyph in that soup is the one that comes back.
testing.expect(t, strings.contains(s, "K"), "a readable glyph must survive its neighbours")
testing.expect(t, !strings.contains(s, "a"), "a NaN matrix places nothing")
testing.expect(t, !strings.contains(s, "b"), "an infinite position places nothing")
testing.expect(t, !strings.contains(s, "c"), "a collapsed matrix places nothing")
blocks := text_device_blocks(td)
defer text_blocks_destroy(blocks)
testing.expect(t, len(blocks) >= 1, "")
for b in blocks {
testing.expect(t, rect_is_finite(b.rect), "no NaN may reach a caller's rectangle")
for l in b.lines {
testing.expect(t, rect_is_finite(l.rect), "")
for sp in l.spans {
testing.expect(t, rect_is_finite(sp.rect) && is_finite(sp.size), "")
}
}
}
}
// Both outputs are rendered from one layout, so they cannot disagree about
// what the page says.
@(test)
test_text_device_result_and_blocks_agree :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
tdt_run(td, &f, "alpha", 0, 100, 10, 5)
tdt_run(td, &f, "beta", 0, 112, 10, 5)
tdt_run(td, &f, "gamma", 0, 160, 10, 5)
s := text_device_result(td)
defer delete(s)
blocks := text_device_blocks(td)
defer text_blocks_destroy(blocks)
b := strings.builder_make()
defer strings.builder_destroy(&b)
for blk, bi in blocks {
if bi > 0 { strings.write_string(&b, "\n\n") }
for ln, li in blk.lines {
if li > 0 { strings.write_byte(&b, '\n') }
for sp in ln.spans { strings.write_string(&b, sp.text) }
}
}
testing.expect_value(t, s, strings.to_string(b))
testing.expect_value(t, s, "alpha\nbeta\n\ngamma")
}
// Extracting twice must give the same answer: nothing is consumed.
@(test)
test_text_device_result_is_repeatable :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
tdt_run(td, &f, "twice", 0, 100, 10, 5)
a := text_device_result(td)
defer delete(a)
b := text_device_result(td)
defer delete(b)
testing.expect_value(t, a, b)
testing.expect_value(t, a, "twice")
}
// Text_Item.trm is contracted to map glyph space to device space. A producer
// of Text_Items that handed over the text-space matrix instead would leave
// every em a thousand times too large, and the whole page would collapse into
// one line with no spaces in it. The guard is a page whose text cannot
// possibly fit inside one em.
@(test)
test_text_device_rescales_impossible_matrices :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
word := "abcdefghij klmnopqrst"
items := make([dynamic]Text_Item, 0, len(word))
defer delete(items)
x := 0.0
for i in 0 ..< len(word) {
if word[i] == ' ' {
x += 3
continue
}
// Note: no font matrix folded in, which is the mistake being guarded.
append(&items, Text_Item{
trm = tdt_tm(x, 100, 10),
code = 65,
cid = 65,
unicode = rune(word[i]),
})
x += 5
}
tdt_show(td, &f, items[:])
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "abcdefghij klmnopqrst")
}
// Trailing space glyphs are producer noise; a space glyph at the start of a
// line carries nothing the rectangle does not.
@(test)
test_text_device_trims_edge_spaces :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
tdt_run(td, &f, " hi ", 0, 100, 10, 5)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "hi")
}
// A run that is emitted twice with a gap between the two halves still reads
// as one line: runs are joined by position, not by operator.
@(test)
test_text_device_joins_runs_on_one_baseline :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
tdt_run(td, &f, "one", 0, 100, 10, 5)
tdt_run(td, &f, "two", 15, 100, 10, 5) // butts straight on
tdt_run(td, &f, "three", 33, 100, 10, 5) // 3 units clear: a word break
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "onetwo three")
}
// A text matrix with a flattened axis -- a producer hiding text, or a scale
// that rounded to zero -- still says where every glyph is. The surviving axis
// stands in for the other rather than the run being thrown away.
@(test)
test_text_device_flattened_matrix_still_reads :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
items := make([dynamic]Text_Item, 0, 4)
defer delete(items)
word := "flat"
for i in 0 ..< len(word) {
// No y axis at all: {size, 0, 0, 0, x, y}.
append(&items, tdt_item(&f, Matrix{10, 0, 0, 0, f64(i) * 5, 100}, rune(word[i])))
}
tdt_show(td, &f, items[:])
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "flat")
blocks := text_device_blocks(td)
defer text_blocks_destroy(blocks)
testing.expect_value(t, len(blocks), 1)
// The borrowed axis has to give the glyph box a real height, or a
// selection rectangle is a zero-height line.
r := blocks[0].lines[0].spans[0].rect
testing.expect(t, rect_height(r) > 5, "the em box must survive a flattened matrix")
testing.expect(t, rect_width(r) > 15, "")
}
// Fake italics are a shear in the text matrix: the y axis leans but the
// baseline does not. Lines still group by their baselines.
@(test)
test_text_device_sheared_matrix_reads_in_order :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
// A hard lean, and lines long enough that measuring baselines along the
// leaning y axis rather than along the true normal would tear each line
// into pieces.
shear :: proc(x, y: f64) -> Matrix { return Matrix{10, 0, 8, -10, x, y} }
items := make([dynamic]Text_Item, 0, 20)
defer delete(items)
top := "abcdefghij"
bot := "klmnopqrst"
for i in 0 ..< len(top) {
append(&items, tdt_item(&f, shear(f64(i) * 5, 100), rune(top[i])))
}
for i in 0 ..< len(bot) {
append(&items, tdt_item(&f, shear(f64(i) * 5, 112), rune(bot[i])))
}
tdt_show(td, &f, items[:])
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "abcdefghij\nklmnopqrst")
}
// The glyph budget is a bound on what a hostile content stream can make the
// device hold, and it has to bite without losing what came before it.
@(test)
test_text_device_glyph_budget_bites :: proc(t: ^testing.T) {
f: Font
tdt_font_simple(&f)
td, _ := text_device_make()
defer text_device_destroy(td)
items := make([dynamic]Text_Item, 0, 1000)
defer delete(items)
for line in 0 ..< 400 {
clear(&items)
for i in 0 ..< 1000 {
append(&items, tdt_item(&f, tdt_tm(f64(i) * 5, f64(line) * 12, 10), 'x'))
}
tdt_show(td, &f, items[:])
if td.truncated {
break
}
}
testing.expect(t, td.truncated, "400 000 glyphs must not all be kept")
testing.expect_value(t, len(td.glyphs), MAX_TEXT_GLYPHS)
blocks := text_device_blocks(td)
defer text_blocks_destroy(blocks)
testing.expect(t, len(blocks) > 0, "what was collected before the budget ran out is still text")
}
// ---------------------------------------------------------------------------
// End to end, through the real interpreter
//
// Everything above hands the device Text runs built by hand. These run actual
// content streams through content.odin, which is where the two files' readings
// of `Text_Item.trm` have to agree: the interpreter folds the font matrix into
// it and this device divides that back out, and if either changed its mind
// every threshold here would be a thousand times off.
//
// The fixture font is half an em wide for every code -- /BaseFont is not one
// of the standard 14, so no AFM metric can get in ahead of /MissingWidth --
// which makes every expected position arithmetic at 10 point.
// ---------------------------------------------------------------------------
@(private = "file")
TDT_FONT :: "<< /Type /Font /Subtype /Type1 /BaseFont /Halfwidth " +
"/FontDescriptor << /Flags 32 /MissingWidth 500 >> >>"
@(private = "file")
TDT_TYPE3 :: "<< /Type /Font /Subtype /Type3 /FontMatrix [0.01 0 0 0.01 0 0] " +
"/FontBBox [0 0 100 100] /FirstChar 65 /LastChar 66 /Widths [100 100] " +
"/Encoding << /Type /Encoding /Differences [65 /A 66 /B] >> >>"
// A /ToUnicode that maps one code to a ligature's two letters and another to
// a single character, so that the multi-character path and the ordinary one
// are both live in the same font.
@(private = "file")
TDT_TOUNICODE :: `begincmap
1 begincodespacerange <00> <ff> endcodespacerange
2 beginbfchar
<41> <00660069>
<42> <0058>
endbfchar
endcmap`
@(private = "file")
tdt_resources :: proc(doc: ^Document) -> ^Dict {
res := new_dict(doc.allocator)
fonts := new_dict(doc.allocator)
dict_set(fonts, "F1", cf_parse(doc, TDT_FONT))
if lig, ok := to_dict(cf_parse(doc, TDT_FONT)); ok {
dict_set(lig, "ToUnicode", cf_stream(doc, "<< >>", transmute([]byte)string(TDT_TOUNICODE)))
dict_set(fonts, "FL", lig)
}
t3 := cf_parse(doc, TDT_TYPE3)
if t3d, ok := to_dict(t3); ok {
procs := new_dict(doc.allocator)
// A whole-em square, declared with d0 the way a Type 3 glyph must.
dict_set(procs, "A", cf_stream(doc, "<< >>",
transmute([]byte)string("100 0 d0 0 0 100 100 re f")))
dict_set(procs, "B", cf_stream(doc, "<< >>",
transmute([]byte)string("100 0 d0")))
dict_set(t3d, "CharProcs", procs)
}
dict_set(fonts, "T3", t3)
dict_set(res, "Font", fonts)
return res
}
// tdt_extract runs a content stream under the page flip a real render installs.
@(private = "file")
tdt_extract :: proc(t: ^testing.T, doc: ^Document, src: string) -> string {
td, err := text_device_make()
if err != .None || td == nil {
testing.expect(t, false, "text device")
return ""
}
defer text_device_destroy(td)
flip := Matrix{1, 0, 0, -1, 0, 792}
rerr := run_content(doc, text_device_as_device(td), transmute([]byte)src,
tdt_resources(doc), flip)
testing.expect_value(t, rerr, Error.None)
return text_device_result(td)
}
@(test)
test_text_device_end_to_end_lines :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
s := tdt_extract(t, doc, "BT /F1 10 Tf 100 700 Td (Hello) Tj 0 -12 Td (World) Tj ET")
defer delete(s)
testing.expect_value(t, s, "Hello\nWorld")
// Four lines of leading is a paragraph break, not a line break.
p := tdt_extract(t, doc, "BT /F1 10 Tf 100 700 Td (one) Tj 0 -40 Td (two) Tj ET")
defer delete(p)
testing.expect_value(t, p, "one\n\ntwo")
}
// TJ's numbers are thousandths of an em, subtracted from the pen. A -250 is a
// word space nobody wrote; a -80 is kerning.
@(test)
test_text_device_end_to_end_tj_spacing :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
s := tdt_extract(t, doc, "BT /F1 10 Tf 100 700 Td [(the) -250 (quick) -80 (brown)] TJ ET")
defer delete(s)
testing.expect_value(t, s, "the quickbrown")
}
// Render mode 3 is the OCR layer under a scan.
@(test)
test_text_device_end_to_end_invisible :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
s := tdt_extract(t, doc, "BT /F1 10 Tf 3 Tr 100 700 Td (scanned) Tj ET")
defer delete(s)
testing.expect_value(t, s, "scanned")
}
// Modes 2 and 4 to 6 reach the device twice: once to be painted and once as a
// clip. The page still says it once.
@(test)
test_text_device_end_to_end_clipped_text_is_not_doubled :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
for mode in ([]string{"2", "4", "5", "6", "7"}) {
src := strings.concatenate({"BT /F1 10 Tf ", mode, " Tr 100 700 Td (clip) Tj ET"})
defer delete(src)
s := tdt_extract(t, doc, src)
defer delete(s)
testing.expectf(t, s == "clip", "render mode %s extracted %q", mode, s)
}
}
// Tc widens every gap in the run; Tw widens the one after a space glyph. The
// first must not become spaces and the second must not become two.
@(test)
test_text_device_end_to_end_char_and_word_spacing :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
// 3 points of tracking at 10 point: 0.3 em, over the space threshold.
a := tdt_extract(t, doc, "BT /F1 10 Tf 3 Tc 100 700 Td (SPACED) Tj ET")
defer delete(a)
testing.expect_value(t, a, "SPACED")
// 5 points of word spacing, on top of the space glyph's own 5.
b := tdt_extract(t, doc, "BT /F1 10 Tf 5 Tw 100 700 Td (a b) Tj ET")
defer delete(b)
testing.expect_value(t, b, "a b")
}
// A Type 3 glyph is a content stream, so it never reaches a text hook as an
// outline -- the interpreter hands the run over separately, which is the only
// reason a Type 3 page extracts at all. Its widths are in the font's own
// glyph space and its /FontMatrix is the only thing that says how wide an em
// is: read as thousandths, these two glyphs would be a tenth of the size and
// the line would come out as "A B".
@(test)
test_text_device_end_to_end_type3 :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
s := tdt_extract(t, doc, "BT /T3 10 Tf 100 700 Td (AB) Tj ET")
defer delete(s)
testing.expect_value(t, s, "AB")
}
// A page rotated 90 degrees puts the rotation in the CTM, and every glyph
// matrix carries it. Reading order comes out of the matrices, so the text is
// the same text.
@(test)
test_text_device_end_to_end_rotated_page :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
td, _ := text_device_make()
defer text_device_destroy(td)
// The base matrix a viewer builds for /Rotate 90: flip y, then turn.
ctm := matrix_mul(Matrix{1, 0, 0, -1, 0, 792}, rotate_matrix(90))
err := run_content(doc, text_device_as_device(td),
transmute([]byte)string("BT /F1 10 Tf 100 700 Td (Hello) Tj 0 -12 Td (World) Tj ET"),
tdt_resources(doc), ctm)
testing.expect_value(t, err, Error.None)
s := text_device_result(td)
defer delete(s)
testing.expect_value(t, s, "Hello\nWorld")
}
// A ligature glyph stands for several characters, and /ToUnicode is where the
// producer says so. Reporting only the first of them is how "Specification"
// extracts as "Specifcation" -- which is what the corpus does when this is
// missing.
@(test)
test_text_device_end_to_end_ligature :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
s := tdt_extract(t, doc, "BT /FL 10 Tf 100 700 Td (ABA) Tj ET")
defer delete(s)
testing.expect_value(t, s, "fiXfi")
}