pdfrum_text/
orientation.rs1use crate::object::TextRun;
11use pdfrum_page::Page;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Orientation {
16 #[default]
20 Unknown,
21 Horizontal,
23 Vertical,
25}
26
27#[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 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 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
138fn 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#[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 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 let (unit_x, unit_y) = if length < 0.0001 {
185 (dx, dy)
186 } else {
187 (dx / length, dy / length)
188 };
189 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 #![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 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 assert_eq!(filled(&[true], 0, 9), 0.0);
248 }
249}