diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index dea26142..5db3fa47 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -20,8 +20,14 @@ use kurbo::{Affine, BezPath, Rect, Shape}; pub struct TextGlyph { /// The glyph's Unicode text: one character, or several for a ligature. pub text: String, - /// Box in page view space (points, y down): [x0, y0, x1, y1]. + /// Box in page view space (points, y down): [x0, y0, x1, y1]. The bounding box of `quad`. pub rect: [f32; 4], + /// The glyph's em box in page view space, as it is drawn (rotated, skewed or mirrored): the + /// start and end of its edge towards the next line, then the end and start of the opposite + /// edge. `quad[0]` → `quad[1]` runs in the writing direction. + pub quad: [[f32; 2]; 4], + /// Unit vector of the writing direction in page view space (`[1, 0]`: left to right). + pub direction: [f32; 2], } /// The text of one page, in content order, with line structure. @@ -202,9 +208,22 @@ impl PageText { } } +/// Largest sine of the angle between a glyph's writing direction and a flow's for the glyph to be +/// laid out in that flow's frame. Turning an em box by θ moves its edges along the line by up to +/// h·sin θ, so below the layout's word-gap tolerance (`WORD_GAP` · h) the boxes still read as they +/// would unturned. +const DIRECTION_TOLERANCE: f64 = WORD_GAP as f64; + +/// A word gap is wider than the line's typical letter gap by this fraction of the line height. +const WORD_GAP: f32 = 0.15; + +/// A glyph with its writing direction to the nearest quarter turn (right, down, left, up) and as +/// a unit vector. +type Drawn = (usize, [f64; 2], TextGlyph); + struct TextDevice { - /// Separate flows by writing direction: right, down, left, up. - glyphs: [Vec; 4], + /// Every glyph in content order. + glyphs: Vec, } impl<'a> Device<'a> for TextDevice { @@ -240,7 +259,7 @@ 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. + // The baseline direction in view space (y down). let coefficients = t.as_coeffs(); let (dx, dy) = if matches!(glyph, Glyph::Outline(g) if g.is_vertical()) { // PDF vertical fonts advance along negative glyph-space y (WMode 1), while @@ -257,13 +276,29 @@ impl<'a> Device<'a> for TextDevice { 3 }; let em = Rect::new(0.0, -200.0, advance, 800.0); - let b = (t * em.to_path(0.1)).bounding_box(); + let corners = if matches!(glyph, Glyph::Outline(g) if g.is_vertical()) { + [(em.x0, em.y1), (em.x0, em.y0), (em.x1, em.y0), (em.x1, em.y1)] + } else { + [(em.x0, em.y0), (em.x1, em.y0), (em.x1, em.y1), (em.x0, em.y1)] + } + .map(|(x, y)| t * kurbo::Point::new(x, y)); + let b = corners.iter().fold(Rect::new(f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| { + Rect::new(b.x0.min(p.x), b.y0.min(p.y), b.x1.max(p.x), b.y1.max(p.y)) + }); 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 { return; } + let length = dx.hypot(dy); + let direction = if length.is_finite() && length > 0.0 { [dx / length, dy / length] } else { [1.0, 0.0] }; // A glyph whose Unicode is several characters (a ligature such as "ffi") stays one glyph // with its own box: the PDF gives no position for the characters inside it. - self.glyphs[dir].push(TextGlyph { text, rect: [b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32] }); + let glyph = TextGlyph { + text, + rect: [b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32], + quad: corners.map(|p| [p.x as f32, p.y as f32]), + direction: direction.map(|d| d as f32), + }; + self.glyphs.push((dir, direction, glyph)); } fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} fn pop_clip_path(&mut self) {} @@ -281,36 +316,163 @@ 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: Default::default() }; + let mut dev = TextDevice { glyphs: Vec::new() }; interpret_page(p, &mut ctx, &mut dev); - // A horizontal heading must not force a vertical body back into horizontal line grouping. - // Group each writing direction independently, then join flows from their topmost position. - let mut flows = Vec::new(); - for (quarter, mut glyphs) in dev.glyphs.into_iter().enumerate() { - if glyphs.is_empty() { + let glyphs = dev.glyphs; + // Text on a curved path: glyphs that each continue the previous one's line while the + // direction turns. Each such run is one line. + let turned = |u: [f64; 2]| u[0].abs().min(u[1].abs()) > DIRECTION_TOLERANCE; + let mut in_curve = vec![false; glyphs.len()]; + // Turned text: curved runs, and straight flows along one writing direction. The two are kept + // apart so finding a glyph's direction scans only the directions (a few dozen at most: each + // differs from the others by more than the tolerance), not every curved run. + let mut curves: Vec> = Vec::new(); + let mut straight: Vec<([f64; 2], Vec)> = Vec::new(); + let mut start = 0; + for end in 1..=glyphs.len() { + if end < glyphs.len() && continues(&glyphs[end - 1], &glyphs[end]) { continue; } - let top = glyphs.iter().map(|g| g.rect[1]).fold(f32::INFINITY, f32::min); - for g in &mut glyphs { - g.rect = to_upright(g.rect, quarter as u8, w, h); + let run = &glyphs[start..end]; + if run.iter().any(|g| turned(g.1)) && run.iter().any(|g| !compatible(run[0].1, g.1)) { + in_curve[start..end].fill(true); + curves.push((start..end).collect()); } - let mut text = layout(glyphs); - for g in &mut text.glyphs { - g.rect = from_upright(g.rect, quarter as u8, w, h); - } - flows.push((top, text)); + start = end; } - flows.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut quarters: [Vec; 4] = Default::default(); + for (i, (quarter, u, _)) in glyphs.iter().enumerate() { + if in_curve[i] { + continue; + } + if !turned(*u) { + if let Some(q) = quarters.get_mut(*quarter) { + q.push(i); + } + } else if let Some((_, ids)) = straight.iter_mut().find(|(d, _)| compatible(*d, *u)) { + ids.push(i); + } else { + straight.push((*u, vec![i])); + } + } + // A horizontal heading must not force a vertical body back into horizontal line grouping. + // Group each writing direction independently, then join flows from their topmost position. + let upright = |quarters: [Vec; 4]| { + let mut flows: Vec = quarters + .into_iter() + .enumerate() + .filter(|(_, ids)| !ids.is_empty()) + .map(|(quarter, ids)| { + let rects = ids.iter().map(|&i| to_upright(glyphs[i].2.rect, quarter as u8, w, h)).collect(); + arrange_flow(&glyphs, ids, rects, Some(quarter as u8)) + }) + .collect(); + flows.sort_by(|a, b| a.top.total_cmp(&b.top)); + flows + }; + let flows = upright(quarters); + let mut extra: Vec = curves + .into_iter() + .map(|ids| (None, ids)) + .chain(straight.into_iter().map(|(u, ids)| (Some(u), ids))) + .map(|(u, ids)| { + let rects = match u { + // Turned text is laid out along its own baseline. + Some(u) => ids.iter().map(|&i| along_baseline(&glyphs[i].2.quad, u).map(|c| c as f32)).collect(), + None => unrolled(ids.iter().map(|&i| &glyphs[i])), + }; + arrange_flow(&glyphs, ids, rects, None) + }) + .collect(); + // Turned text takes the place in the reading order where laying it out among the nearest + // quarter turn's text puts its first glyph. + let mut rank = Vec::new(); + if !extra.is_empty() { + rank = vec![usize::MAX; glyphs.len()]; + let mut nearest: [Vec; 4] = Default::default(); + for (i, g) in glyphs.iter().enumerate() { + if let Some(q) = nearest.get_mut(g.0) { + q.push(i); + } + } + for (r, i) in upright(nearest).into_iter().flat_map(|f| f.ids).enumerate() { + rank[i] = r; + } + } + let first = |ids: &[usize]| ids.iter().filter_map(|&i| rank.get(i).copied()).min().unwrap_or(usize::MAX); + extra.sort_by_key(|f| first(&f.ids)); + let mut extra = extra.into_iter().peekable(); + let mut slots: Vec> = glyphs.into_iter().map(|g| Some(g.2)).collect(); let mut text = PageText::default(); - for (_, flow) in flows { + let mut emit = |text: &mut PageText, ids: &[usize], line_of: &[u32], space_before: &[bool], quarter: Option| { let offset = text.line_of.last().map_or(0, |line| line.saturating_add(1)); - text.line_of.extend(flow.line_of.into_iter().map(|line| line.saturating_add(offset))); - text.glyphs.extend(flow.glyphs); - text.space_before.extend(flow.space_before); + for ((&i, &line), &space) in ids.iter().zip(line_of).zip(space_before) { + let Some(mut g) = slots.get_mut(i).and_then(Option::take) else { continue }; + if let Some(quarter) = quarter { + g.rect = from_upright(to_upright(g.rect, quarter, w, h), quarter, w, h); + } + text.glyphs.push(g); + text.line_of.push(line.saturating_add(offset)); + text.space_before.push(space); + } + }; + for flow in flows { + let mut k = 0; + while k < flow.ids.len() { + let line = flow.line_of[k]; + let end = flow.line_of[k..].iter().position(|l| *l != line).map_or(flow.ids.len(), |n| k + n); + let before = first(&flow.ids[k..end]); + while let Some(f) = extra.next_if(|f| first(&f.ids) < before) { + emit(&mut text, &f.ids, &f.line_of, &f.space_before, None); + } + emit(&mut text, &flow.ids[k..end], &vec![0; end - k], &flow.space_before[k..end], flow.quarter); + k = end; + } + } + for f in extra { + emit(&mut text, &f.ids, &f.line_of, &f.space_before, None); } Some(text) } +/// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps. +struct Flow { + /// The top of the flow's topmost glyph in view space. + top: f32, + /// The quarter turn whose frame the flow was laid out in. + quarter: Option, + ids: Vec, + line_of: Vec, + space_before: Vec, +} + +/// Lay out the page's glyphs `ids`, giving each the box at the same position in `rects`. +fn arrange_flow(glyphs: &[Drawn], ids: Vec, rects: Vec<[f32; 4]>, quarter: Option) -> Flow { + let top = ids.iter().filter_map(|&i| glyphs.get(i)).map(|g| g.2.rect[1]).fold(f32::INFINITY, f32::min); + let framed: Vec = ids.iter().zip(rects).filter_map(|(&i, rect)| glyphs.get(i).map(|g| TextGlyph { rect, ..g.2.clone() })).collect(); + let (order, line_of, space_before) = arrange(&framed); + let ids = order.into_iter().filter_map(|k| ids.get(k).copied()).collect(); + Flow { top, quarter, ids, line_of, space_before } +} + +/// Boxes for a run of glyphs set along a curve: each glyph's box along its own direction, placed +/// where the pen moved in the previous glyph's direction. The path straightened out. +fn unrolled<'g>(run: impl Iterator) -> Vec<[f32; 4]> { + let mut pen = [0.0, 0.0]; + let mut previous: Option<([f64; 2], [f64; 4])> = None; + run.map(|(_, u, g)| { + let b = along_baseline(&g.quad, *u); + if let Some((pu, pb)) = previous { + let at = along_baseline(&g.quad, pu); + pen = [pen[0] + at[0] - pb[0], pen[1] + (at[1] + at[3] - pb[1] - pb[3]) / 2.0]; + } + previous = Some((*u, b)); + let half = (b[3] - b[1]) / 2.0; + [pen[0], pen[1] - half, pen[0] + b[2] - b[0], pen[1] + half].map(|c| c as f32) + }) + .collect() +} + /// 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] { @@ -323,6 +485,35 @@ fn to_upright(r: [f32; 4], quarter: u8, w: f32, h: f32) -> [f32; 4] { } } +/// Whether two writing directions are close enough to share a frame ([`DIRECTION_TOLERANCE`]). +fn compatible(a: [f64; 2], b: [f64; 2]) -> bool { + a[0] * b[0] + a[1] * b[1] > 0.0 && (a[0] * b[1] - a[1] * b[0]).abs() <= DIRECTION_TOLERANCE +} + +/// Whether glyph `g` continues the line of glyph `p` (content order), by the test `layout` uses +/// for consecutive glyphs, taken in `p`'s own frame. The direction may turn by less than half a +/// quarter turn, as text set along a curve does. +fn continues(p: &Drawn, g: &Drawn) -> bool { + let (pu, gu) = (p.1, g.1); + if (pu[0] * gu[1] - pu[1] * gu[0]).abs() >= pu[0] * gu[0] + pu[1] * gu[1] { + return false; + } + let (pb, gb, own) = (along_baseline(&p.2.quad, pu), along_baseline(&g.2.quad, pu), along_baseline(&g.2.quad, gu)); + let h = (pb[3] - pb[1]).min(own[3] - own[1]).max(0.1); + let cy = |b: [f64; 4]| (b[1] + b[3]) / 2.0; + (cy(gb) - cy(pb)).abs() < h * 0.5 && gb[0] - pb[2] < h * 3.0 && gb[0] > pb[0] - h * 2.0 +} + +/// A view-space quad → its box in the frame where `u` runs left to right and `u` turned a +/// quarter clockwise runs downwards. Along `u` it spans the middles of the quad's start and end +/// sides, so a skewed glyph is no wider than its advance; across, it spans every corner. +fn along_baseline(quad: &[[f32; 2]; 4], u: [f64; 2]) -> [f64; 4] { + let p = quad.map(|[x, y]| [f64::from(x) * u[0] + f64::from(y) * u[1], f64::from(y) * u[0] - f64::from(x) * u[1]]); + let (a0, a1) = ((p[0][0] + p[3][0]) / 2.0, (p[1][0] + p[2][0]) / 2.0); + let (c0, c1) = p.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q[1]), hi.max(q[1]))); + [a0.min(a1), c0, a0.max(a1), c1] +} + /// 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; @@ -381,8 +572,16 @@ fn union(a: &mut [f32; 4], b: &[f32; 4]) { /// 4. Within a segment glyphs run left to right; runs of right-to-left script are reversed so the /// text comes out in logical order. pub fn layout(glyphs: Vec) -> PageText { + let (order, line_of, space_before) = arrange(&glyphs); + let mut slots: Vec> = glyphs.into_iter().map(Some).collect(); + // Each glyph is emitted exactly once. + let glyphs = order.iter().filter_map(|i| slots.get_mut(*i).and_then(Option::take)).collect(); + PageText { glyphs, line_of, space_before } +} + +/// [`layout`] as the reading order of indices into `glyphs`, with each one's line and word gap. +fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { // Drop "fake bold" duplicates: the same character redrawn at (almost) the same place. - let mut glyphs = glyphs; let mut keep = vec![true; glyphs.len()]; for i in 1..glyphs.len() { let g = &glyphs[i]; @@ -395,13 +594,11 @@ pub fn layout(glyphs: Vec) -> PageText { } } } - if keep.iter().any(|k| !k) { - let mut it = keep.iter(); - glyphs.retain(|_| *it.next().unwrap_or(&true)); - } + let kept: Vec = (0..glyphs.len()).filter(|i| keep[*i]).collect(); + let glyphs: Vec<&TextGlyph> = kept.iter().map(|i| &glyphs[*i]).collect(); let n = glyphs.len(); if n == 0 { - return PageText::default(); + return Default::default(); } let height = |i: usize| (glyphs[i].rect[3] - glyphs[i].rect[1]).max(0.1); let cy = |i: usize| (glyphs[i].rect[1] + glyphs[i].rect[3]) / 2.0; @@ -526,7 +723,7 @@ pub fn layout(glyphs: Vec) -> PageText { let mut gaps: Vec = s.idx.windows(2).map(|w| glyphs[w[1]].rect[0] - glyphs[w[0]].rect[2]).collect(); gaps.sort_by(f32::total_cmp); let typical = gaps.get(gaps.len() / 3).copied().unwrap_or(0.0).max(0.0); - let threshold = (typical + s.h * 0.15).max(s.h * 0.15); + let threshold = (typical + s.h * WORD_GAP).max(s.h * WORD_GAP); let mut spaces = vec![false; idx.len()]; for w in 1..s.idx.len() { let (p, c) = (s.idx[w - 1], s.idx[w]); @@ -549,10 +746,7 @@ pub fn layout(glyphs: Vec) -> PageText { line += 1; } } - let mut slots: Vec> = glyphs.into_iter().map(Some).collect(); - // Each glyph is emitted exactly once. - let glyphs = order.iter().filter_map(|i| slots.get_mut(*i).and_then(Option::take)).collect(); - PageText { glyphs, line_of, space_before } + (order.into_iter().map(|i| kept[i]).collect(), line_of, space_before) } #[cfg(test)] @@ -713,7 +907,8 @@ mod tests { } fn g(t: &str, x0: f32, y0: f32, x1: f32) -> TextGlyph { - TextGlyph { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] } + let rect = [x0, y0, x1, y0 + 10.0]; + TextGlyph { text: t.into(), rect, quad: [[x0, y0], [x1, y0], [x1, rect[3]], [x0, rect[3]]], direction: [1.0, 0.0] } } #[test] @@ -790,7 +985,12 @@ mod tests { #[test] fn words_and_lines_around_a_glyph() { let t = PageText { - glyphs: Vec::from(["a", "b", " ", "c", "d", "e", "f", "g"].map(|s| TextGlyph { text: s.into(), rect: [0.0; 4] })), + glyphs: Vec::from(["a", "b", " ", "c", "d", "e", "f", "g"].map(|s| TextGlyph { + text: s.into(), + rect: [0.0; 4], + quad: [[0.0; 2]; 4], + direction: [1.0, 0.0], + })), line_of: vec![0, 0, 0, 0, 0, 1, 1, 1], space_before: vec![false, false, false, false, false, false, true, false], }; @@ -805,4 +1005,119 @@ mod tests { assert_eq!(t.line_at(8), None); assert_eq!(PageText::default().line_at(0), None); } + + /// A 400 × 400 page drawing `ops` with Helvetica as /F1 and Courier as /F2. + fn text_page(ops: &str) -> Pdf { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let helvetica = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" }); + let courier = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Courier" }); + let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => helvetica, "F2" => courier}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + Pdf::new(std::sync::Arc::new(bytes)).unwrap() + } + + /// `body` shown in /F`font` at `size` with its baseline turned `degrees` counter-clockwise. + fn turned(degrees: f64, font: u8, size: f64, x: f64, y: f64, body: &str) -> String { + let (s, c) = degrees.to_radians().sin_cos(); + format!("BT /F{font} {size} Tf {c:.6} {s:.6} {:.6} {c:.6} {x:.4} {y:.4} Tm {body} ET\n", -s) + } + + fn page_text(ops: &str) -> String { + extract_page(&text_page(ops), 0, &InterpreterSettings::default()).unwrap().plain_text() + } + + #[test] + fn turned_lines_group_along_their_baseline() { + // Two lines 13 pt apart, words separated by TJ offsets (no space glyphs), the second + // line drawn first. Turned away from the quarter turns, the lines' boxes overlap. + for degrees in [12.0, 30.0, 37.0, 45.0, 60.0, 135.0, 200.0, 300.0, -45.0] { + let (s, c) = f64::to_radians(degrees).sin_cos(); + let second = turned(degrees, 1, 12.0, 200.0 + s * 13.0, 200.0 - c * 13.0, "[(Second) -400 (line) -400 (here)] TJ"); + let first = turned(degrees, 1, 12.0, 200.0, 200.0, "[(First) -400 (line) -400 (text)] TJ"); + assert_eq!(page_text(&(second + &first)), "First line text\nSecond line here", "{degrees}°"); + } + } + + #[test] + fn crossing_turned_lines_stay_apart() { + // Lines at 30° and −30° crossing each other, their glyphs drawn alternately. + let (up, down) = ("Upward words here", "Downward words now"); + let mut ops = String::new(); + let (s, c) = f64::to_radians(30.0).sin_cos(); + for (k, (a, b)) in up.chars().zip(down.chars()).enumerate() { + let along = 7.2 * k as f64; + ops += &turned(30.0, 2, 12.0, 100.0 + c * along, 150.0 + s * along, &format!("({a}) Tj")); + ops += &turned(-30.0, 2, 12.0, 100.0 + c * along, 250.0 - s * along, &format!("({b}) Tj")); + } + ops += &turned(-30.0, 2, 12.0, 100.0 + c * 7.2 * 17.0, 250.0 - s * 7.2 * 17.0, "(w) Tj"); + assert_eq!(page_text(&ops), "Downward words now\nUpward words here"); + } + + #[test] + fn text_along_a_curve_is_one_line() { + // Courier glyphs (7.2 pt advance) set on circles of radius 90 around (200, 200): over + // the top reading clockwise, under the bottom reading counter-clockwise, and a full ring. + let arc = |text: &str, from: f64, clockwise: bool, radius: f64| { + let step = (7.2 / radius).to_degrees() * if clockwise { -1.0 } else { 1.0 }; + let mut ops = String::new(); + for (k, ch) in text.chars().enumerate() { + let a = from + step * k as f64; + let (s, c) = a.to_radians().sin_cos(); + ops += &turned(if clockwise { a - 90.0 } else { a + 90.0 }, 2, 12.0, 200.0 + radius * c, 200.0 + radius * s, &format!("({ch}) Tj")); + } + ops + }; + assert_eq!(page_text(&arc("CURVED STAMP TEXT", 150.0, true, 90.0)), "CURVED STAMP TEXT"); + assert_eq!(page_text(&arc("BOTTOM ARC WORDS", 220.0, false, 90.0)), "BOTTOM ARC WORDS"); + assert_eq!(page_text(&arc("ROUND SEAL OF THE COMPANY LIMITED", 180.0, true, 50.0)), "ROUND SEAL OF THE COMPANY LIMITED"); + } + + #[test] + fn sheared_text_keeps_its_word_gaps() { + // The baseline climbs at about 31° while the glyphs stay upright. + let ops = "BT /F1 12 Tf 1 0.6 0 1 100 200 Tm [(Sheared) -400 (first)] TJ 1 0.6 0 1 100 187 Tm [(Sheared) -400 (second)] TJ ET"; + assert_eq!(page_text(ops), "Sheared first\nSheared second"); + } + + #[test] + fn turned_text_keeps_its_place_in_the_reading_order() { + // A turned label between a heading and its caption reads between them, as it did when + // it was laid out with the horizontal text; a vertical body still follows its heading. + let ops = "BT /F1 14 Tf 1 0 0 1 50 360 Tm (Heading above) Tj ET\n".to_string() + + &turned(30.0, 1, 12.0, 80.0, 250.0, "(Turned label) Tj") + + "BT /F1 12 Tf 1 0 0 1 50 150 Tm (Caption below) Tj ET\n"; + assert_eq!(page_text(&ops), "Heading above\nTurned label\nCaption below"); + } + + #[test] + fn turned_glyphs_keep_their_shape_and_direction() { + // 30° counter-clockwise on the page is 30° up from the x axis in view space (y down). + let pdf = text_page(&(turned(30.0, 1, 20.0, 100.0, 100.0, "(Ab) Tj") + "BT /F1 20 Tf 1 0 0.5 1 100 300 Tm (Ab) Tj ET")); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap(); + assert_eq!(text.plain_text(), "Ab\nAb"); + for g in &text.glyphs { + assert!(g.quad.iter().flatten().chain(&g.direction).chain(&g.rect).all(|c| c.is_finite()), "{g:?}"); + let bounds = g.quad.iter().fold([f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY], |r, p| { + [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])] + }); + assert_eq!(g.rect, bounds, "the box bounds the quad"); + let [x, y] = [g.quad[1][0] - g.quad[0][0], g.quad[1][1] - g.quad[0][1]]; + assert!( + (x * g.direction[1] - y * g.direction[0]).abs() < 1e-3 && x * g.direction[0] + y * g.direction[1] > 0.0, + "quad[0] → quad[1] runs along the direction: {g:?}" + ); + } + let (skewed, turned) = (&text.glyphs[0], &text.glyphs[2]); + assert!((turned.direction[0] - 30f32.to_radians().cos()).abs() < 1e-6 && (turned.direction[1] + 0.5).abs() < 1e-6, "{turned:?}"); + assert_eq!(skewed.direction, [1.0, 0.0]); + let slant = skewed.quad[3][0] - skewed.quad[0][0]; + assert!((slant - 10.0).abs() < 1e-3, "the skewed glyph's upright edge leans by half its 20 pt height: {skewed:?}"); + } }