use crate::object::TextRun;
use pdfrum_page::Page;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Orientation {
#[default]
Unknown,
Horizontal,
Vertical,
}
#[must_use]
pub fn page_flow(page: &Page, runs: &[TextRun]) -> Orientation {
let (width, height) = page.display_size();
#[expect(
clippy::cast_possible_truncation,
reason = "page dimensions are int32_t in the C++, truncated the same way"
)]
let (page_width, page_height) = (width as i32, height as i32);
if page_width <= 0 || page_height <= 0 {
return Orientation::Unknown;
}
let (page_width, page_height) = (
page_width.unsigned_abs() as usize,
page_height.unsigned_abs() as usize,
);
let mut horizontal = vec![false; page_width];
let mut vertical = vec![false; page_height];
let mut line_height = 0.0f64;
let (mut start_h, mut end_h) = (page_width, 0usize);
let (mut start_v, mut end_v) = (page_height, 0usize);
let mut runs = runs.iter();
for index in crate::object::top_level_text_indices(&page.objects) {
let Some(run) = runs.find(|run| run.index.0 >= index.0) else {
break;
};
if run.index != index {
continue;
}
let rect = run.rect;
let clamp = |value: f64, limit: usize| -> usize {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
reason = "clamped into 0..=limit before the cast, and a page is \
a few thousand units wide"
)]
let clamped = value.clamp(0.0, limit as f64) as usize;
clamped
};
let (min_h, max_h) = (clamp(rect.x0, page_width), clamp(rect.x1, page_width));
let (min_v, max_v) = (clamp(rect.y0, page_height), clamp(rect.y1, page_height));
if min_h >= max_h || min_v >= max_v {
continue;
}
for cell in horizontal.get_mut(min_h..max_h).unwrap_or_default() {
*cell = true;
}
for cell in vertical.get_mut(min_v..max_v).unwrap_or_default() {
*cell = true;
}
start_h = start_h.min(min_h);
end_h = end_h.max(max_h);
start_v = start_v.min(min_v);
end_v = end_v.max(max_v);
if line_height <= 0.0 {
line_height = rect.height();
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "int32_t truncation in the C++, reproduced"
)]
let double_line_height = (2.0 * line_height) as i32;
let span = |start: usize, end: usize| -> i32 {
i32::try_from(end).unwrap_or(i32::MAX) - i32::try_from(start).unwrap_or(i32::MAX)
};
if span(start_v, end_v) < double_line_height {
return Orientation::Horizontal;
}
if span(start_h, end_h) < double_line_height {
return Orientation::Vertical;
}
let sum_h = filled(&horizontal, start_h, end_h);
if sum_h > 0.8 {
return Orientation::Horizontal;
}
let sum_v = filled(&vertical, start_v, end_v);
if sum_h > sum_v {
Orientation::Horizontal
} else if sum_h < sum_v {
Orientation::Vertical
} else {
Orientation::Unknown
}
}
fn filled(mask: &[bool], start: usize, end: usize) -> f32 {
if start >= end {
return 0.0;
}
let Some(span) = mask.get(start..end) else {
return 0.0;
};
#[expect(
clippy::cast_precision_loss,
reason = "a page is a few thousand cells wide; the C++ divides in float too"
)]
let ratio = span.iter().filter(|set| **set).count() as f32 / (end - start) as f32;
ratio
}
#[must_use]
pub fn object_flow(run: &TextRun, page_flow: Orientation) -> Orientation {
if run.count() <= 1 {
return page_flow;
}
let (Some(first), Some(last)) = (run.item(0), run.item(run.count() - 1)) else {
return page_flow;
};
let first = run.text_matrix * first.origin;
let last = run.text_matrix * last.origin;
let dx = (last.x - first.x).abs();
let dy = (last.y - first.y).abs();
if dx <= 0.0001 && dy <= 0.0001 {
return Orientation::Unknown;
}
let length = dx.hypot(dy);
let (unit_x, unit_y) = if length < 0.0001 {
(dx, dy)
} else {
(dx / length, dy / length)
};
let x_inside = unit_x <= 0.0872;
if unit_y <= 0.0872 {
return if x_inside {
page_flow
} else {
Orientation::Horizontal
};
}
if x_inside {
Orientation::Vertical
} else {
page_flow
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
clippy::indexing_slicing,
clippy::unreadable_literal,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::*;
use kurbo::Rect;
#[test]
fn a_zero_sized_page_has_no_orientation() {
let mut page = Page::empty();
page.crop_box = Rect::ZERO;
assert_eq!(page_flow(&page, &[]), Orientation::Unknown);
}
#[test]
fn a_page_with_no_text_objects_comes_back_horizontal() {
let page = Page::empty();
assert_eq!(page_flow(&page, &[]), Orientation::Horizontal);
}
#[test]
fn an_empty_span_is_zero_percent_filled() {
assert_eq!(filled(&[true, true], 1, 1), 0.0);
assert_eq!(filled(&[true, true], 2, 1), 0.0);
assert_eq!(filled(&[true, false, true, true], 0, 4), 0.75);
assert_eq!(filled(&[true], 0, 9), 0.0);
}
}