M2: text turned at any angle reads as lines along its own baseline (#735)

* M2: text turned at any angle reads as lines along its own baseline

The text layer sorted every glyph into the nearest of four quarter turns
and laid it out by its axis-aligned box. Text at 30-60 degrees lost its
word gaps, split into one line per word in reverse order, or interleaved
two neighbouring lines letter by letter; text set along a curve came out
in fragments.

Each glyph now keeps its drawn em box as a quad and its writing
direction. Glyphs within the layout's word-gap tolerance (sin 0.15) of a
quarter turn take the existing path unchanged. Other glyphs are laid out
in a frame along their own baseline, one flow per direction; runs that
continue each other while turning (stamps, seals) are unrolled into one
line. Turned text keeps the place in the reading order that its first
glyph had before.

Horizontal, quarter-turn, skewed and near-axis text is bit-identical
(text, rect, line_of, space_before) over pdf_engine_test.pdf, the
crates' test PDFs and 300 synthetic pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M2: look up a turned glyph's direction among directions only, not every curved run

Curved runs can number about half the glyphs, which made grouping quadratic on hostile pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 06:19:54 -04:00
1 parent d94da2038a
commit 7fafadc3a2
1 file changed
+353 -38
+353 -38
View File
@@ -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<TextGlyph>; 4],
/// Every glyph in content order.
glyphs: Vec<Drawn>,
}
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<usize>> = Vec::new();
let mut straight: Vec<([f64; 2], Vec<usize>)> = 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<usize>; 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<usize>; 4]| {
let mut flows: Vec<Flow> = 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<Flow> = 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<usize>; 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<Option<TextGlyph>> = 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<u8>| {
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<u8>,
ids: Vec<usize>,
line_of: Vec<u32>,
space_before: Vec<bool>,
}
/// Lay out the page's glyphs `ids`, giving each the box at the same position in `rects`.
fn arrange_flow(glyphs: &[Drawn], ids: Vec<usize>, rects: Vec<[f32; 4]>, quarter: Option<u8>) -> 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<TextGlyph> = 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<Item = &'g Drawn>) -> 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<TextGlyph>) -> PageText {
let (order, line_of, space_before) = arrange(&glyphs);
let mut slots: Vec<Option<TextGlyph>> = 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<usize>, Vec<u32>, Vec<bool>) {
// 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<TextGlyph>) -> PageText {
}
}
}
if keep.iter().any(|k| !k) {
let mut it = keep.iter();
glyphs.retain(|_| *it.next().unwrap_or(&true));
}
let kept: Vec<usize> = (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<TextGlyph>) -> PageText {
let mut gaps: Vec<f32> = 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<TextGlyph>) -> PageText {
line += 1;
}
}
let mut slots: Vec<Option<TextGlyph>> = 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:?}");
}
}