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

991 lines
34 KiB
Odin

package pdf
import "core:math"
import "core:mem"
import "core:slice"
import "core:strings"
import "core:unicode/utf8"
// The text-extraction Device (ISO 32000-2, 9.4.4 for the geometry it undoes).
//
// A PDF has no notion of a word, a line or a paragraph. It has glyphs at
// coordinates, emitted in whatever order suited the producer -- every column
// of a table first, or a whole page of body text with each word positioned
// individually and not one space character in the file. Reconstructing reading
// order from that is the entire job of this file; drawing is somebody else's.
//
// WHAT ARRIVES
//
// One `Text` per text-showing operator, each item carrying `trm`, the complete
// glyph-space-to-device-space matrix, and `unicode`, which font.odin has
// already resolved through /ToUnicode, the glyph name and the CMap. Three
// hooks feed the same collector: `fill_text`, `stroke_text` (render mode 1 is
// real text) and `ignore_text` -- mode 3 is invisible, and invisible is
// exactly how a scanned page stores its OCR layer, which is the text a user
// most wants. `clip_text` feeds it too, so that mode 7 is not lost; a run that
// arrives twice because its render mode both fills and clips is recognised and
// counted once.
//
// One glyph can stand for several characters -- "ffi" is one glyph and three
// letters -- and `unicode`, being one rune, reports the first. The producer
// said the rest in /ToUnicode, so this file asks for it: without that,
// "Specification" extracts as "Specifcation", which is what the corpus does.
//
// HOW A GLYPH IS PLACED
//
// `trm` maps glyph space, which is the font program's own unit system, to
// device space. Undoing the font matrix,
//
// T = font_matrix(f)^-1 * trm
//
// gives text space to device space, and that is the frame everything here
// works in: T's translation is the pen, |(T.a, T.b)| is one em along the
// writing direction (with the horizontal scale folded in, which is what
// advances are measured against) and |(T.c, T.d)| is one em vertically, which
// is the font size a caller wants reported. Deriving both from the matrix is
// what makes rotated, skewed and mirrored text come out in the right order
// without a special case: "left to right" is along T's x axis, never along
// device x.
//
// A glyph's advance is taken from the Font rather than from `Text_Item.advance`
// on purpose. The interpreter positioned the next glyph with the width out of
// that same Font, so measuring against it is self-consistent by construction:
// a font whose /Widths are all zero piles its glyphs on one spot and yields no
// gaps, instead of yielding a gap between every pair.
//
// THE THRESHOLDS, AND WHY THEY ARE WHAT THEY ARE
//
// All of them are fractions of the em, so they hold at any font size.
//
// space 0.20 A gap wider than this becomes a space. Kerning pairs
// are tightenings -- negative gaps -- and the widest
// positive kern in a real font is around 0.05 em; a
// word space is 0.25 to 0.33 em. 0.20 sits in the empty
// band between the two.
// tracking cap 0.15 Letter-spacing (Tc) widens every gap in a run
// equally. The smallest gap in a run is subtracted from
// all of them, capped here so that a table row emitted
// as one widely-spaced TJ keeps its spaces.
// same line 0.35 Baseline separation, incrementally, and 0.80 in
// total, so a subscript joins its line and two lines of
// text never merge -- leading below a third of the font
// size does not exist.
// new block 1.60 Baseline step that ends a paragraph. Normal leading
// is 1.0 to 1.5 em.
// duplicate 0.10 The same character re-drawn within a tenth of an em
// is a producer faking bold by overprinting, not a
// repeated letter.
//
// Nothing here is a Document operation, so there is no `add_repair` to call;
// damage shows up as `Text_Device.truncated` or is simply skipped.
// Gap, as a fraction of the em, that becomes a space.
@(private)
TEXT_SPACE_EM :: 0.20
// Most letter-spacing that will be compensated away, in ems.
@(private)
TEXT_TRACK_EM :: 0.15
// Smallest tracking worth compensating for, in ems.
@(private)
TEXT_TRACK_MIN_EM :: 0.05
// Adjacent pairs a run needs before its tracking is estimated at all.
@(private)
TEXT_TRACK_PAIRS :: 3
// Baseline separation that keeps two glyphs on one line: step to step, and
// over the whole line.
@(private)
TEXT_LINE_EM :: 0.35
@(private)
TEXT_LINE_SPREAD_EM :: 0.80
// Baseline step that starts a new block.
@(private)
TEXT_BLOCK_EM :: 1.60
// Distance within which a repeat of the same character is fake bold.
@(private)
TEXT_DUP_EM :: 0.10
// Em box used for a glyph's rectangle, in the absence of real per-glyph
// bounding boxes: a typical ascent and descent.
@(private)
TEXT_ASCENT_EM :: 0.80
@(private)
TEXT_DESCENT_EM :: 0.20
// Glyphs kept from one page. A page of dense CJK holds a few thousand; this
// is three orders of magnitude above any real page and bounds the memory a
// hostile content stream can make us hold.
@(private)
MAX_TEXT_GLYPHS :: 1 << 18
// Bytes of /ToUnicode text one glyph may stand for. A ligature is three or
// four; a producer that maps a glyph to a paragraph is not describing a glyph.
@(private)
MAX_TEXT_UNICODE_BYTES :: 64
// An advance is clamped to this many ems. /Widths is attacker-controlled and
// font.odin's own clamp still allows 10 000 ems.
@(private)
MAX_TEXT_ADVANCE_EM :: 100.0
// ---------------------------------------------------------------------------
// Structured output
// ---------------------------------------------------------------------------
// Text_Span is a run of characters on one line in one font at one size.
// `rect` is in device space -- the space the CTM handed to the interpreter
// maps into -- and covers the em boxes of the span's glyphs.
Text_Span :: struct {
text: string,
rect: Rect,
font: ^Font,
size: f64,
}
// Text_Line is one baseline's worth of spans, ordered along the writing
// direction.
Text_Line :: struct {
spans: []Text_Span,
rect: Rect,
}
// Text_Block is a run of lines with no unusual vertical gap between them: a
// paragraph, as far as coordinates can tell.
Text_Block :: struct {
lines: []Text_Line,
rect: Rect,
}
// ---------------------------------------------------------------------------
// The device
// ---------------------------------------------------------------------------
// Text_Glyph is one placed character, in device space and in the frame of its
// own writing direction.
@(private)
Text_Glyph :: struct {
// Pen position, and where the pen lands after this glyph's advance.
pen, end: Point,
// Unit writing direction, unit "up" (the em's y axis), and the unit normal
// pointing the way successive lines advance.
dir, up, n: Point,
// One em along the writing direction, and one em vertically.
em, size: f64,
// pen projected on n and on dir, in the canonical frame of the glyph's
// angle bucket. Filled in at layout time.
u, v: f64,
r: rune,
// The whole of what the glyph stands for, when that is more than one code
// point; empty otherwise, and `r` is the character. A view into the font's
// /ToUnicode CMap, which the Document owns.
text: string,
font: ^Font,
// Emission order: the last tiebreak in every sort, so the result is
// deterministic, and the order in which equally-placed glyphs are joined.
seq: int,
// Writing angle in whole degrees, 0..359, and its rank in reading order.
bucket: int,
border: int,
}
// Text_Run_Sig identifies a glyph run closely enough to recognise the same run
// arriving twice. Render mode 2 fills and strokes, and modes 4 to 6 also clip,
// so a device that implements every text hook sees one run two or three times.
@(private)
Text_Run_Sig :: struct {
count: int,
font: ^Font,
first, last: Matrix,
r0, r1: rune,
}
Text_Device :: struct {
dev: Device,
allocator: mem.Allocator,
glyphs: [dynamic]Text_Glyph,
seq: int,
// Set when MAX_TEXT_GLYPHS was reached and glyphs were dropped.
truncated: bool,
sig: Text_Run_Sig,
have_sig: bool,
}
// text_device_make builds a device that collects text instead of pixels.
// Everything it allocates comes from `allocator` and is released by
// text_device_destroy.
text_device_make :: proc(allocator := context.allocator) -> (^Text_Device, Error) {
td, err := new(Text_Device, allocator)
if err != nil || td == nil {
return nil, .Out_Of_Memory
}
td.allocator = allocator
gs, gerr := make([dynamic]Text_Glyph, 0, 256, allocator)
if gerr != nil {
free(td, allocator)
return nil, .Out_Of_Memory
}
td.glyphs = gs
td.dev.user = td
// Text sits on top of photographs and gradients constantly, and decoding
// either to extract it is pure waste. No outlines either: this device
// never draws a glyph, only places it -- which for a Type 3 font also
// spares the interpreter running one content stream per character. And no
// tiling: a pattern cell is replayed once per placement, so a watermark
// lattice with a word in it arrives once per tile.
//
// The transparency hooks are deliberately nil rather than suppressing
// what is drawn between them. Text with a gradient fill is drawn INTO a
// luminosity soft mask and the colour painted through it, so a device
// that ignored mask content would lose exactly that text.
td.dev.hints = {.Ignore_Image, .Ignore_Shade, .No_Glyph_Outlines, .No_Tiling}
td.dev.fill_text = text_dev_fill_text
td.dev.stroke_text = text_dev_stroke_text
td.dev.ignore_text = text_dev_ignore_text
td.dev.clip_text = text_dev_clip_text
return td, .None
}
text_device_destroy :: proc(td: ^Text_Device) {
if td == nil {
return
}
allocator := td.allocator
delete(td.glyphs)
td^ = {}
free(td, allocator)
}
// text_device_as_device hands the interpreter the vtable. The Device is
// embedded in the Text_Device, so the two share a lifetime.
text_device_as_device :: proc(td: ^Text_Device) -> ^Device {
if td == nil {
return nil
}
return &td.dev
}
@(private)
text_dev_self :: proc(dev: ^Device) -> ^Text_Device {
if dev == nil {
return nil
}
return cast(^Text_Device)dev.user
}
@(private)
text_dev_fill_text :: proc(dev: ^Device, text: ^Text, ctm: Matrix, paint: Paint) {
text_dev_collect(text_dev_self(dev), text)
}
@(private)
text_dev_stroke_text :: proc(dev: ^Device, text: ^Text, stroke: ^Stroke_State, ctm: Matrix, paint: Paint) {
text_dev_collect(text_dev_self(dev), text)
}
@(private)
text_dev_ignore_text :: proc(dev: ^Device, text: ^Text, ctm: Matrix) {
text_dev_collect(text_dev_self(dev), text)
}
// clip_text is implemented so that render mode 7 -- text used only as a clip,
// which is how some producers letter shapes over an image -- is still
// extracted. It pushes no clip: this device has no clip stack, and pop_clip
// stays nil.
@(private)
text_dev_clip_text :: proc(dev: ^Device, text: ^Text, ctm: Matrix) {
text_dev_collect(text_dev_self(dev), text)
}
// ---------------------------------------------------------------------------
// Collecting
// ---------------------------------------------------------------------------
@(private)
text_run_sig :: proc(text: ^Text) -> (sig: Text_Run_Sig) {
n := len(text.items)
sig.count = n
sig.font = text.font
if n > 0 {
sig.first = text.items[0].trm
sig.last = text.items[n - 1].trm
sig.r0 = text.items[0].unicode
sig.r1 = text.items[n - 1].unicode
}
return
}
@(private)
text_dev_collect :: proc(td: ^Text_Device, text: ^Text) {
if td == nil || text == nil || len(text.items) == 0 {
return
}
// The same run reaching us twice is the render mode drawing it twice, not
// the page saying it twice: identical glyphs at identical positions are
// one piece of text however many times they are inked.
sig := text_run_sig(text)
if td.have_sig && sig == td.sig {
return
}
td.have_sig = true
td.sig = sig
f := text.font
fm := font_matrix(f)
fm_inv, inv_ok := matrix_invert(fm)
if !inv_ok {
// A singular font matrix draws nothing, but the text is still there to
// be read; assume the 1/1000 glyph space everything but Type3 uses.
fm_inv = Matrix{1000, 0, 0, 1000, 0, 0}
}
vertical := text.wmode == 1
start := len(td.glyphs)
for &it in text.items {
if len(td.glyphs) >= MAX_TEXT_GLYPHS {
td.truncated = true
break
}
if !matrix_is_finite(it.trm) {
continue
}
t := matrix_mul(fm_inv, it.trm)
if !matrix_is_finite(t) {
continue
}
g: Text_Glyph
g.pen = Point{t.e, t.f}
hvec := Point{t.a, t.b}
vvec := Point{t.c, t.d}
hlen := text_len(hvec)
vlen := text_len(vvec)
if hlen < 1e-9 && vlen < 1e-9 {
// A matrix that collapses to a point places nothing anywhere.
continue
}
// One axis flattened still positions text, so the surviving axis
// stands in for the other: it gives the missing size, and turning it
// a quarter turn gives the missing direction. Upright text has the
// x axis to the right and the y axis up the page, which in a y-down
// device space is (1, 0) and (0, -1) -- that is the turn taken here.
hdir := hlen > 1e-9 ? Point{hvec.x / hlen, hvec.y / hlen} : Point{}
up := vlen > 1e-9 ? Point{vvec.x / vlen, vvec.y / vlen} : Point{}
if hlen < 1e-9 {
hdir = Point{-up.y, up.x}
hlen = vlen
} else if vlen < 1e-9 {
up = Point{hdir.y, -hdir.x}
vlen = hlen
}
if vertical {
// A vertical font writes down text space's -y, and its columns
// advance along -x (9.7.4.3).
g.dir = Point{-up.x, -up.y}
g.n = text_perp_towards(g.dir, Point{-hdir.x, -hdir.y})
g.em = vlen
} else {
g.dir = hdir
g.n = text_perp_towards(g.dir, Point{-up.x, -up.y})
g.em = hlen
}
g.up = up
g.size = vlen
g.font = f
g.r = text_rune_of(it.unicode)
g.text = text_glyph_text(f, &it)
adv := text_em_advance(f, &it, vertical) * g.em
g.end = Point{g.pen.x + g.dir.x * adv, g.pen.y + g.dir.y * adv}
if !is_finite(g.end.x) || !is_finite(g.end.y) {
// An advance that overflows says nothing about where the next
// glyph starts; a zero-width glyph is the honest reading.
g.end = g.pen
}
deg := math.atan2(g.dir.y, g.dir.x) * 180 / math.PI
if !is_finite(deg) {
continue
}
b := int(math.round(deg)) % 360
if b < 0 { b += 360 }
g.bucket = b
g.seq = td.seq
td.seq += 1
append(&td.glyphs, g)
}
text_run_track(td.glyphs[start:])
}
// text_run_track compensates for letter-spacing. Tc widens the gap after every
// glyph in a run by the same amount, which at 0.2 em and up would otherwise
// put a space between every pair of letters in a tracked heading. The smallest
// gap in the run is the one least likely to be a word break, so it is taken as
// the tracking and removed from all of them.
//
// The cap is what keeps this safe: a table row emitted as one TJ with huge
// gaps between the cells has a large "smallest gap", and without the cap the
// spaces between its cells would be compensated away.
@(private)
text_run_track :: proc(gs: []Text_Glyph) {
if len(gs) < TEXT_TRACK_PAIRS + 1 {
return
}
least := math.INF_F64
pairs := 0
for i in 0 ..< len(gs) - 1 {
a := &gs[i]
b := &gs[i + 1]
if a.em <= 0 || a.bucket != b.bucket {
continue
}
d := Point{b.pen.x - a.end.x, b.pen.y - a.end.y}
// Only pairs that sit on one baseline say anything about tracking.
if abs(d.x * a.n.x + d.y * a.n.y) > TEXT_LINE_EM * a.em {
continue
}
gap := (d.x * a.dir.x + d.y * a.dir.y) / a.em
least = min(least, gap)
pairs += 1
}
if pairs < TEXT_TRACK_PAIRS || !is_finite(least) || least < TEXT_TRACK_MIN_EM {
return
}
track := min(least, TEXT_TRACK_EM)
for &g in gs {
g.end.x += g.dir.x * track * g.em
g.end.y += g.dir.y * track * g.em
}
}
// text_em_advance is the glyph's advance in text space, where 1 is an em.
@(private)
text_em_advance :: proc(f: ^Font, it: ^Text_Item, vertical: bool) -> f64 {
if f == nil {
return 0
}
w: f64
if f.kind == .Type0 {
w = font_cid_width(f, it.cid)
if vertical {
w1y, _, _ := font_cid_vmetric(f, it.cid, w)
// The writing direction points along text space -y, so the
// displacement -- negative by convention -- is a positive distance
// along it.
return is_finite(w1y) ? text_clamp_em(-w1y / 1000) : 0
}
} else if it.code < FONT_CODES {
w = f.code_width[it.code]
}
if !is_finite(w) {
return 0
}
if f.kind == .Type3 {
// A Type3 width is in the font's own glyph space and only its
// /FontMatrix says how big that is (9.6.4).
v := transform_vector(font_matrix(f), Point{w, 0})
return is_finite(v.x) ? text_clamp_em(v.x) : 0
}
// Every other width in a PDF is thousandths of text space (9.6.2.2).
return text_clamp_em(w / 1000)
}
@(private)
text_clamp_em :: proc(x: f64) -> f64 {
return clamp(x, -MAX_TEXT_ADVANCE_EM, MAX_TEXT_ADVANCE_EM)
}
// text_rune_of maps what the font resolved to a character to put in the
// output. A glyph the font offers no Unicode for becomes U+FFFD: dropping it
// silently would join the words either side of it, and the caller cannot tell
// that happened.
@(private)
text_rune_of :: proc(r: rune) -> rune {
switch {
case r <= 0:
// font.odin uses -1 for "none"; 0 is not a character a glyph maps to.
return 0xFFFD
case r < 0x20:
// A control code is not a glyph. Producers emit them through a broken
// /ToUnicode; a space keeps the line structure this file imposes.
return ' '
case r > 0x10FFFF, r >= 0xD800 && r <= 0xDFFF:
return 0xFFFD
}
return r
}
// text_glyph_text is the several code points a glyph stands for, when it
// stands for more than one.
//
// Font_Code.unicode is a single rune -- the frozen shape of the struct -- so
// an "ffi" ligature arrives as 'f' and the word loses two letters. That is not
// font.odin being wrong: its own tests say the first character is what the
// field reports and "the whole string is cmap_lookup_text's to give". This is
// the caller asking for the rest of it.
//
// The result is only used when its first code point is the one font.odin
// chose. A disagreement means /ToUnicode was not the route it took -- a
// mapping it rejected as malformed, say -- and its answer wins.
@(private)
text_glyph_text :: proc(f: ^Font, it: ^Text_Item) -> string {
if f == nil || f.to_unicode == nil || it.unicode < 0 {
return ""
}
txt, ok := cmap_lookup_text(f.to_unicode, it.code)
if !ok || len(txt) < 2 || len(txt) > MAX_TEXT_UNICODE_BYTES {
return ""
}
r0, size := utf8.decode_rune_in_string(txt)
if r0 != it.unicode || size >= len(txt) {
return ""
}
return txt
}
@(private)
text_len :: proc(p: Point) -> f64 {
return math.sqrt(p.x * p.x + p.y * p.y)
}
// text_perp_towards is the unit normal of `dir`, turned to agree with `want`.
// Taking the sign from a vector out of the same matrix is what keeps mirrored
// text -- and a page whose base matrix flips y -- ordered the right way round.
@(private)
text_perp_towards :: proc(dir, want: Point) -> Point {
p := Point{-dir.y, dir.x}
if p.x * want.x + p.y * want.y < 0 {
return Point{-p.x, -p.y}
}
return p
}
// ---------------------------------------------------------------------------
// Layout: glyphs to lines to blocks
// ---------------------------------------------------------------------------
@(private)
Text_Layout_Line :: struct {
first, last: int, // half-open range of glyphs
u: f64, // baseline, in the bucket's frame
size: f64, // largest em on the line
dir, n: Point,
border: int,
block: int,
}
@(private)
Text_Layout :: struct {
glyphs: []Text_Glyph,
lines: [dynamic]Text_Layout_Line,
blocks: int,
allocator: mem.Allocator,
}
@(private)
text_layout_destroy :: proc(lay: ^Text_Layout) {
if lay == nil {
return
}
delete(lay.glyphs, lay.allocator)
delete(lay.lines)
lay^ = {}
}
@(private)
Text_Bucket_Rank :: struct {
seq, bucket: int,
}
@(private)
text_layout :: proc(td: ^Text_Device, allocator: mem.Allocator) -> (lay: Text_Layout) {
lay.allocator = allocator
if td == nil || len(td.glyphs) == 0 {
return
}
gs, err := make([]Text_Glyph, len(td.glyphs), allocator)
if err != nil {
return
}
copy(gs, td.glyphs[:])
lay.glyphs = gs
// One canonical frame per whole degree of writing angle. Projecting every
// glyph in a bucket on one frame -- rather than each on its own -- is what
// makes the baseline coordinate comparable: half a degree of disagreement
// is seven points of drift across a line of text, which is a whole line
// height of error.
first_seq: [360]int
dirs, norms: [360]Point
for i in 0 ..< 360 {
first_seq[i] = max(int)
}
for g in gs {
if g.seq < first_seq[g.bucket] {
first_seq[g.bucket] = g.seq
dirs[g.bucket] = g.dir
norms[g.bucket] = g.n
}
}
// Buckets are read in the order they were first drawn, so that a rotated
// caption on an upright page lands where the producer put it rather than
// at an angle-sorted position nobody chose.
ranks: [360]Text_Bucket_Rank
nranks := 0
for i in 0 ..< 360 {
if first_seq[i] != max(int) {
ranks[nranks] = {first_seq[i], i}
nranks += 1
}
}
slice.sort_by(ranks[:nranks], proc(a, b: Text_Bucket_Rank) -> bool {
return a.seq < b.seq
})
order: [360]int
for e, i in ranks[:nranks] {
order[e.bucket] = i
}
for &g in gs {
d := dirs[g.bucket]
n := norms[g.bucket]
g.u = g.pen.x * n.x + g.pen.y * n.y
g.v = g.pen.x * d.x + g.pen.y * d.y
g.border = order[g.bucket]
}
text_layout_rescale(gs)
slice.sort_by(gs, proc(a, b: Text_Glyph) -> bool {
if a.border != b.border { return a.border < b.border }
if a.u != b.u { return a.u < b.u }
if a.v != b.v { return a.v < b.v }
return a.seq < b.seq
})
lay.lines = make([dynamic]Text_Layout_Line, 0, 32, allocator)
i := 0
for i < len(gs) {
anchor := gs[i].u
prev := gs[i].u
size := gs[i].size
j := i + 1
for j < len(gs) {
g := gs[j]
if g.border != gs[i].border {
break
}
limit := max(size, g.size)
if g.u - prev > TEXT_LINE_EM * limit || g.u - anchor > TEXT_LINE_SPREAD_EM * limit {
break
}
prev = g.u
size = max(size, g.size)
j += 1
}
// Reading order along the baseline. The emission order is the
// tiebreak, so glyphs stacked at one point keep the order they were
// drawn in.
slice.sort_by(gs[i:j], proc(a, b: Text_Glyph) -> bool {
if a.v != b.v { return a.v < b.v }
return a.seq < b.seq
})
append(&lay.lines, Text_Layout_Line{
first = i,
last = j,
u = anchor,
size = size,
dir = dirs[gs[i].bucket],
n = norms[gs[i].bucket],
border = gs[i].border,
})
i = j
}
nblocks := 0
for &ln, k in lay.lines {
if k > 0 {
p := lay.lines[k - 1]
if ln.border != p.border || ln.u - p.u > TEXT_BLOCK_EM * max(ln.size, p.size) {
nblocks += 1
}
}
ln.block = nblocks
}
lay.blocks = len(lay.lines) > 0 ? nblocks + 1 : 0
return
}
// text_layout_rescale is a guard, not a feature. Text_Item.trm is contracted
// to map glyph space to device space, and this file divides the font matrix
// back out of it to recover text space. A producer of Text_Items that handed
// us the text-space matrix instead would leave every em a thousand times too
// large, and a thousand-point em turns a page of text into one unreadable
// line with no spaces in it.
//
// That is detectable without ambiguity: if every glyph on the page fits inside
// a small fraction of a single em, the matrices cannot mean what they claim.
// Only a page with enough glyphs to be sure is touched, and a page that really
// does stack all its text on one spot has nothing to lose either way.
@(private)
text_layout_rescale :: proc(gs: []Text_Glyph) {
if len(gs) < 16 {
return
}
lo := gs[0].pen
hi := gs[0].pen
em := 0.0
for g in gs {
lo.x = min(lo.x, g.pen.x); lo.y = min(lo.y, g.pen.y)
hi.x = max(hi.x, g.pen.x); hi.y = max(hi.y, g.pen.y)
em = max(em, g.em, g.size)
}
extent := text_len(Point{hi.x - lo.x, hi.y - lo.y})
if em <= 20 * extent {
return
}
for &g in gs {
s := matrix_expansion(font_matrix(g.font))
if !is_finite(s) || s <= 0 || s >= 1 {
continue
}
g.em *= s
g.size *= s
g.end.x = g.pen.x + (g.end.x - g.pen.x) * s
g.end.y = g.pen.y + (g.end.y - g.pen.y) * s
}
}
// ---------------------------------------------------------------------------
// Rendering the layout
// ---------------------------------------------------------------------------
@(private)
Text_Rect_Acc :: struct {
r: Rect,
have: bool,
}
@(private)
text_acc_point :: proc(acc: ^Text_Rect_Acc, p: Point) {
if !is_finite(p.x) || !is_finite(p.y) {
return
}
if !acc.have {
acc.r = Rect{p.x, p.y, p.x, p.y}
acc.have = true
return
}
acc.r.x0 = min(acc.r.x0, p.x)
acc.r.y0 = min(acc.r.y0, p.y)
acc.r.x1 = max(acc.r.x1, p.x)
acc.r.y1 = max(acc.r.y1, p.y)
}
@(private)
text_acc_rect :: proc(acc: ^Text_Rect_Acc, r: Rect, have: bool) {
if !have {
return
}
text_acc_point(acc, Point{r.x0, r.y0})
text_acc_point(acc, Point{r.x1, r.y1})
}
// text_glyph_box is the em box the glyph occupies, as a device-space
// rectangle. Real per-glyph bounding boxes would need the outlines this device
// asks the interpreter not to load, and a selection rectangle wants the line's
// full height anyway.
@(private)
text_glyph_box :: proc(g: ^Text_Glyph, acc: ^Text_Rect_Acc) {
a := g.up * (TEXT_ASCENT_EM * g.size)
d := g.up * (TEXT_DESCENT_EM * g.size)
text_acc_point(acc, g.pen + a)
text_acc_point(acc, g.pen - d)
text_acc_point(acc, g.end + a)
text_acc_point(acc, g.end - d)
}
// text_device_blocks returns the page's text as blocks of lines of spans.
//
// The blocks, the lines, the spans and every string in them are allocated with
// `allocator` and belong to the caller: release them with text_blocks_destroy.
// Calling this twice is fine; nothing is consumed.
text_device_blocks :: proc(td: ^Text_Device, allocator := context.allocator) -> []Text_Block {
if td == nil {
return nil
}
lay := text_layout(td, td.allocator)
defer text_layout_destroy(&lay)
if len(lay.lines) == 0 {
return nil
}
blocks := make([dynamic]Text_Block, 0, lay.blocks, allocator)
lines := make([dynamic]Text_Line, 0, 8, allocator)
spans := make([dynamic]Text_Span, 0, 4, allocator)
sb := strings.builder_make(td.allocator)
defer strings.builder_destroy(&sb)
block_acc: Text_Rect_Acc
line_acc: Text_Rect_Acc
span_acc: Text_Rect_Acc
cur_block := 0
for ln, li in lay.lines {
if li > 0 && ln.block != cur_block {
if len(lines) > 0 {
append(&blocks, Text_Block{lines = slice.clone(lines[:], allocator), rect = block_acc.r})
clear(&lines)
}
block_acc = {}
cur_block = ln.block
}
clear(&spans)
line_acc = {}
span_acc = {}
strings.builder_reset(&sb)
span_font: ^Font
span_size: f64
have_span := false
prev: ^Text_Glyph
for k in ln.first ..< ln.last {
g := &lay.glyphs[k]
if prev != nil && g.r == prev.r && g.text == prev.text &&
text_len(Point{g.pen.x - prev.pen.x, g.pen.y - prev.pen.y}) <= TEXT_DUP_EM * max(g.em, prev.em) {
// The same character re-inked in the same place: fake bold.
continue
}
space := false
if prev != nil {
d := Point{g.pen.x - prev.end.x, g.pen.y - prev.end.y}
gap := d.x * ln.dir.x + d.y * ln.dir.y
space = gap > TEXT_SPACE_EM * max(g.em, prev.em)
}
// A space is only ever a separator, so it never opens a line and
// never doubles one that is already there.
if space && (!have_span || text_builder_ends_space(&sb)) {
space = false
}
if have_span && (g.font != span_font || abs(g.size - span_size) > 0.01 * max(g.size, span_size)) {
if space {
strings.write_byte(&sb, ' ')
space = false
}
text_flush_span(&spans, &sb, span_font, span_size, &span_acc, allocator)
text_acc_rect(&line_acc, span_acc.r, span_acc.have)
span_acc = {}
have_span = false
}
if !have_span {
span_font = g.font
span_size = g.size
have_span = true
}
if space {
strings.write_byte(&sb, ' ')
}
if g.r == ' ' && len(g.text) == 0 && strings.builder_len(sb) == 0 && len(spans) == 0 {
// Leading space glyphs carry nothing the rectangle does not.
continue
}
if len(g.text) > 0 {
for r in g.text {
strings.write_rune(&sb, text_rune_of(r))
}
} else {
strings.write_rune(&sb, g.r)
}
text_glyph_box(g, &span_acc)
prev = g
}
// Trailing spaces are producer noise, not content.
text_builder_trim_right(&sb)
if have_span && strings.builder_len(sb) > 0 {
text_flush_span(&spans, &sb, span_font, span_size, &span_acc, allocator)
text_acc_rect(&line_acc, span_acc.r, span_acc.have)
}
if len(spans) == 0 {
continue
}
append(&lines, Text_Line{spans = slice.clone(spans[:], allocator), rect = line_acc.r})
text_acc_rect(&block_acc, line_acc.r, line_acc.have)
}
if len(lines) > 0 {
append(&blocks, Text_Block{lines = slice.clone(lines[:], allocator), rect = block_acc.r})
}
delete(lines)
delete(spans)
return blocks[:]
}
@(private)
text_flush_span :: proc(spans: ^[dynamic]Text_Span, sb: ^strings.Builder, font: ^Font, size: f64,
acc: ^Text_Rect_Acc, allocator: mem.Allocator) {
if strings.builder_len(sb^) == 0 {
return
}
append(spans, Text_Span{
text = strings.clone(strings.to_string(sb^), allocator),
rect = acc.r,
font = font,
size = size,
})
strings.builder_reset(sb)
}
@(private)
text_builder_ends_space :: proc(sb: ^strings.Builder) -> bool {
n := len(sb.buf)
return n > 0 && sb.buf[n - 1] == ' '
}
@(private)
text_builder_trim_right :: proc(sb: ^strings.Builder) {
for len(sb.buf) > 0 && sb.buf[len(sb.buf) - 1] == ' ' {
pop(&sb.buf)
}
}
// text_blocks_destroy releases what text_device_blocks returned. `allocator`
// must be the one it was given.
text_blocks_destroy :: proc(blocks: []Text_Block, allocator := context.allocator) {
for b in blocks {
for l in b.lines {
for s in l.spans {
delete(s.text, allocator)
}
delete(l.spans, allocator)
}
delete(b.lines, allocator)
}
delete(blocks, allocator)
}
// text_device_result is the page's text as one string: spans joined with
// nothing, lines with a newline, blocks with a blank line. There is no
// trailing newline, so a caller stitching pages together decides its own
// separator.
//
// The string is allocated with `allocator` and belongs to the caller; release
// it with `delete(s, allocator)`. It is built from exactly the same layout
// text_device_blocks returns, so the two can never disagree.
text_device_result :: proc(td: ^Text_Device, allocator := context.allocator) -> string {
b := strings.builder_make(allocator)
if td == nil {
return strings.to_string(b)
}
blocks := text_device_blocks(td, td.allocator)
defer text_blocks_destroy(blocks, td.allocator)
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)
}
}
}
return strings.to_string(b)
}