Skip to main content

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}