From 49c0c6bffc3457ed6ccc59f48e540a0358d32ba8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 23:32:23 -0400 Subject: [PATCH] M2.7: text layout follows the dominant text direction (rotated pages read along their lines) Co-Authored-By: Claude Opus 5.5 --- crates/render/src/text.rs | 60 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 0b7316a2..c336918c 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -142,6 +142,8 @@ impl PageText { struct TextDevice { glyphs: Vec, + /// How many glyphs run in each view direction: right, down, left, up. + directions: [usize; 4], } impl<'a> Device<'a> for TextDevice { @@ -177,6 +179,16 @@ impl<'a> Device<'a> for TextDevice { } Glyph::Type3(g) => g.advance_width().filter(|a| a.is_finite() && *a > 0.0).map(f64::from).unwrap_or(600.0), }; + // The baseline direction in view space (y down), to the nearest quarter turn. + let (dx, dy) = (t.as_coeffs()[0], t.as_coeffs()[1]); + let dir = if dx.abs() >= dy.abs() { + if dx >= 0.0 { 0 } else { 2 } + } else if dy > 0.0 { + 1 + } else { + 3 + }; + self.directions[dir] += text.chars().count(); let em = Rect::new(0.0, -200.0, advance, 800.0); let b = (t * em.to_path(0.1)).bounding_box(); if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 { @@ -206,9 +218,43 @@ pub(crate) fn extract_page(pdf: &Pdf, page: usize, settings: &InterpreterSetting let settings = settings.clone(); let initial = p.initial_transform(true).to_kurbo(); let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, w as f64, h as f64), &cache, p.xref(), settings); - let mut dev = TextDevice { glyphs: Vec::new() }; + let mut dev = TextDevice { glyphs: Vec::new(), directions: [0; 4] }; interpret_page(p, &mut ctx, &mut dev); - Some(layout(dev.glyphs)) + // Lay out in the frame where most text runs left to right, so a page shown rotated (by + // `/Rotate` or by its content matrix) still reads along its lines; then map the boxes back. + // Pages whose `/Rotate` is cancelled by counter-rotated content stay as they are. + let quarter = (0..4u8).max_by_key(|q| (dev.directions[*q as usize], *q == 0)).unwrap_or(0); + for g in &mut dev.glyphs { + g.rect = to_upright(g.rect, quarter, w, h); + } + let mut text = layout(dev.glyphs); + for g in &mut text.glyphs { + g.rect = from_upright(g.rect, quarter, w, h); + } + Some(text) +} + +/// View-space box → the frame where text runs left to right, for text running `quarter` × 90° +/// clockwise from that in a `w` × `h` view (1: text runs downwards). +fn to_upright(r: [f32; 4], quarter: u8, w: f32, h: f32) -> [f32; 4] { + let [x0, y0, x1, y1] = r; + match quarter { + 1 => [y0, w - x1, y1, w - x0], + 2 => [w - x1, h - y1, w - x0, h - y0], + 3 => [h - y1, x0, h - y0, x1], + _ => r, + } +} + +/// The inverse of [`to_upright`]. +fn from_upright(r: [f32; 4], quarter: u8, w: f32, h: f32) -> [f32; 4] { + let [u0, v0, u1, v1] = r; + match quarter { + 1 => [w - v1, u0, w - v0, u1], + 2 => [w - u1, h - v1, w - u0, h - v0], + 3 => [v0, h - u1, v1, h - u0], + _ => r, + } } use hayro::hayro_interpret::TransformExt; @@ -436,6 +482,16 @@ pub fn layout(glyphs: Vec) -> PageText { mod tests { use super::*; + #[test] + fn upright_mapping_round_trips() { + let r = [10.0, 20.0, 30.0, 25.0]; + for q in 0..4 { + assert_eq!(from_upright(to_upright(r, q, 300.0, 200.0), q, 300.0, 200.0), r, "quarter {q}"); + } + // A 90° page: the view's top-right corner is the upright top-left. + assert_eq!(to_upright([290.0, 0.0, 300.0, 10.0], 1, 300.0, 200.0), [0.0, 0.0, 10.0, 10.0]); + } + fn g(t: &str, x0: f32, y0: f32, x1: f32) -> TextGlyph { TextGlyph { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] } }