pdfrum_text/charinfo.rs
1//! One extracted character, and the loose box derived from it.
2
3use kurbo::{Affine, Point, Rect};
4
5/// Where a character came from, which decides how the rest of the pipeline
6/// treats it.
7///
8/// The distinction that matters most: a space **written in the content
9/// stream** is [`Normal`](Self::Normal), not [`Generated`](Self::Generated).
10/// "Generated" means the extractor invented the character because the
11/// geometry implied one — an inter-word gap, an inter-object gap, or a line
12/// break's `\r\n`.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum CharType {
15 /// A character the font decoded from the content stream.
16 Normal,
17 /// A space or line break the extractor invented from the geometry.
18 Generated,
19 /// A character code the font could not map to Unicode; its `unicode` is
20 /// the raw character code.
21 NotUnicode,
22 /// The soft hyphen at a line break.
23 ///
24 /// Its `unicode` is forced to `0x0002`, while
25 /// [`TextPage::search_text`](crate::TextPage::search_text) carries
26 /// `U+00AD` at the same position — the one place the character stream and
27 /// the search-facing text provably disagree.
28 Hyphen,
29 /// One piece of a character that normalization split into several.
30 Piece,
31 /// A character synthesized from a marked-content `/ActualText` string
32 /// rather than from any glyph.
33 ActualText,
34}
35
36/// One extracted character, with its metrics and page geometry.
37///
38/// `unicode` is a `u32`, not a `char`: the character-code passthrough
39/// ([`CharType::NotUnicode`]) emits raw codes that need not be Unicode scalar
40/// values, and `0` is a legal and observed value.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct CharBox {
43 /// Where this character came from.
44 pub char_type: CharType,
45 /// The `--txt` code unit.
46 pub unicode: u32,
47 /// The PDF character code, or `None` where the C++ uses its invalid-code
48 /// sentinel — which is every generated and `/ActualText` character.
49 pub code: Option<pdfrum_font::CharCode>,
50 /// The baseline origin, already transformed into page space.
51 pub origin: Point,
52 /// The tight glyph box in page space. **Zero-area for a generated
53 /// character**, by construction.
54 pub char_box: Rect,
55 /// The font-uniform box: the glyph's advance width by the *font's*
56 /// ascent and descent, so two characters of one font share a box height
57 /// whatever their glyphs do. Always contains [`char_box`](Self::char_box).
58 pub loose_char_box: Rect,
59 /// The composed text × form matrix for a real character; the bare form
60 /// matrix — usually the identity — for a generated one.
61 pub matrix: Affine,
62 /// Which of [`Page::objects`](pdfrum_page::Page::objects) produced it, in
63 /// content order counting into form `XObject`s. `None` for a character no
64 /// text object produced.
65 pub object: Option<ObjectIndex>,
66 /// The font size in force, or `1.0` for a character with no text object —
67 /// which is every generated one.
68 pub font_size: f32,
69 /// `atan2(matrix.c, matrix.a)` normalized to `[0, 2π)`.
70 pub angle: f32,
71}
72
73/// A text object's position in the page's flattened object walk.
74///
75/// Two characters share a text object exactly when their indices are equal.
76/// Objects inside form `XObject`s are numbered in the order the walk reaches
77/// them, so the index is unique across the whole page.
78// An index rather than a pointer, the cross-reference from
79// a character back to the object that drew it is data, not a back-edge.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
81pub struct ObjectIndex(pub u32);
82
83impl CharBox {
84 /// Whether this character is one
85 /// [`search_text`](crate::TextPage::search_text) keeps.
86 ///
87 /// A character with a unicode is normal unless it is one of the eight
88 /// control code points; a character *without* one is normal exactly when
89 /// its character code is non-zero. So the charcode-0 passthrough — which
90 /// carries `unicode == 0` — is **not** normal: it lands in
91 /// [`chars`](crate::TextPage::chars) as a NUL and never reaches the text.
92 #[must_use]
93 pub fn is_normal(&self) -> bool {
94 if self.unicode == 0 {
95 return self.code.is_some_and(|code| code.0 != 0);
96 }
97 !self.is_control()
98 }
99
100 /// Whether this is one of the control code points
101 /// [`search_text`](crate::TextPage::search_text) drops.
102 ///
103 /// A [`CharType::Hyphen`] is **exempt**, which is why its `0x0002`
104 /// survives into [`chars`](crate::TextPage::chars).
105 // The `0x93..=0x98` band is the Windows-1252 smart-quote and dash range
106 // read as raw code points — a historical artifact, kept verbatim.
107 #[must_use]
108 pub fn is_control(&self) -> bool {
109 matches!(
110 self.unicode,
111 0x02 | 0x03 | 0x93 | 0x94 | 0x96 | 0x97 | 0x98 | 0xFFFE
112 ) && self.char_type != CharType::Hyphen
113 }
114
115 /// Whether the extractor invented this character.
116 #[must_use]
117 pub fn is_generated(&self) -> bool {
118 self.char_type == CharType::Generated
119 }
120}
121
122/// The angle a matrix draws text at, in `[0, 2π)`.
123#[must_use]
124pub fn matrix_angle(matrix: Affine) -> f32 {
125 let [a, _, c, ..] = matrix.as_coeffs();
126 // `atan2(c, a)`, where `c` is the coefficient carrying y into x. The two
127 // matrix conventions agree on `a` and disagree on which of the two
128 // off-diagonal terms is called `b`: the C++'s row-major `|a b; c d|` puts
129 // its `c` where kurbo's column-major `[a, b, c, d]` puts *its* `c`. Using
130 // the other one reflects every angle about the x axis, which shows up as
131 // a first quadrant reading as the fourth.
132 let angle = c.atan2(a);
133 let angle = if angle < 0.0 {
134 angle + std::f64::consts::TAU
135 } else {
136 angle
137 };
138 #[expect(
139 clippy::cast_possible_truncation,
140 reason = "an angle in [0, 2pi) is exactly representable enough in f32"
141 )]
142 let narrowed = angle as f32;
143 narrowed
144}
145
146/// Everything [`loose_bounds`] needs about one character.
147#[derive(Debug)]
148pub struct LooseBoundsInput<'a> {
149 /// The character's tight box in page space.
150 pub char_box: Rect,
151 /// Its baseline origin in page space.
152 pub origin: Point,
153 /// Its composed matrix.
154 pub matrix: Affine,
155 /// Its character code, or `None` for a generated character.
156 pub code: Option<pdfrum_font::CharCode>,
157 /// The font that drew it, or `None` for a generated character.
158 pub font: Option<&'a pdfrum_font::Font>,
159 /// The font size in force.
160 pub font_size: f32,
161 /// The advance width already scaled by `font_size / 1000`.
162 pub scaled_width: f32,
163}
164
165/// Whether a float is inside the C++'s float-zero band, `(-1e-4, 1e-4)`.
166#[must_use]
167pub(crate) fn is_float_zero(value: f32) -> bool {
168 value > -0.0001 && value < 0.0001
169}
170
171/// A rectangle is empty when it has no positive area in either direction,
172/// tested on the corner pair as given rather than on a normalized rect.
173fn is_empty(rect: Rect) -> bool {
174 rect.x1 <= rect.x0 || rect.y1 <= rect.y0
175}
176
177/// The union of two rectangles, taken as unnormalized corner pairs.
178fn union(a: Rect, b: Rect) -> Rect {
179 Rect::new(
180 a.x0.min(b.x0),
181 a.y0.min(b.y0),
182 a.x1.max(b.x1),
183 a.y1.max(b.y1),
184 )
185}
186
187/// The inverse of a matrix, or the **zero matrix** when it is singular —
188/// which maps every point to the origin. Degenerate, but never a panic.
189#[must_use]
190pub fn inverse_or_zero(matrix: Affine) -> Affine {
191 let [a, b, c, d, ..] = matrix.as_coeffs();
192 if a * d - b * c == 0.0 {
193 Affine::new([0.0; 6])
194 } else {
195 matrix.inverse()
196 }
197}
198
199/// The font-uniform box around a character (`GetLooseBounds`).
200///
201/// Four shapes come out of this, in the order they are tried:
202///
203/// 1. A character whose tight box is empty — every generated one — keeps that
204/// empty box, so a generated character's loose box is also zero-area.
205/// 2. A vertical CID character gets a box a full font size wide, positioned
206/// from the font's vertical origin and as tall as its vertical advance.
207/// 3. Anything else with a usable ascent and descent gets the glyph's advance
208/// width by the *font's* ascent and descent, computed in the character's
209/// own space and transformed back — which is what makes the box
210/// font-uniform, and what transposes it under a quarter turn.
211/// 4. Failing all of that, the tight box again.
212///
213/// Both computed shapes finish by unioning the tight box in, so the loose box
214/// always contains it.
215#[must_use]
216pub fn loose_bounds(input: &LooseBoundsInput<'_>) -> Rect {
217 let tight = input.char_box;
218 if is_empty(tight) {
219 return tight;
220 }
221 let (Some(font), Some(code)) = (input.font, input.code) else {
222 return tight;
223 };
224 let font_size = f64::from(input.font_size);
225 if is_float_zero(input.font_size) {
226 return tight;
227 }
228
229 let vertical = font.is_vertical();
230 if vertical && let (Some((vx, vy)), Some(vw)) = (font.vert_origin(code), font.vert_width(code))
231 {
232 // A vertical CID glyph hangs from its own origin: the box is one font
233 // size wide and as tall as the (negative) vertical advance.
234 let offset_x = (f64::from(vx) - 500.0) * font_size / 1000.0;
235 let offset_y = f64::from(vy) * font_size / 1000.0;
236 let height = f64::from(vw) * font_size / 1000.0;
237 let left = input.origin.x + offset_x;
238 let top = input.origin.y + offset_y;
239 let box_rect = Rect::new(left, top + height, left + font_size, top);
240 return union(box_rect, tight);
241 }
242
243 let mut ascent = font.type_ascent();
244 let mut descent = font.type_descent();
245 let bbox = font.font_bbox();
246 // `FX_RECT` is y-down but holds y-up glyph values here, so "top greater
247 // than bottom" is the *ordinary* case and the clamp only fires then.
248 #[expect(
249 clippy::cast_possible_truncation,
250 reason = "font bbox metrics are integral 1000/em values"
251 )]
252 if bbox.y1 > bbox.y0 {
253 ascent = ascent.min(bbox.y1 as i32);
254 descent = descent.max(bbox.y0 as i32);
255 }
256 if ascent == descent {
257 return tight;
258 }
259
260 let width = f64::from(input.scaled_width);
261 let inverse = inverse_or_zero(input.matrix);
262 let origin = inverse * input.origin;
263 let right = origin.x + if vertical { -width } else { width };
264 let bottom = origin.y + f64::from(descent) * font_size / 1000.0;
265 let top = origin.y + f64::from(ascent) * font_size / 1000.0;
266 // The transform is an axis-aligned bound of the rotated rectangle, which
267 // is why a quarter turn swaps the loose box's width and height.
268 let box_rect = transform_rect(input.matrix, Rect::new(origin.x, bottom, right, top));
269 union(box_rect, tight)
270}
271
272/// The axis-aligned bound of a transformed rectangle, taking the corners as
273/// an unnormalized pair.
274#[must_use]
275pub fn transform_rect(matrix: Affine, rect: Rect) -> Rect {
276 let corners = [
277 matrix * Point::new(rect.x0, rect.y0),
278 matrix * Point::new(rect.x1, rect.y0),
279 matrix * Point::new(rect.x0, rect.y1),
280 matrix * Point::new(rect.x1, rect.y1),
281 ];
282 let xs = corners.map(|p| p.x);
283 let ys = corners.map(|p| p.y);
284 Rect::new(
285 xs.iter().copied().fold(f64::INFINITY, f64::min),
286 ys.iter().copied().fold(f64::INFINITY, f64::min),
287 xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
288 ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
289 )
290}
291
292/// The distance a matrix scales a length by: the mean of its two axis
293/// scales.
294#[must_use]
295pub fn transform_distance(matrix: Affine, distance: f64) -> f64 {
296 let [a, b, c, d, ..] = matrix.as_coeffs();
297 let x_unit = (a * a + b * b).sqrt();
298 let y_unit = (c * c + d * d).sqrt();
299 distance * (x_unit + y_unit) / 2.0
300}
301
302#[cfg(test)]
303mod tests {
304 // Test fixtures quote the oracle's own vectors, compare floats exactly
305 // where the behaviour being pinned is exact, and index arrays whose
306 // length the fixture itself fixes.
307 #![allow(
308 clippy::float_cmp,
309 clippy::indexing_slicing,
310 clippy::unreadable_literal,
311 clippy::cast_precision_loss,
312 clippy::cast_possible_truncation,
313 reason = "test fixtures quote oracle vectors verbatim and compare exactly"
314 )]
315
316 use super::*;
317 use pdfrum_font::CharCode;
318
319 fn char_box(char_type: CharType, unicode: u32, code: Option<u32>) -> CharBox {
320 CharBox {
321 char_type,
322 unicode,
323 code: code.map(CharCode),
324 origin: Point::ZERO,
325 char_box: Rect::ZERO,
326 loose_char_box: Rect::ZERO,
327 matrix: Affine::IDENTITY,
328 object: None,
329 font_size: 1.0,
330 angle: 0.0,
331 }
332 }
333
334 #[test]
335 fn control_characters_are_the_eight_the_cpp_lists() {
336 for unicode in [0x02, 0x03, 0x93, 0x94, 0x96, 0x97, 0x98, 0xFFFE] {
337 assert!(char_box(CharType::Normal, unicode, Some(1)).is_control());
338 }
339 for unicode in [0x01, 0x04, 0x92, 0x95, 0x99, 0x20, 0x41, 0xFFFD] {
340 assert!(!char_box(CharType::Normal, unicode, Some(1)).is_control());
341 }
342 }
343
344 #[test]
345 fn a_hyphen_is_exempt_from_the_control_test() {
346 // Its unicode is 0x2, which would otherwise make it a control char --
347 // and that exemption is what carries 0x2 into the character list.
348 let hyphen = char_box(CharType::Hyphen, 0x02, None);
349 assert!(!hyphen.is_control());
350 assert!(hyphen.is_normal());
351 }
352
353 #[test]
354 fn normality_falls_back_to_the_char_code_when_there_is_no_unicode() {
355 // The charcode-0 passthrough: unicode 0 and code 0, so not normal.
356 assert!(!char_box(CharType::Normal, 0, Some(0)).is_normal());
357 // Unicode 0 with a real code is normal.
358 assert!(char_box(CharType::Normal, 0, Some(7)).is_normal());
359 // No code at all is not normal either.
360 assert!(!char_box(CharType::Generated, 0, None).is_normal());
361 // A control character is not normal.
362 assert!(!char_box(CharType::Normal, 0x03, Some(3)).is_normal());
363 }
364
365 #[test]
366 fn a_singular_matrix_inverts_to_the_zero_matrix() {
367 let singular = Affine::new([1.0, 2.0, 2.0, 4.0, 5.0, 6.0]);
368 let inverse = inverse_or_zero(singular);
369 assert_eq!(inverse.as_coeffs(), [0.0; 6]);
370 // Which maps every point to the origin rather than panicking.
371 assert_eq!(inverse * Point::new(100.0, 200.0), Point::ZERO);
372 // A well-formed matrix inverts normally.
373 let scale = Affine::scale(2.0);
374 assert_eq!(
375 inverse_or_zero(scale) * Point::new(4.0, 6.0),
376 Point::new(2.0, 3.0)
377 );
378 }
379
380 #[test]
381 fn the_angle_is_read_from_the_y_into_x_coefficient() {
382 use std::f64::consts::{FRAC_PI_2, PI, TAU};
383 assert!((matrix_angle(Affine::IDENTITY) - 0.0).abs() < 1e-6);
384 // The coefficient the angle comes from is `c`, the one carrying y
385 // into x. A matrix with `c` positive and `a` zero is a quarter turn.
386 let quarter = matrix_angle(Affine::new([0.0, 0.0, 1.0, 0.0, 0.0, 0.0]));
387 assert!((f64::from(quarter) - FRAC_PI_2).abs() < 1e-5, "{quarter}");
388 // Negative `a` alone is a half turn.
389 let half = matrix_angle(Affine::new([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
390 assert!((f64::from(half) - PI).abs() < 1e-5, "{half}");
391 // And a negative result wraps forward into the last quadrant rather
392 // than staying negative.
393 let three_quarter = matrix_angle(Affine::new([0.0, 0.0, -1.0, 0.0, 0.0, 0.0]));
394 assert!(
395 (f64::from(three_quarter) - 3.0 * FRAC_PI_2).abs() < 1e-5,
396 "{three_quarter}"
397 );
398 assert!(f64::from(three_quarter) < TAU);
399 }
400
401 #[test]
402 fn a_generated_characters_loose_box_is_its_empty_tight_box() {
403 // The first line of GetLooseBounds: an empty tight box comes back
404 // untouched, so a generated character is 0x0 both ways.
405 let input = LooseBoundsInput {
406 char_box: Rect::new(50.0, 100.0, 50.0, 100.0),
407 origin: Point::new(50.0, 100.0),
408 matrix: Affine::IDENTITY,
409 code: None,
410 font: None,
411 font_size: 1.0,
412 scaled_width: 0.0,
413 };
414 let loose = loose_bounds(&input);
415 assert_eq!(loose, Rect::new(50.0, 100.0, 50.0, 100.0));
416 assert_eq!(loose.width(), 0.0);
417 assert_eq!(loose.height(), 0.0);
418 }
419
420 #[test]
421 fn transform_distance_averages_the_two_axis_scales() {
422 // A pure scale by 2 scales a distance by 2.
423 assert_eq!(transform_distance(Affine::scale(2.0), 10.0), 20.0);
424 // Anisotropic scaling averages: (3 + 1) / 2 = 2.
425 let skewed = Affine::new([3.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
426 assert_eq!(transform_distance(skewed, 10.0), 20.0);
427 }
428
429 #[test]
430 fn transforming_a_rect_bounds_the_rotated_corners() {
431 let rect = Rect::new(0.0, 0.0, 2.0, 1.0);
432 let rotated = transform_rect(Affine::rotate(std::f64::consts::FRAC_PI_2), rect);
433 // A quarter turn swaps the extents.
434 assert!((rotated.width() - 1.0).abs() < 1e-9);
435 assert!((rotated.height() - 2.0).abs() < 1e-9);
436 }
437}