Skip to main content

pdfrum_text/
orientation.rs

1//! Text line flow orientation detection.
2//!
3//! Estimates whether text runs horizontally or vertically across the page.
4
5// Two guesses, one global and one per object. The global one is made once
6// before any character is emitted and is the fallback whenever the per-object
7// one cannot decide — which is often, because a one-glyph object has no
8// direction of its own.
9
10use crate::object::TextRun;
11use pdfrum_page::Page;
12
13/// Which way a line of text runs.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Orientation {
16    /// No guess: the two line-end tests are both skipped for an object whose
17    /// orientation resolves to this, so no line break is ever generated from
18    /// geometry alone.
19    #[default]
20    Unknown,
21    /// Left to right (or right to left) along a baseline.
22    Horizontal,
23    /// Top to bottom down a column.
24    Vertical,
25}
26
27/// The page-global orientation guess (`FindTextlineFlowOrientation`).
28///
29/// Paints two occupancy masks — one per page axis — from the *page-level*
30/// text objects' bounding boxes and asks which axis the text fills. Three
31/// details are load-bearing and all look like mistakes:
32///
33/// - **`line_height` is seeded from the first object that contributes and is
34///   never updated.** Not a median, not an average: whatever the first text
35///   object's box happens to be tall.
36/// - **Text inside form `XObject`s is not counted.** The scan walks the
37///   page's own object list, and a form object is not a text object, so a
38///   page whose text lives entirely inside forms scans nothing and comes back
39///   [`Orientation::Unknown`].
40/// - **The page dimensions truncate toward zero**, and so does twice the line
41///   height, because both are `int32_t` in the C++.
42#[must_use]
43pub fn page_flow(page: &Page, runs: &[TextRun]) -> Orientation {
44    let (width, height) = page.display_size();
45    #[expect(
46        clippy::cast_possible_truncation,
47        reason = "page dimensions are int32_t in the C++, truncated the same way"
48    )]
49    let (page_width, page_height) = (width as i32, height as i32);
50    if page_width <= 0 || page_height <= 0 {
51        return Orientation::Unknown;
52    }
53    // Both are positive after the guard above.
54    let (page_width, page_height) = (
55        page_width.unsigned_abs() as usize,
56        page_height.unsigned_abs() as usize,
57    );
58
59    let mut horizontal = vec![false; page_width];
60    let mut vertical = vec![false; page_height];
61    let mut line_height = 0.0f64;
62    let (mut start_h, mut end_h) = (page_width, 0usize);
63    let (mut start_v, mut end_v) = (page_height, 0usize);
64
65    let mut runs = runs.iter();
66    for index in crate::object::top_level_text_indices(&page.objects) {
67        // Both sequences are ascending in the same flattened numbering, so
68        // one forward scan pairs a page-level text object with the run
69        // `walk` already built for it. A text object with no font builds no
70        // run and is simply not found -- the same objects the old rebuild
71        // skipped, since it read `build`'s `None` as an empty rect.
72        let Some(run) = runs.find(|run| run.index.0 >= index.0) else {
73            break;
74        };
75        if run.index != index {
76            continue;
77        }
78        let rect = run.rect;
79        let clamp = |value: f64, limit: usize| -> usize {
80            #[expect(
81                clippy::cast_possible_truncation,
82                clippy::cast_sign_loss,
83                clippy::cast_precision_loss,
84                reason = "clamped into 0..=limit before the cast, and a page is \
85                          a few thousand units wide"
86            )]
87            let clamped = value.clamp(0.0, limit as f64) as usize;
88            clamped
89        };
90        let (min_h, max_h) = (clamp(rect.x0, page_width), clamp(rect.x1, page_width));
91        let (min_v, max_v) = (clamp(rect.y0, page_height), clamp(rect.y1, page_height));
92        if min_h >= max_h || min_v >= max_v {
93            continue;
94        }
95        for cell in horizontal.get_mut(min_h..max_h).unwrap_or_default() {
96            *cell = true;
97        }
98        for cell in vertical.get_mut(min_v..max_v).unwrap_or_default() {
99            *cell = true;
100        }
101        start_h = start_h.min(min_h);
102        end_h = end_h.max(max_h);
103        start_v = start_v.min(min_v);
104        end_v = end_v.max(max_v);
105        if line_height <= 0.0 {
106            line_height = rect.height();
107        }
108    }
109
110    #[expect(
111        clippy::cast_possible_truncation,
112        reason = "int32_t truncation in the C++, reproduced"
113    )]
114    let double_line_height = (2.0 * line_height) as i32;
115    let span = |start: usize, end: usize| -> i32 {
116        i32::try_from(end).unwrap_or(i32::MAX) - i32::try_from(start).unwrap_or(i32::MAX)
117    };
118    if span(start_v, end_v) < double_line_height {
119        return Orientation::Horizontal;
120    }
121    if span(start_h, end_h) < double_line_height {
122        return Orientation::Vertical;
123    }
124    let sum_h = filled(&horizontal, start_h, end_h);
125    if sum_h > 0.8 {
126        return Orientation::Horizontal;
127    }
128    let sum_v = filled(&vertical, start_v, end_v);
129    if sum_h > sum_v {
130        Orientation::Horizontal
131    } else if sum_h < sum_v {
132        Orientation::Vertical
133    } else {
134        Orientation::Unknown
135    }
136}
137
138/// The fraction of a mask's cells that are set over `start..end`, or zero for
139/// an empty span (`MaskPercentFilled`).
140fn filled(mask: &[bool], start: usize, end: usize) -> f32 {
141    if start >= end {
142        return 0.0;
143    }
144    let Some(span) = mask.get(start..end) else {
145        return 0.0;
146    };
147    #[expect(
148        clippy::cast_precision_loss,
149        reason = "a page is a few thousand cells wide; the C++ divides in float too"
150    )]
151    let ratio = span.iter().filter(|set| **set).count() as f32 / (end - start) as f32;
152    ratio
153}
154
155/// The orientation one text object suggests (`GetTextObjectWritingMode`).
156///
157/// Takes the vector from the first glyph's origin to the last, transformed
158/// into page space, and asks which axis it is within five degrees of. Both
159/// components inside the cone (a diagonal) or both outside means "trust the
160/// page-global guess"; exactly one outside picks that axis.
161///
162/// A run of one glyph or fewer has no vector at all and falls straight
163/// through to the page-global guess.
164#[must_use]
165pub fn object_flow(run: &TextRun, page_flow: Orientation) -> Orientation {
166    if run.count() <= 1 {
167        return page_flow;
168    }
169    let (Some(first), Some(last)) = (run.item(0), run.item(run.count() - 1)) else {
170        return page_flow;
171    };
172    // The *linear* part only: the object's own translation cancels out of a
173    // difference, and the C++ transforms both origins by the same matrix.
174    let first = run.text_matrix * first.origin;
175    let last = run.text_matrix * last.origin;
176    let dx = (last.x - first.x).abs();
177    let dy = (last.y - first.y).abs();
178    if dx <= 0.0001 && dy <= 0.0001 {
179        return Orientation::Unknown;
180    }
181    let length = dx.hypot(dy);
182    // `CFX_VectorF::Normalize` leaves a very short vector alone, but the
183    // guard above already put at least one component past that threshold.
184    let (unit_x, unit_y) = if length < 0.0001 {
185        (dx, dy)
186    } else {
187        (dx / length, dy / length)
188    };
189    // 0.0872 is sin(5 degrees): the cone within which an axis counts as
190    // aligned.
191    let x_inside = unit_x <= 0.0872;
192    if unit_y <= 0.0872 {
193        return if x_inside {
194            page_flow
195        } else {
196            Orientation::Horizontal
197        };
198    }
199    if x_inside {
200        Orientation::Vertical
201    } else {
202        page_flow
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    // Test fixtures quote the oracle's own vectors, compare floats exactly
209    // where the behaviour being pinned is exact, and index arrays whose
210    // length the fixture itself fixes.
211    #![allow(
212        clippy::float_cmp,
213        clippy::indexing_slicing,
214        clippy::unreadable_literal,
215        clippy::cast_precision_loss,
216        clippy::cast_possible_truncation,
217        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
218    )]
219
220    use super::*;
221    use kurbo::Rect;
222
223    #[test]
224    fn a_zero_sized_page_has_no_orientation() {
225        let mut page = Page::empty();
226        page.crop_box = Rect::ZERO;
227        assert_eq!(page_flow(&page, &[]), Orientation::Unknown);
228    }
229
230    #[test]
231    fn a_page_with_no_text_objects_comes_back_horizontal() {
232        // With nothing scanned the vertical span is `0 - page_height`, a
233        // large negative, and twice a zero line height is zero -- so the very
234        // first test fires and the answer is Horizontal. The arithmetic
235        // matters because a page whose text lives entirely inside form
236        // XObjects scans nothing and lands here.
237        let page = Page::empty();
238        assert_eq!(page_flow(&page, &[]), Orientation::Horizontal);
239    }
240
241    #[test]
242    fn an_empty_span_is_zero_percent_filled() {
243        assert_eq!(filled(&[true, true], 1, 1), 0.0);
244        assert_eq!(filled(&[true, true], 2, 1), 0.0);
245        assert_eq!(filled(&[true, false, true, true], 0, 4), 0.75);
246        // A span past the end reads as empty rather than panicking.
247        assert_eq!(filled(&[true], 0, 9), 0.0);
248    }
249}