Skip to main content

pdfrum_page/state/
text.rs

1//! Text state and the two matrices that track position
2//! (ISO 32000-1 §9.3, §9.4.2).
3//!
4//! Two positions are kept, not one: `pos` is where the next glyph goes, and
5//! `line_pos` is where the current line began. `Td` moves the *line* and then
6//! syncs the position to it, so a sequence of `Td`s accumulates rather than
7//! replacing.
8//!
9//! Three details are easy to get wrong and are all reproduced:
10//!
11//! - **`Tz` stores a fraction, not a percentage.** `150 Tz` becomes `1.5`.
12//! - **`TD` sets the leading to the *negated* y offset**, so `0 -14 TD` gives
13//!   a leading of 14.
14//! - **The rise is applied inside the text matrix**, not after it, so a
15//!   rotated text matrix rotates the rise too.
16
17use crate::ops::TextRenderMode;
18use kurbo::{Affine, Point};
19use pdfrum_font::Font;
20use std::sync::Arc;
21
22/// The text-showing parameters (ISO 32000-1 table 105).
23///
24/// Equality compares the font by its identity rather than its contents: a
25/// [`Font`] is a large loaded object with no meaningful structural equality,
26/// and two states holding the same `Arc` are the same state.
27#[derive(Debug, Clone)]
28pub struct TextState {
29    /// The font and its size. `Tf` always sets the size, and sets the font
30    /// only when the name resolved — so a bad `/Font` name changes the size
31    /// and leaves the font standing.
32    pub font: Option<(Arc<Font>, f32)>,
33    /// The `/Font` resource entry the current font came from, so a
34    /// regenerated content stream can name the same object. `None` when the
35    /// resource was written inline, and therefore has no object to name.
36    pub font_source: Option<pdfrum_object::ObjRef>,
37    /// `Tc`, added after each glyph.
38    pub char_space: f32,
39    /// `Tw`, added after each single-byte space.
40    pub word_space: f32,
41    /// `Tz` as a fraction: `150 Tz` is `1.5`.
42    pub horz_scale: f32,
43    /// `TL`, the distance between baselines.
44    pub leading: f32,
45    /// `Ts`, the baseline offset.
46    pub rise: f32,
47    /// `Tr`.
48    pub render_mode: TextRenderMode,
49    /// The 2×2 linear part of the CTM at the `Tj`, stored as `[a, c, b, d]`.
50    ///
51    /// Set only when [`Self::render_mode`] strokes (modes 1, 2, 5, 6); a fill
52    /// keeps the identity `[1, 0, 0, 1]`. The transposition is the oracle's
53    /// four-float slot (`text_ctm[1] = ctm.c`, `text_ctm[2] = ctm.b`): a
54    /// stroke's width is measured in user space (ISO 32000-1 §8.4.3.2), so a
55    /// scaling CTM has to be folded into the device matrix rather than left
56    /// on the text matrix, and this is the matrix that split consumes.
57    pub stroke_ctm: [f32; 4],
58}
59
60impl PartialEq for TextState {
61    fn eq(&self, other: &Self) -> bool {
62        let same_font = match (&self.font, &other.font) {
63            (Some((a, sa)), Some((b, sb))) => a.id() == b.id() && sa == sb,
64            (None, None) => true,
65            _ => false,
66        };
67        same_font
68            && self.char_space == other.char_space
69            && self.word_space == other.word_space
70            && self.horz_scale == other.horz_scale
71            && self.leading == other.leading
72            && self.rise == other.rise
73            && self.render_mode == other.render_mode
74            && self.stroke_ctm == other.stroke_ctm
75    }
76}
77
78impl Default for TextState {
79    fn default() -> Self {
80        Self {
81            font: None,
82            font_source: None,
83            char_space: 0.0,
84            word_space: 0.0,
85            horz_scale: 1.0,
86            leading: 0.0,
87            rise: 0.0,
88            render_mode: TextRenderMode::Fill,
89            stroke_ctm: [1.0, 0.0, 0.0, 1.0],
90        }
91    }
92}
93
94/// Where the next glyph goes, and where its line began.
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub struct TextCursor {
97    /// The text matrix, set wholesale by `Tm` and reset by `BT`.
98    pub matrix: Affine,
99    /// Where the next glyph goes, in text space.
100    pub pos: Point,
101    /// Where the current line began.
102    pub line_pos: Point,
103}
104
105impl Default for TextCursor {
106    fn default() -> Self {
107        Self {
108            matrix: Affine::IDENTITY,
109            pos: Point::ZERO,
110            line_pos: Point::ZERO,
111        }
112    }
113}
114
115impl TextCursor {
116    /// `BT` and `Tm`: reset both positions to the origin of the new matrix.
117    pub fn set_matrix(&mut self, matrix: Affine) {
118        self.matrix = matrix;
119        self.pos = Point::ZERO;
120        self.line_pos = Point::ZERO;
121    }
122
123    /// `Td`: move the line start by `(tx, ty)` and put the position there.
124    ///
125    /// The offset **accumulates** onto the previous line start, which is why
126    /// two `Td`s in a row move twice.
127    pub fn move_line(&mut self, tx: f64, ty: f64) {
128        self.line_pos.x += tx;
129        self.line_pos.y += ty;
130        self.pos = self.line_pos;
131    }
132
133    /// `T*`: down one leading, back to the line start.
134    pub fn next_line(&mut self, leading: f64) {
135        self.line_pos.y -= leading;
136        self.pos = self.line_pos;
137    }
138
139    /// The device-space position of the next glyph.
140    ///
141    /// The rise is applied **inside** the text matrix, before the CTM, so a
142    /// rotated text matrix rotates it.
143    #[must_use]
144    pub fn device_position(&self, rise: f32, ctm: Affine) -> Point {
145        let in_text = Point::new(self.pos.x, self.pos.y + f64::from(rise));
146        ctm * (self.matrix * in_text)
147    }
148
149    /// Advance the position after showing a run.
150    ///
151    /// Vertical writing moves y and leaves x alone; horizontal writing does
152    /// the opposite and scales by the horizontal scale.
153    pub fn advance(&mut self, amount: f64, vertical: bool) {
154        if vertical {
155            self.pos.y += amount;
156        } else {
157            self.pos.x += amount;
158        }
159    }
160}
161
162/// The displacement a `TJ` adjustment produces, in text space.
163///
164/// `k * size / 1000`, times the horizontal scale for horizontal writing.
165#[must_use]
166pub fn kerning_shift(kerning: f32, font_size: f32, horz_scale: f32, vertical: bool) -> f64 {
167    let base = f64::from(kerning) * f64::from(font_size) / 1000.0;
168    if vertical {
169        base
170    } else {
171        base * f64::from(horz_scale)
172    }
173}
174
175/// The matrix a glyph is drawn with, without its translation.
176///
177/// `[horz_scale 0; 0 1; 0 0] × text_matrix × ctm` in PDF order, which in
178/// kurbo's operand order is `ctm * text_matrix * scale`.
179#[must_use]
180pub fn glyph_matrix(horz_scale: f32, text_matrix: Affine, ctm: Affine) -> Affine {
181    let scale = Affine::new([f64::from(horz_scale), 0.0, 0.0, 1.0, 0.0, 0.0]);
182    ctm * text_matrix * scale
183}
184
185#[cfg(test)]
186mod tests {
187    // Test fixtures quote the oracle's own vectors, compare floats exactly
188    // where the behaviour being pinned is exact, and index arrays whose
189    // length the fixture itself fixes.
190    #![allow(
191        clippy::unreadable_literal,
192        clippy::float_cmp,
193        clippy::indexing_slicing,
194        clippy::cast_precision_loss,
195        clippy::cast_possible_truncation,
196        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
197    )]
198
199    use super::{TextCursor, TextState, glyph_matrix, kerning_shift};
200    use crate::ops::TextRenderMode;
201    use kurbo::{Affine, Point};
202
203    #[test]
204    fn the_default_horizontal_scale_is_one_not_a_hundred() {
205        let s = TextState::default();
206        assert!((s.horz_scale - 1.0).abs() < 1e-6);
207        assert_eq!(s.render_mode, TextRenderMode::Fill);
208        assert_eq!(s.stroke_ctm, [1.0, 0.0, 0.0, 1.0]);
209        assert!(s.font.is_none());
210    }
211
212    #[test]
213    fn two_states_that_differ_only_in_stroke_ctm_are_unequal() {
214        let a = TextState::default();
215        let b = TextState {
216            stroke_ctm: [2.0, 0.0, 0.0, 3.0],
217            ..TextState::default()
218        };
219        assert_ne!(a, b);
220    }
221
222    #[test]
223    fn td_accumulates_onto_the_line_start() {
224        let mut c = TextCursor::default();
225        c.move_line(10.0, -14.0);
226        assert_eq!(c.pos, Point::new(10.0, -14.0));
227        assert_eq!(c.line_pos, Point::new(10.0, -14.0));
228        // A second `Td` moves again rather than replacing.
229        c.move_line(5.0, -14.0);
230        assert_eq!(c.pos, Point::new(15.0, -28.0));
231    }
232
233    #[test]
234    fn t_star_returns_to_the_line_start_one_leading_down() {
235        let mut c = TextCursor::default();
236        c.move_line(10.0, 0.0);
237        // Advance within the line, then break.
238        c.advance(50.0, false);
239        assert_eq!(c.pos.x, 60.0);
240        c.next_line(14.0);
241        assert_eq!(c.pos, Point::new(10.0, -14.0));
242    }
243
244    #[test]
245    fn setting_the_matrix_resets_both_positions() {
246        let mut c = TextCursor::default();
247        c.move_line(10.0, 20.0);
248        c.set_matrix(Affine::translate((100.0, 200.0)));
249        assert_eq!(c.pos, Point::ZERO);
250        assert_eq!(c.line_pos, Point::ZERO);
251    }
252
253    #[test]
254    fn the_rise_goes_through_the_text_matrix() {
255        let c = TextCursor {
256            // A quarter turn, so a rise along +y comes out along −x.
257            matrix: Affine::rotate(std::f64::consts::FRAC_PI_2),
258            ..TextCursor::default()
259        };
260        let p = c.device_position(10.0, Affine::IDENTITY);
261        assert!(p.x.abs() > 9.0, "the rise should have been rotated: {p:?}");
262        assert!(p.y.abs() < 1e-6);
263
264        // Applying it after the matrix would have moved y instead.
265        let flat = TextCursor::default().device_position(10.0, Affine::IDENTITY);
266        assert!((flat.y - 10.0).abs() < 1e-6);
267    }
268
269    #[test]
270    fn kerning_scales_by_size_and_by_the_horizontal_scale() {
271        // 1000 units of kerning at size 12 is one full em.
272        assert!((kerning_shift(1000.0, 12.0, 1.0, false) - 12.0).abs() < 1e-6);
273        assert!((kerning_shift(1000.0, 12.0, 2.0, false) - 24.0).abs() < 1e-6);
274        // Vertical writing ignores the horizontal scale.
275        assert!((kerning_shift(1000.0, 12.0, 2.0, true) - 12.0).abs() < 1e-6);
276    }
277
278    #[test]
279    fn the_glyph_matrix_applies_the_horizontal_scale_first() {
280        let m = glyph_matrix(2.0, Affine::IDENTITY, Affine::IDENTITY);
281        // A unit x step comes out twice as long.
282        let p = m * Point::new(1.0, 1.0);
283        assert!((p.x - 2.0).abs() < 1e-6);
284        assert!((p.y - 1.0).abs() < 1e-6);
285    }
286
287    #[test]
288    fn vertical_writing_advances_y() {
289        let mut c = TextCursor::default();
290        c.advance(20.0, true);
291        assert_eq!(c.pos, Point::new(0.0, 20.0));
292    }
293}