diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index dc7df0fe..0e2d478e 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -3,12 +3,15 @@ //! fifth of the size reads as a space). Fonts are named from the file's base font name (its //! subset prefix dropped, the family matched against the fonts available); a font that isn't //! available keeps its name, and the text shows in the fallback font until it is. +//! +//! Glyphs each turned a little further along a curve (type set on a path: apps write each glyph +//! with its own placement) make one type-on-a-path object, its path through the glyphs' baseline. use std::collections::HashMap; -use kurbo::{Affine, Point, Vec2}; +use kurbo::{Affine, BezPath, Point, Vec2}; use vectorcraft_color::Paint; -use vectorcraft_doc::{CharStyle, TextObject, TextRun}; +use vectorcraft_doc::{CharStyle, TextKind, TextObject, TextRun}; /// One glyph's placement: its baseline origin, advance direction, size and horizontal scale. #[derive(Clone, Copy, Debug)] @@ -93,12 +96,19 @@ pub(crate) struct TextLine { next: Point, /// The last glyph's origin. last: Point, + /// The last glyph's baseline direction. + last_dir: Vec2, + /// Each glyph's baseline origin, then where the last one ends. + baseline: Vec, runs: Vec<(Look, String)>, } +/// The most a glyph on a curve turns from the one before it (about 20°). +const CURVE_TURN_COS: f64 = 0.94; + impl TextLine { pub fn new(at: Placement, opacity: f32) -> Self { - Self { at, opacity, next: at.origin, last: at.origin, runs: vec![] } + Self { at, opacity, next: at.origin, last: at.origin, last_dir: at.dir, baseline: vec![], runs: vec![] } } /// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if @@ -106,13 +116,20 @@ impl TextLine { pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool { if let Some((last, run)) = self.runs.last_mut() { let size = self.at.size.max(at.size); - let gap = (at.origin - self.next).dot(self.at.dir); + let gap = (at.origin - self.next).dot(self.last_dir); let on_line = opacity == self.opacity && at.dir.dot(self.at.dir) > 0.9995 && (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15 && gap > -size * 0.3 && gap < size * 3.0; - if !on_line { + // Or on a curve: turned a little from the glyph before it, and starting about where + // that one ends. + let on_curve = !on_line + && opacity == self.opacity + && at.dir.dot(self.last_dir) > CURVE_TURN_COS + && at.dir.dot(self.last_dir) < 0.99999 + && (at.origin - self.next).hypot() < size * 0.6; + if !on_line && !on_curve { return false; } // A gap wider than a fifth of an em reads as a space. @@ -127,11 +144,38 @@ impl TextLine { } else { self.runs.push((look.clone(), text.to_string())); } - self.next = at.origin + self.at.dir * advance; + self.next = at.origin + at.dir * advance; self.last = at.origin; + self.last_dir = at.dir; + self.baseline.push(at.origin); true } + /// Has the baseline turned (more than about 3° from the first glyph to the last)? + fn curved(&self) -> bool { + self.last_dir.dot(self.at.dir) < 0.9986 + } + + /// A smooth path through the glyphs' baseline origins and the end of the last glyph + /// (Catmull-Rom through the points, as cubic Béziers). + fn baseline_path(&self) -> Option { + let mut pts = self.baseline.clone(); + pts.push(self.next); + let first = *pts.first()?; + if pts.len() < 3 { + return None; + } + let mut bp = BezPath::new(); + bp.move_to(first); + for (i, seg) in pts.windows(2).enumerate() { + let &[p1, p2] = seg else { continue }; + let p0 = *pts.get(i.wrapping_sub(1)).unwrap_or(&p1); + let p3 = *pts.get(i + 2).unwrap_or(&p2); + bp.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2); + } + Some(bp) + } + /// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill /// and stroke rendering): the run is stroked. `false`: it isn't that glyph. pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool { @@ -144,6 +188,8 @@ impl TextLine { /// The point type object and its opacity. pub fn finish(mut self) -> Option<(TextObject, f32)> { + // Turned along a curve: type on a path through the glyphs. + let path = if self.curved() { self.baseline_path() } else { None }; if let Some((_, last)) = self.runs.last_mut() { last.truncate(last.trim_end().len()); } @@ -156,6 +202,10 @@ impl TextLine { let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style); t.runs.extend(runs); t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(self.at.dir.atan2()); + if let Some(path) = path { + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0 }; + t.xf = Affine::IDENTITY; + } Some((t, self.opacity)) } } diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index 83329b3b..6b7120ec 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -359,3 +359,32 @@ fn appearance_items_are_untouched_for_plain_art() { assert!(nodes.iter().all(|n| n.mask.is_none() && !matches!(n.kind, NodeKind::Group { .. }))); assert!(matches!(nodes.iter().find(|n| n.appearance.stroke().is_some()).unwrap().appearance.items[0], AppearanceItem::Stroke(_))); } + +/// Type set on a path (each glyph placed and turned along a curve, as apps write it) comes back +/// as type on a path through the glyphs, not as a straight line; straight type stays point type. +#[test] +fn glyphs_turned_along_a_curve_become_type_on_a_path() { + // "ARCHING" along an arc of radius 100 around (150, 0), each glyph where the one before + // it ends (Helvetica's advances), clockwise from 135° (PDF y up: the arc bulges upwards). + let mut content = String::from("BT /F1 18 Tf "); + let mut a = 135f64.to_radians(); + for (c, adv) in "ARCHING".chars().zip([667.0, 722.0, 722.0, 722.0, 278.0, 722.0, 778.0]) { + let (x, y) = (150.0 + 100.0 * a.cos(), 100.0 * a.sin()); + // The baseline's direction is the tangent, clockwise along the arc. + let (dx, dy) = (a.sin(), -a.cos()); + content.push_str(&format!("{dx:.5} {dy:.5} {:.5} {dx:.5} {x:.3} {y:.3} Tm ({c}) Tj ", -dy)); + a -= adv / 1000.0 * 18.0 / 100.0; + } + content.push_str("ET"); + let t = texts(&open(&one_page(&content, HELVETICA, &[]))); + assert_eq!(t.len(), 1, "one text object: {t:?}"); + let text: String = t[0].runs.iter().map(|r| r.text.as_str()).collect(); + assert_eq!(text, "ARCHING"); + let vectorcraft_doc::TextKind::OnPath { path, .. } = &t[0].kind else { panic!("type on a path: {:?}", t[0].kind) }; + let b = path.bounds().unwrap(); + assert!(b.height() > 15.0, "the path curves: {b:?}"); + // Straight type with a slight rotation stays one point type object. + let straight = texts(&open(&one_page("BT /F1 18 Tf 0.98481 0.17365 -0.17365 0.98481 20 40 Tm (Straight) Tj ET", HELVETICA, &[]))); + assert_eq!(straight.len(), 1); + assert!(matches!(straight[0].kind, vectorcraft_doc::TextKind::Point), "{:?}", straight[0].kind); +}