Merge pull request #278 from p0lyg0n/fix/pdf-type-on-a-path

PDF/.ai import: type set on a path comes back as type on a path
This commit is contained in:
BFlat 2026-10-07 13:13:55 +07:00 committed by GitHub
commit 49dc3b90be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 92 additions and 7 deletions

View File

@ -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.

View File

@ -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<Point>,
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,42 @@ 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, an em
/// further (Catmull-Rom through the points, as cubic Béziers).
fn baseline_path(&self) -> Option<BezPath> {
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()?;
// 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();
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 +192,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 +206,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))
}
}

View File

@ -359,3 +359,34 @@ 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:?}");
// 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);
assert!(matches!(straight[0].kind, vectorcraft_doc::TextKind::Point), "{:?}", straight[0].kind);
}