From ea5a04504a29595e4417d3a3d833432ed05bd1b8 Mon Sep 17 00:00:00 2001 From: Hiroshi Inukai Date: Tue, 6 Oct 2026 19:45:26 +0900 Subject: [PATCH 1/2] PDF/.ai import: type set on a path comes back as type on a path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps write type on a path one glyph at a time, each placed and turned along the curve. The importer joined glyphs into a line only while they kept the first glyph's direction. Type on a gentle curve was flattened into straight lines, and type on a tighter curve fell apart into one object per glyph. A glyph that starts about where the previous one ends, turned a little from it (under about 20°), now continues the line. A line whose baseline turned more than about 3° becomes type on a path: its path is a smooth curve (Catmull-Rom as cubic Béziers) through the glyphs' baseline origins and the end of the last glyph. Spaces between words are measured along the last glyph. Straight type, rotated or not, imports as before. Test: "ARCHING" written glyph by glyph along an arc imports as one type-on-a-path object with that text and a curved path; slightly rotated straight type stays point type. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/import_text.rs | 62 ++++++++++++++++++++++--- crates/pdf/src/tests_import_fidelity.rs | 29 ++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) 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); +} From e2227849a13415b7747d814e1c76aebf618698e3 Mon Sep 17 00:00:00 2001 From: Hiroshi Inukai Date: Wed, 7 Oct 2026 08:37:57 +0900 Subject: [PATCH 2/2] PDF/.ai import: the last glyph of type on a path stays on its path The path rebuilt through the glyphs ended where the last glyph ends, so rounding in the spacing could push that glyph past the end, where type on a path stops ("OFFICIAL ISSUED" lost its D). The path now runs an em further. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/pdf/src/import_text.rs | 10 +++++++--- crates/pdf/src/tests_import_fidelity.rs | 2 ++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a16e5a51..73325f4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,7 +135,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export. - - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). + - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). - Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 0e2d478e..5b0110ce 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -156,13 +156,17 @@ impl TextLine { 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). + /// A smooth path through the glyphs' baseline origins and the end of the last glyph, an em + /// further (Catmull-Rom through the points, as cubic Béziers). fn baseline_path(&self) -> Option { let mut pts = self.baseline.clone(); + // On past the end of the last glyph by an em, so rounding in the spacing doesn't push it + // off the end of the path (where type on a path stops). pts.push(self.next); + pts.push(self.next + self.last_dir * self.at.size); let first = *pts.first()?; - if pts.len() < 3 { + // Finite placements far out can still overflow once an em is added: no path then. + if pts.len() < 3 || !pts.iter().all(|p| p.is_finite()) { return None; } let mut bp = BezPath::new(); diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index 6b7120ec..7b5ec3bd 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -383,6 +383,8 @@ fn glyphs_turned_along_a_curve_become_type_on_a_path() { 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:?}"); + // Every glyph fits on the path, the last one too. + assert_eq!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]).glyphs.len(), 7); // 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);