Skip to main content

pdf_inspector/extractor/
geometry.rs

1//! Device-space geometry of shown text runs.
2//!
3//! Shared by the page content-stream parser and the Form XObject parser so
4//! both stamp identical boxes and baseline angles on their `TextItem`s.
5
6/// Which way a page's coordinate frame was turned so that predominantly
7/// rotated text reads along +x.
8///
9/// A page whose text mostly runs vertically is re-based so its dominant runs
10/// read left-to-right: the items' `x`/`y`/`width`/`height` and `rotation`
11/// are then expressed in the turned frame, not in page coordinates. Region
12/// boxes given in page coordinates must be turned the same way —
13/// `collect_text_in_region_in_frame` does that, and
14/// `extract_text_with_positions_and_rotations_mem` reports each page's turn.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum PageRotation {
17    /// Text reads along +x; coordinates are plain page coordinates.
18    Upright,
19    /// Most runs read bottom-to-top (90°, `Tm = [0 b -b 0]`): the frame was
20    /// turned so they read along +x, mapping `(x, y) → (y, -x)`.
21    Ccw,
22    /// Most runs read top-to-bottom (270°, `Tm = [0 -b b 0]`): the frame was
23    /// turned the other way, mapping `(x, y) → (-y, x)`.
24    Cw,
25}
26
27impl PageRotation {
28    /// Degrees added to a run's baseline angle when its page frame is
29    /// turned: the dominant runs land on `0`.
30    pub(crate) fn baseline_rebase_degrees(self) -> f32 {
31        match self {
32            PageRotation::Upright => 0.0,
33            PageRotation::Ccw => -90.0,
34            PageRotation::Cw => 90.0,
35        }
36    }
37
38    /// Turn a point with the page frame.
39    pub(crate) fn rotate_point(self, x: f32, y: f32) -> (f32, f32) {
40        match self {
41            PageRotation::Upright => (x, y),
42            PageRotation::Ccw => (y, -x),
43            PageRotation::Cw => (-y, x),
44        }
45    }
46
47    /// Turn an axis-aligned box with the page frame: the negated axis's far
48    /// edge becomes the new near edge and the extents swap. Extents may be
49    /// negative (rects drawn under a reflected CTM), so both edges are
50    /// normalised first and the result always has non-negative extents —
51    /// for an upright page too, where the box otherwise stays put.
52    pub(crate) fn rotate_box(self, x: &mut f32, y: &mut f32, width: &mut f32, height: &mut f32) {
53        let (x0, x1) = (x.min(*x + *width), x.max(*x + *width));
54        let (y0, y1) = (y.min(*y + *height), y.max(*y + *height));
55        let (new_x, new_y, new_width, new_height) = match self {
56            PageRotation::Upright => (x0, y0, x1 - x0, y1 - y0),
57            PageRotation::Ccw => (y0, -x1, y1 - y0, x1 - x0),
58            PageRotation::Cw => (-y1, x0, y1 - y0, x1 - x0),
59        };
60        *x = new_x;
61        *y = new_y;
62        *width = new_width;
63        *height = new_height;
64    }
65
66    /// Undo [`PageRotation::rotate_box`]: take a box expressed in the turned
67    /// frame back to the page frame it was turned from. Extents are
68    /// normalised the same way, so the result has non-negative extents.
69    pub(crate) fn unrotate_box(self, x: &mut f32, y: &mut f32, width: &mut f32, height: &mut f32) {
70        let (x0, x1) = (x.min(*x + *width), x.max(*x + *width));
71        let (y0, y1) = (y.min(*y + *height), y.max(*y + *height));
72        let (new_x, new_y, new_width, new_height) = match self {
73            PageRotation::Upright => (x0, y0, x1 - x0, y1 - y0),
74            // Forward `(x, y) → (y, -x)`: the turned x range is the page y
75            // range and the turned y range is the negated page x range, so
76            // the page's left edge is the negated turned top edge.
77            PageRotation::Ccw => (-y1, x0, y1 - y0, x1 - x0),
78            // Forward `(x, y) → (-y, x)`: the turned x range is the negated
79            // page y range and the turned y range is the page x range, so
80            // the page's bottom edge is the negated turned right edge.
81            PageRotation::Cw => (y0, -x1, y1 - y0, x1 - x0),
82        };
83        *x = new_x;
84        *y = new_y;
85        *width = new_width;
86        *height = new_height;
87    }
88}
89
90/// Text rise (Ts) displaces the glyph origin by (0, rise) in unscaled text
91/// space — per the rendering-matrix definition it sits left of Tm, so the
92/// offset maps through the text matrix's y column. Rise never contributes
93/// to the advance, so callers apply it only to the rendering position and
94/// keep advancing the unshifted text matrix.
95pub(crate) fn rise_adjusted(tm: &[f32; 6], rise: f32) -> [f32; 6] {
96    if rise == 0.0 {
97        return *tm;
98    }
99    [
100        tm[0],
101        tm[1],
102        tm[2],
103        tm[3],
104        tm[4] + rise * tm[2],
105        tm[5] + rise * tm[3],
106    ]
107}
108
109/// `tm` with its origin carried `advance_ts` text-space units along the
110/// baseline (scaled by `Tz`): where a run painted after that much pen travel
111/// begins.
112pub(crate) fn advanced_tm(tm: &[f32; 6], advance_ts: f32, horizontal_scale: f32) -> [f32; 6] {
113    let advance = advance_ts * horizontal_scale;
114    [
115        tm[0],
116        tm[1],
117        tm[2],
118        tm[3],
119        tm[4] + advance * tm[0],
120        tm[5] + advance * tm[1],
121    ]
122}
123
124/// Axis-aligned device-space box of one shown run plus its baseline angle.
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub(crate) struct RunGeometry {
127    pub(crate) x: f32,
128    pub(crate) y: f32,
129    pub(crate) width: f32,
130    pub(crate) height: f32,
131    pub(crate) rotation: f32,
132    /// Whether `advance_ts` was known: see `TextItem::advance_known`.
133    pub(crate) advance_known: bool,
134}
135
136impl RunGeometry {
137    /// Whether the run reads along +x: the same half-plane as
138    /// `TextItem::is_upright`.
139    pub(crate) fn is_upright(&self) -> bool {
140        let rotation = self.rotation.rem_euclid(360.0);
141        rotation <= 45.0 || rotation >= 315.0
142    }
143}
144
145/// Compute the axis-aligned box a shown run occupies in device space.
146///
147/// `combined` is the (rise-adjusted) text matrix × CTM at the run's start,
148/// `advance_ts` the run's advance in text-space units when the font carries
149/// width information, and `em` the rendered em size. The glyphs occupy the
150/// rectangle spanned by the device-space advance vector and a one-em glyph
151/// up vector; its bounding box is `[x, x+advance] × [y, y+em]` for upright
152/// text — exactly what callers always produced — but stays tall and thin
153/// for a rotated run. Projecting the advance onto x alone left a 90° run
154/// with `width == 0`, which downstream code reads as "advance unknown" and
155/// replaces with a character-count estimate: a vertical margin stamp became
156/// a page-wide phantom horizontal line.
157///
158/// The up vector is one em perpendicular to the baseline, on the side the
159/// matrix's own y axis points to. Perpendicular rather than the y axis
160/// itself, because synthetic-italic shears lean that axis and would widen
161/// the box by the slant and shrink its height, which the baseline-anchored
162/// box never did. The y axis still decides the side: a producer that
163/// mirrors x for right-to-left text (`[-s 0 0 s]`) keeps its glyphs above
164/// the baseline, and a genuinely y-flipped matrix renders them below.
165/// `glyph_up_flipped` undoes that choice for Type3 fonts whose FontMatrix
166/// mirrors y: dvips/PK bitmap fonts declare `[1 0 0 -1 0 0]` and pair it
167/// with a y-flipped text matrix so the glyphs come out upright, so their
168/// box belongs above the baseline although the matrix says otherwise.
169///
170/// An unknown advance (a font without width metrics) is replaced by
171/// `fallback_advance_ts`, the caller's half-an-em-per-glyph estimate in
172/// text-space units, so the box still lies where the text plausibly is —
173/// laid along the baseline whichever way it runs, which a consumer adding an
174/// estimate on the +x side could not get right for runs reading towards -x —
175/// and `advance_known` records that the extent is an estimate.
176/// The device-space direction a run reads in: the text matrix's x axis,
177/// turned around when the `Tf` size is negative (a negative size negates the
178/// glyph matrix). Page-rotation votes take this, as `run_geometry` does, so a
179/// vertical run drawn at a negative size never votes against its own items.
180pub(crate) fn reading_direction(combined: &[f32; 6], font_size: f32) -> (f32, f32) {
181    if font_size < 0.0 {
182        (-combined[0], -combined[1])
183    } else {
184        (combined[0], combined[1])
185    }
186}
187
188/// Apply the text state's horizontal scale (`Tz`) to a run without scaling
189/// its height. Advances and spacing are measured before `Tz`; a negative
190/// scale also reflects the baseline axis used to determine orientation.
191pub(crate) fn scaled_run_geometry(
192    combined: &[f32; 6],
193    advance_ts: Option<f32>,
194    fallback_advance_ts: f32,
195    em: f32,
196    glyph_up_flipped: bool,
197    horizontal_scale: f32,
198) -> RunGeometry {
199    let mut reflected = *combined;
200    if horizontal_scale < 0.0 {
201        reflected[0] = -reflected[0];
202        reflected[1] = -reflected[1];
203    }
204    run_geometry(
205        &reflected,
206        advance_ts.map(|advance| advance * horizontal_scale.abs()),
207        fallback_advance_ts * horizontal_scale.abs(),
208        em,
209        glyph_up_flipped,
210    )
211}
212
213/// `em` is the rendered font size, negative when the `Tf` size was.
214pub(crate) fn run_geometry(
215    combined: &[f32; 6],
216    advance_ts: Option<f32>,
217    fallback_advance_ts: f32,
218    em: f32,
219    glyph_up_flipped: bool,
220) -> RunGeometry {
221    let (x0, y0) = (combined[4], combined[5]);
222    let (advance, advance_known) = match advance_ts {
223        Some(advance) => (advance, true),
224        None => (fallback_advance_ts, false),
225    };
226    // A negative `em` is the caller saying the `Tf` size was negative: that
227    // turns the glyph matrix around, so the run reads backwards and its
228    // glyphs stand on the other side. A negative *advance* alone (tight
229    // character spacing, TJ positioning) only moves the cursor backwards
230    // over upright glyphs and changes neither.
231    let reversed = em < 0.0;
232    let em = em.abs();
233    let (ax, ay) = (advance * combined[0], advance * combined[1]);
234    let axis_len = combined[0].hypot(combined[1]);
235    let (ux, uy) = if axis_len > f32::EPSILON {
236        // Unit perpendicular to the baseline (the advance turned 90° CCW),
237        // then the y axis's (c, d) component along it: its sign is the side
238        // the glyphs stand on, its size the em's true perpendicular extent.
239        // `em` carries the matrix's larger scale (that is what `font_size`
240        // reports), so a stretched `[2 0 0 1]` matrix must be scaled back
241        // down or the box would be twice as tall as the glyphs.
242        let (px, py) = (-combined[1] / axis_len, combined[0] / axis_len);
243        let y_axis_perp = combined[2] * px + combined[3] * py;
244        let max_scale = axis_len.max(combined[2].hypot(combined[3]));
245        let em_perp = if y_axis_perp.abs() > f32::EPSILON && max_scale > f32::EPSILON {
246            em * y_axis_perp.abs() / max_scale
247        } else {
248            em
249        };
250        let y_axis_side = if y_axis_perp < 0.0 { -1.0 } else { 1.0 };
251        let side = if glyph_up_flipped != reversed {
252            -y_axis_side
253        } else {
254            y_axis_side
255        };
256        (px * em_perp * side, py * em_perp * side)
257    } else {
258        (
259            0.0,
260            if glyph_up_flipped != reversed {
261                -em
262            } else {
263                em
264            },
265        )
266    };
267    // The reading direction, or for a reflected matrix (negative
268    // determinant) the orientation of the glyphs: a reflection has no
269    // rotation — its reading direction and its glyphs differ by a half turn
270    // — so the run reports how its glyphs stand. The mirrored-x matrix some
271    // producers paint right-to-left text with therefore reads as an upright
272    // run (0), a y-flipped matrix as an upside-down one (180); the box covers
273    // the run either way. The glyphs' "right" is their up vector turned 90°
274    // clockwise.
275    let (dir_x, dir_y) = if reversed {
276        (-combined[0], -combined[1])
277    } else {
278        (combined[0], combined[1])
279    };
280    let det = combined[0] * combined[3] - combined[1] * combined[2];
281    let rotation = if det < 0.0 {
282        let up_sign = if glyph_up_flipped != reversed {
283            -1.0
284        } else {
285            1.0
286        };
287        let (up_x, up_y) = (combined[2] * up_sign, combined[3] * up_sign);
288        baseline_rotation(up_y, -up_x)
289    } else {
290        baseline_rotation(dir_x, dir_y)
291    };
292    let xs = [x0, x0 + ax, x0 + ux, x0 + ax + ux];
293    let ys = [y0, y0 + ay, y0 + uy, y0 + ay + uy];
294    let x_min = xs.iter().copied().fold(f32::INFINITY, f32::min);
295    let x_max = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max);
296    let y_min = ys.iter().copied().fold(f32::INFINITY, f32::min);
297    let y_max = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max);
298    RunGeometry {
299        x: x_min,
300        y: y_min,
301        width: x_max - x_min,
302        height: y_max - y_min,
303        rotation,
304        advance_known,
305    }
306}
307
308/// Advance estimate from the decoded text alone, in text-space units: half
309/// an em per character. The parsers estimate from the painted codes instead
310/// (`content_stream::estimated_string_advance_ts`, where a ligature is one
311/// glyph and spacing counts) and fall back to this when no codes were seen.
312/// `font_size_ts` is the em in text space (the `Tf` size, times the Type3
313/// scale where one applies).
314pub(crate) fn estimated_advance_ts(text: &str, font_size_ts: f32) -> f32 {
315    estimated_advance_for_glyphs(text.chars().count(), font_size_ts)
316}
317
318/// The same estimate from a glyph count — for ActualText spans, whose
319/// replacement string can be longer or shorter than what was painted.
320pub(crate) fn estimated_advance_for_glyphs(glyphs: usize, font_size_ts: f32) -> f32 {
321    glyphs as f32 * 0.5 * font_size_ts
322}
323
324/// Angle of the text-space x axis `(a, b)` in device space, in degrees
325/// counter-clockwise from +x and normalised to `[0, 360)`. Float noise
326/// within a hundredth of a degree of a whole degree is snapped so
327/// rotation-only matrices report exact `0`, `90`, `180`, `270`.
328pub(crate) fn baseline_rotation(a: f32, b: f32) -> f32 {
329    if a == 0.0 && b == 0.0 {
330        return 0.0;
331    }
332    let degrees = normalize_degrees(b.atan2(a).to_degrees());
333    let whole = degrees.round();
334    if (degrees - whole).abs() < 0.01 {
335        normalize_degrees(whole)
336    } else {
337        degrees
338    }
339}
340
341/// Wrap an angle into `[0, 360)`, mapping `-0.0` and `360.0` to `0.0`.
342pub(crate) fn normalize_degrees(degrees: f32) -> f32 {
343    let wrapped = degrees.rem_euclid(360.0);
344    if wrapped >= 360.0 || wrapped == 0.0 {
345        0.0
346    } else {
347        wrapped
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn unrotate_box_undoes_rotate_box() {
357        for rotation in [PageRotation::Upright, PageRotation::Ccw, PageRotation::Cw] {
358            for (x, y, w, h) in [
359                (188.0, 100.0, 12.0, 36.0),
360                (28.0, 420.0, 12.0, 200.0),
361                (-50.0, -20.0, 30.0, 10.0),
362            ] {
363                let (mut tx, mut ty, mut tw, mut th) = (x, y, w, h);
364                rotation.rotate_box(&mut tx, &mut ty, &mut tw, &mut th);
365                rotation.unrotate_box(&mut tx, &mut ty, &mut tw, &mut th);
366                assert_eq!((tx, ty, tw, th), (x, y, w, h), "{rotation:?}");
367            }
368        }
369        // The turned box may arrive with negative extents; the page box
370        // comes back normalised.
371        let (mut x, mut y, mut w, mut h) = (620.0, -28.0, -200.0, -12.0);
372        PageRotation::Ccw.unrotate_box(&mut x, &mut y, &mut w, &mut h);
373        assert_eq!((x, y, w, h), (28.0, 420.0, 12.0, 200.0));
374        let (mut x, mut y, mut w, mut h) = (-500.0, 312.0, -200.0, -12.0);
375        PageRotation::Cw.unrotate_box(&mut x, &mut y, &mut w, &mut h);
376        assert_eq!((x, y, w, h), (300.0, 500.0, 12.0, 200.0));
377    }
378
379    #[test]
380    fn page_rotation_turns_boxes_and_points_consistently() {
381        // A 90° run (box 12 wide, 36 tall, baseline at x = 200) turned CCW:
382        // x = run start, y = -baseline, extents swapped.
383        let (mut x, mut y, mut w, mut h) = (188.0, 100.0, 12.0, 36.0);
384        PageRotation::Ccw.rotate_box(&mut x, &mut y, &mut w, &mut h);
385        assert_eq!((x, y, w, h), (100.0, -200.0, 36.0, 12.0));
386        assert_eq!(
387            PageRotation::Ccw.rotate_point(200.0, 100.0),
388            (100.0, -200.0)
389        );
390        // A 270° run (baseline at x = 580, running down from y = 700) turned
391        // CW: x = -(run end), y = baseline x.
392        let (mut x, mut y, mut w, mut h) = (580.0, 664.0, 10.0, 36.0);
393        PageRotation::Cw.rotate_box(&mut x, &mut y, &mut w, &mut h);
394        assert_eq!((x, y, w, h), (-700.0, 580.0, 36.0, 10.0));
395        assert_eq!(PageRotation::Cw.rotate_point(580.0, 700.0), (-700.0, 580.0));
396        let (mut x, mut y, mut w, mut h) = (1.0, 2.0, 3.0, 4.0);
397        PageRotation::Upright.rotate_box(&mut x, &mut y, &mut w, &mut h);
398        assert_eq!((x, y, w, h), (1.0, 2.0, 3.0, 4.0));
399        assert_eq!(PageRotation::Ccw.baseline_rebase_degrees(), -90.0);
400        assert_eq!(PageRotation::Cw.baseline_rebase_degrees(), 90.0);
401    }
402
403    #[test]
404    fn page_rotation_normalises_negative_extents() {
405        // A rect drawn under a reflected CTM: anchor (100, 200), extents
406        // (-20, -10) span x ∈ [80, 100], y ∈ [190, 200]. Both turns must
407        // produce the same box as for the normalised rect.
408        for rotation in [PageRotation::Ccw, PageRotation::Cw] {
409            let (mut x, mut y, mut w, mut h) = (100.0, 200.0, -20.0, -10.0);
410            rotation.rotate_box(&mut x, &mut y, &mut w, &mut h);
411            let (mut nx, mut ny, mut nw, mut nh) = (80.0, 190.0, 20.0, 10.0);
412            rotation.rotate_box(&mut nx, &mut ny, &mut nw, &mut nh);
413            assert_eq!((x, y, w, h), (nx, ny, nw, nh), "{rotation:?}");
414            assert!(w > 0.0 && h > 0.0);
415        }
416        let (mut x, mut y, mut w, mut h) = (100.0, 200.0, -20.0, -10.0);
417        PageRotation::Ccw.rotate_box(&mut x, &mut y, &mut w, &mut h);
418        assert_eq!((x, y, w, h), (190.0, -100.0, 10.0, 20.0));
419        // An upright page keeps the box in place but still normalises it.
420        let (mut x, mut y, mut w, mut h) = (100.0, 200.0, -20.0, -10.0);
421        PageRotation::Upright.rotate_box(&mut x, &mut y, &mut w, &mut h);
422        assert_eq!((x, y, w, h), (80.0, 190.0, 20.0, 10.0));
423    }
424
425    #[test]
426    fn run_geometry_without_advance_keeps_em_width_for_vertical_runs() {
427        // No font widths: an upright run keeps `width == 0` as the
428        // "advance unknown" signal, a vertical run still owns its em column.
429        let vertical = run_geometry(&[0.0, 1.0, -1.0, 0.0, 100.0, 100.0], None, 0.0, 10.0, false);
430        assert_eq!(
431            (vertical.x, vertical.y, vertical.width, vertical.height),
432            (90.0, 100.0, 10.0, 0.0)
433        );
434        assert!(!vertical.advance_known);
435        let upright = run_geometry(&[1.0, 0.0, 0.0, 1.0, 100.0, 100.0], None, 0.0, 10.0, false);
436        assert_eq!(
437            (upright.x, upright.y, upright.width, upright.height),
438            (100.0, 100.0, 0.0, 10.0)
439        );
440        assert!(!upright.advance_known);
441        // A font that reports a zero advance is not "unknown".
442        let zero = run_geometry(
443            &[1.0, 0.0, 0.0, 1.0, 100.0, 100.0],
444            Some(0.0),
445            0.0,
446            10.0,
447            false,
448        );
449        assert!(zero.advance_known);
450        assert_eq!((zero.width, zero.height), (0.0, 10.0));
451    }
452
453    #[test]
454    fn run_geometry_lays_the_fallback_advance_along_the_run() {
455        // Four glyphs without metrics at a 10pt em: a 20pt estimate, laid in
456        // the direction the run reads so the box sits on the text — left of
457        // the origin for 180°, below it for 270°.
458        let upright = run_geometry(&[1.0, 0.0, 0.0, 1.0, 100.0, 100.0], None, 20.0, 10.0, false);
459        assert!(!upright.advance_known);
460        assert_eq!(
461            (upright.x, upright.y, upright.width, upright.height),
462            (100.0, 100.0, 20.0, 10.0)
463        );
464        let flipped = run_geometry(
465            &[-1.0, 0.0, 0.0, 1.0, 100.0, 100.0],
466            None,
467            20.0,
468            10.0,
469            false,
470        );
471        assert_eq!((flipped.x, flipped.width), (80.0, 20.0));
472        assert!(!flipped.advance_known);
473        let down = run_geometry(
474            &[0.0, -1.0, 1.0, 0.0, 100.0, 100.0],
475            None,
476            20.0,
477            10.0,
478            false,
479        );
480        assert_eq!(
481            (down.y, down.height, down.x, down.width),
482            (80.0, 20.0, 100.0, 10.0)
483        );
484        let up = run_geometry(
485            &[0.0, 1.0, -1.0, 0.0, 100.0, 100.0],
486            None,
487            20.0,
488            10.0,
489            false,
490        );
491        assert_eq!((up.y, up.height, up.x, up.width), (100.0, 20.0, 90.0, 10.0));
492        assert_eq!(estimated_advance_ts("abcd", 12.0), 24.0);
493    }
494
495    #[test]
496    fn run_geometry_takes_the_em_height_from_the_y_axis() {
497        // [2 0 0 1]: horizontally stretched 12pt text. `font_size` reports
498        // the larger scale (24), but the glyphs are only 12pt tall.
499        let stretched = run_geometry(
500            &[2.0, 0.0, 0.0, 1.0, 0.0, 0.0],
501            Some(10.0),
502            0.0,
503            24.0,
504            false,
505        );
506        assert_eq!(
507            (stretched.width, stretched.height, stretched.rotation),
508            (20.0, 12.0, 0.0)
509        );
510        // [1 0 0 2]: vertically stretched — the y axis carries the scale.
511        let tall = run_geometry(
512            &[1.0, 0.0, 0.0, 2.0, 0.0, 0.0],
513            Some(10.0),
514            0.0,
515            24.0,
516            false,
517        );
518        assert_eq!((tall.width, tall.height), (10.0, 24.0));
519        // Uniform scale and rotation are unaffected.
520        let turned = run_geometry(
521            &[0.0, 2.0, -2.0, 0.0, 0.0, 0.0],
522            Some(10.0),
523            0.0,
524            24.0,
525            false,
526        );
527        assert_eq!((turned.width, turned.height), (24.0, 20.0));
528    }
529
530    #[test]
531    fn run_geometry_picks_the_baseline_side_from_the_matrix_and_font() {
532        // dvips output: `[s 0 0 -s]` text matrix, glyphs flipped back upright
533        // by the `[1 0 0 -1]` Type3 FontMatrix. The box must stay above the
534        // baseline — putting it one em below would separate Type3 text from
535        // the Type1 math on its line.
536        let dvips = run_geometry(
537            &[1.0, 0.0, 0.0, -1.0, 100.0, 500.0],
538            Some(30.0),
539            0.0,
540            10.0,
541            true,
542        );
543        assert_eq!(
544            (dvips.x, dvips.y, dvips.width, dvips.height, dvips.rotation),
545            (100.0, 500.0, 30.0, 10.0, 0.0)
546        );
547        // The same matrix with an ordinary font really does render the
548        // glyphs upside down, hanging below the baseline.
549        let hanging = run_geometry(
550            &[1.0, 0.0, 0.0, -1.0, 100.0, 500.0],
551            Some(30.0),
552            0.0,
553            10.0,
554            false,
555        );
556        assert_eq!((hanging.y, hanging.height), (490.0, 10.0));
557        // A producer mirroring x for right-to-left text keeps its glyphs
558        // above the baseline: the box runs left from the start point and up,
559        // and the run reports its upright glyphs, not a half turn.
560        let mirrored = run_geometry(
561            &[-1.0, 0.0, 0.0, 1.0, 300.0, 500.0],
562            Some(30.0),
563            0.0,
564            10.0,
565            false,
566        );
567        assert_eq!(
568            (
569                mirrored.x,
570                mirrored.y,
571                mirrored.width,
572                mirrored.height,
573                mirrored.rotation
574            ),
575            (270.0, 500.0, 30.0, 10.0, 0.0)
576        );
577        // Synthetic italics shear the y axis; the box stays em-high and
578        // advance-wide instead of growing by the slant. `em` is the rendered
579        // size, which for this matrix carries the y axis's 1.056 length.
580        let em = 10.0 * 0.34_f32.hypot(1.0);
581        let sheared = run_geometry(
582            &[1.0, 0.0, 0.34, 1.0, 100.0, 500.0],
583            Some(30.0),
584            0.0,
585            em,
586            false,
587        );
588        assert_eq!((sheared.x, sheared.y, sheared.width), (100.0, 500.0, 30.0));
589        assert!(
590            (sheared.height - 10.0).abs() < 1e-3,
591            "height = {}",
592            sheared.height
593        );
594        // Rotations keep the glyph side with the matrix: 90° runs stand to
595        // the left of their baseline, 270° runs to the right.
596        let ccw = run_geometry(
597            &[0.0, 1.0, -1.0, 0.0, 100.0, 100.0],
598            Some(30.0),
599            0.0,
600            10.0,
601            false,
602        );
603        assert_eq!((ccw.x, ccw.width), (90.0, 10.0));
604        let cw = run_geometry(
605            &[0.0, -1.0, 1.0, 0.0, 100.0, 100.0],
606            Some(30.0),
607            0.0,
608            10.0,
609            false,
610        );
611        assert_eq!((cw.x, cw.width), (100.0, 10.0));
612    }
613
614    #[test]
615    fn run_geometry_reports_glyph_orientation_for_reflected_matrices() {
616        // `[-1 0 0 1]` mirrors x: the advance runs left, the glyphs stand
617        // upright — no rotation maps one onto the other, so the run reports
618        // how its glyphs stand and its box still covers the advance.
619        let mirrored = run_geometry(
620            &[-1.0, 0.0, 0.0, 1.0, 300.0, 500.0],
621            Some(21.6),
622            0.0,
623            12.0,
624            false,
625        );
626        assert_eq!(mirrored.rotation, 0.0);
627        assert!((mirrored.x - 278.4).abs() < 1e-3 && mirrored.y == 500.0);
628        assert!((mirrored.width - 21.6).abs() < 1e-3 && mirrored.height == 12.0);
629
630        // dvips Type3 output: a y-flipped matrix whose font matrix flips the
631        // glyphs back (`glyph_up_flipped`) — upright as well.
632        let dvips = run_geometry(
633            &[0.12, 0.0, 0.0, -0.12, 100.0, 700.0],
634            Some(300.0),
635            0.0,
636            12.0,
637            true,
638        );
639        assert_eq!(dvips.rotation, 0.0);
640        assert_eq!(dvips.y, 700.0);
641
642        // A y-flip without that correction hangs the glyphs upside down: the
643        // run is upside-down, its baseline at the top of its box.
644        let flipped = run_geometry(
645            &[1.0, 0.0, 0.0, -1.0, 100.0, 700.0],
646            Some(36.0),
647            0.0,
648            12.0,
649            false,
650        );
651        assert_eq!(flipped.rotation, 180.0);
652        assert_eq!((flipped.y, flipped.height), (688.0, 12.0));
653
654        // A mirrored run turned on its side reads up while its glyphs point
655        // the other way: their orientation is what it reports.
656        let sideways = run_geometry(
657            &[0.0, 1.0, 1.0, 0.0, 100.0, 100.0],
658            Some(36.0),
659            0.0,
660            12.0,
661            false,
662        );
663        assert_eq!(sideways.rotation, 270.0);
664    }
665
666    #[test]
667    fn run_geometry_reads_the_turn_from_the_font_size_sign_not_the_advance() {
668        // A negative `Tf` size (negative `em`) negates the glyph matrix: the
669        // run reads towards -x with its glyphs below the baseline, a 180°
670        // turn; the advance it produced is negative too.
671        let turned = run_geometry(
672            &[1.0, 0.0, 0.0, 1.0, 100.0, 700.0],
673            Some(-36.0),
674            0.0,
675            -12.0,
676            false,
677        );
678        assert_eq!(turned.rotation, 180.0);
679        assert_eq!((turned.x, turned.width), (64.0, 36.0));
680        assert_eq!((turned.y, turned.height), (688.0, 12.0));
681        assert!(turned.advance_known);
682
683        // A negative advance alone — character spacing tighter than the
684        // glyphs, or TJ positioning walking back — leaves the glyphs upright
685        // on their baseline; only the box extends the other way.
686        let tight = run_geometry(
687            &[1.0, 0.0, 0.0, 1.0, 100.0, 700.0],
688            Some(-36.0),
689            0.0,
690            12.0,
691            false,
692        );
693        assert_eq!(tight.rotation, 0.0);
694        assert_eq!((tight.x, tight.width), (64.0, 36.0));
695        assert_eq!((tight.y, tight.height), (700.0, 12.0));
696    }
697
698    #[test]
699    fn baseline_rotation_reports_exact_cardinals_and_fractional_skews() {
700        assert_eq!(baseline_rotation(1.0, 0.0), 0.0);
701        assert_eq!(baseline_rotation(12.0, 0.0), 0.0);
702        assert_eq!(baseline_rotation(0.0, 1.0), 90.0);
703        assert_eq!(baseline_rotation(-1.0, 0.0), 180.0);
704        assert_eq!(baseline_rotation(0.0, -1.0), 270.0);
705        assert_eq!(baseline_rotation(0.0, 0.0), 0.0);
706        assert!((baseline_rotation(1.0, 1.0) - 45.0).abs() < 1e-3);
707        let skew = baseline_rotation(1.0, 0.01);
708        assert!(skew > 0.5 && skew < 0.6, "skew = {skew}");
709        // Float noise snaps to the cardinal instead of reporting 359.99999.
710        assert_eq!(baseline_rotation(1.0, -1e-7), 0.0);
711        assert_eq!(normalize_degrees(-90.0), 270.0);
712        assert_eq!(normalize_degrees(360.0), 0.0);
713    }
714
715    #[test]
716    fn advanced_tm_carries_the_origin_along_the_scaled_baseline() {
717        // 9.5pt text at (68, 418): 2.973 text-space units of pen travel land
718        // 28.24pt to the right and leave the linear part alone.
719        let tm = [9.5, 0.0, 0.0, 9.5, 68.0, 418.0];
720        let moved = advanced_tm(&tm, 2.973, 1.0);
721        assert!((moved[4] - 96.2435).abs() < 1e-3, "{moved:?}");
722        assert_eq!(&moved[..4], &tm[..4]);
723        assert_eq!(moved[5], 418.0);
724        // `Tz 50` halves the travel; a 90° matrix travels along y.
725        assert!((advanced_tm(&tm, 2.973, 0.5)[4] - 82.12175).abs() < 1e-3);
726        let turned = advanced_tm(&[0.0, 12.0, -12.0, 0.0, 100.0, 200.0], 2.0, 1.0);
727        assert_eq!((turned[4], turned[5]), (100.0, 224.0));
728        assert_eq!(advanced_tm(&tm, 0.0, 1.0), tm);
729    }
730}