fix(ai-import): space type on a path round curves as the file lays it out

Type on a path from the editing data's text document came in at the
font's advances, while the file's own layout (and its page) closes the
glyphs up round the outside of a curve and opens them up round the
inside, even where the frame keeps no spacing. Round a circle the type
ran on past where the page ends it.

The spacing (Type on a Path Options > Spacing) that reproduces the
layout is now fitted from the layout's glyph positions, advances and
turns, using the text engine's own model, and set on the imported text
object. Corners and straight runs are left out of the fit.
This commit is contained in:
Don Omondi
2026-10-31 02:05:52 +07:00
committed by bflatastic
parent 7b4a65e231
commit 1a5f0fb099
3 changed files with 186 additions and 3 deletions
+106 -2
View File
@@ -281,6 +281,8 @@ pub(super) struct Story {
paras: Vec<(usize, Para)>,
/// `(characters, style)` in order.
runs: Vec<(usize, Style)>,
/// Type on a path: the spacing its first frame's layout implies ([`path_spacing`]).
path_spacing: f64,
}
#[derive(Clone, Debug)]
@@ -408,8 +410,11 @@ impl Texts {
return None;
}
// The first frame's layout, when the file keeps one: where its lines start.
let (mut anchor, mut lines) = ((0.0, 0.0), vec![]);
let (mut anchor, mut lines, mut path_spacing) = ((0.0, 0.0), vec![], 0.0);
if let Some(f) = view.get("2").and_then(|l| find_node(l, "F")) {
if matches!(frames.first(), Some(FrameKind::OnPath { .. })) {
path_spacing = self::path_spacing(f);
}
if let Some(a) = f.get("0").and_then(|a| a.get("0")).and_then(Val::nums)
&& let [x, y, ..] = a.as_slice()
{
@@ -441,7 +446,7 @@ impl Texts {
if runs.is_empty() {
runs.push((text.chars().count(), self.style(None)));
}
Some(Story { text, anchor, lines, frames, paras, runs })
Some(Story { text, anchor, lines, frames, paras, runs, path_spacing })
}
/// A paragraph's attributes: its own keys, else the document's.
@@ -572,6 +577,50 @@ fn length_fraction(segments: &[[f64; 8]], t: f64) -> f64 {
((before + within) / total).clamp(0.0, 1.0)
}
/// The spacing (Type on a Path Options › Spacing) that the layout `f` of type on a path implies.
/// The file closes its glyphs up round the outside of a curve and opens them up round the inside,
/// even where its frame keeps no spacing; the text engine spaces glyphs that way by the spacing
/// (`vectorcraft_text`'s `path_steps`): between two glyphs' centres, the path's length `s`, their
/// advances' mean `a` and how far it turns `t` (away from their tops) make `s + t × spacing = a`.
/// Fitted over every pair of the layout's glyphs; none where the path doesn't turn under them.
fn path_spacing(f: &Val) -> f64 {
let mut found = vec![];
find_nodes(f, "G", &mut found);
let (mut sum, mut turns) = (0.0, 0.0);
for g in found {
// Each glyph's origin, its matrix (along its baseline, then towards its foot) and where it
// ends along the line; the lists end with where the line ends.
let Some(at) = g.get("21") else { continue };
let list = |k: &str| at.get(k).and_then(Val::nums).unwrap_or_default();
let (xs, ys, matrices, ends) = (list("1"), list("2"), list("3"), list("4"));
let glyphs: Vec<(Point, f64, kurbo::Vec2, kurbo::Vec2)> = xs
.iter()
.zip(&ys)
.zip(matrices.as_chunks::<6>().0)
.zip(ends.windows(2))
.map(|(((x, y), [a, b, c, d, ..]), e)| {
let advance = e.get(1).zip(e.first()).map_or(0.0, |(e1, e0)| e1 - e0);
let along = kurbo::Vec2::new(*a, *b);
(Point::new(*x, *y) + along * (advance * 0.5), advance, along, kurbo::Vec2::new(*c, *d))
})
.collect();
for w in glyphs.windows(2) {
let [(p, a0, along, foot), (q, a1, next, _)] = w else { continue };
let turn = next.dot(*foot).atan2(next.dot(*along));
// The length of the arc between the centres (the path is taken to be round there). A
// corner (a turn of more than a radian between two glyphs) isn't round: left out.
let half = turn.abs() * 0.5;
let length = if half > 1e-9 { (*q - *p).hypot() * half / half.sin() } else { (*q - *p).hypot() };
if turn.abs() <= 1.0 && length.is_finite() && a0.is_finite() && a1.is_finite() {
sum += ((a0 + a1) * 0.5 - length) * turn;
turns += turn * turn;
}
}
}
let spacing = sum / turns;
if turns > 1e-6 && spacing.is_finite() { spacing.clamp(-1000.0, 1000.0) } else { 0.0 }
}
impl Story {
/// How many bytes of text the story has.
pub(super) fn len(&self) -> usize {
@@ -711,6 +760,9 @@ impl Story {
if paras.len() > 1 && paras.iter().any(|p| *p != t.para) {
t.paras = paras;
}
if matches!(kind, TextKind::OnPath { .. }) && !threaded {
t.path_spacing = self.path_spacing * scale;
}
t.kind = kind;
t.xf = xf;
Some(Node::new(id, NodeKind::Text(Box::new(t))))
@@ -1015,9 +1067,61 @@ mod tests {
caps: 0,
},
)],
path_spacing: 0.0,
}
}
/// A layout of glyphs `advances` wide round a circle of radius `radius` (y down), from its left,
/// outside it (their feet towards its centre) or inside it, the length of the circle between
/// their centres `fraction` of their advances' mean.
fn round_layout(advances: &[f64], radius: f64, outside: bool, fraction: f64) -> Val {
let sign = if outside { 1.0 } else { -1.0 };
let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![0.0]);
let mut angle = std::f64::consts::PI;
for (i, a) in advances.iter().enumerate() {
if i > 0 {
angle += sign * (advances[i - 1] + a) * 0.5 * fraction / radius;
}
// Outside, clockwise on the page (y down); inside, the other way round.
let along = (-angle.sin() * sign, angle.cos() * sign);
let centre = (radius * angle.cos(), radius * angle.sin());
xs.push(centre.0 - along.0 * a * 0.5);
ys.push(centre.1 - along.1 * a * 0.5);
matrices.extend([along.0, along.1, -along.1, along.0, 0.0, 0.0]);
ends.push(ends[i] + a);
}
let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::<Vec<_>>().join(" ");
let doc = format!(
"/0 << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >>",
nums(&xs),
nums(&ys),
nums(&matrices),
nums(&ends)
);
read(doc.as_bytes()).unwrap().get("0").unwrap().clone()
}
/// The file lays type on a path out closer round the outside of a curve and further apart round
/// the inside: the spacing that does that is read from its layout, as the text engine's model
/// has it (length + turn × spacing = advance).
#[test]
fn the_layout_of_type_on_a_path_gives_its_spacing() {
let advances = [20.0, 30.0, 10.0, 25.0, 20.0];
// Outside a circle of radius 100, 10 above the path: the centres are 100/110 of the advances apart.
let spacing = path_spacing(&round_layout(&advances, 100.0, true, 100.0 / 110.0));
assert!((spacing - 10.0).abs() < 1e-6, "{spacing}");
// Inside it, 20 above the path: 100/80 of them.
let spacing = path_spacing(&round_layout(&advances, 100.0, false, 100.0 / 80.0));
assert!((spacing - 20.0).abs() < 1e-6, "{spacing}");
// At their advances: none.
assert!(path_spacing(&round_layout(&advances, 100.0, true, 1.0)).abs() < 1e-6);
// Corners (here a turn of about four radians from glyph to glyph) aren't taken for a curve.
assert_eq!(path_spacing(&round_layout(&advances, 5.0, true, 1.0)), 0.0);
// A straight line turns nowhere, and a layout without glyphs says nothing.
assert_eq!(path_spacing(&round_layout(&advances, 1e12, true, 0.9)), 0.0);
assert_eq!(path_spacing(&read(b"/99 /F").unwrap()), 0.0);
}
/// The leading of the text object a story comes in as.
fn leading_of(story: &Story) -> Option<f64> {
let node = story.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY)?;
+78
View File
@@ -1490,6 +1490,84 @@ fn area_type_and_type_on_a_path_come_from_the_text_document() {
assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings);
}
/// A story of `text` on frame 0 whose layout sets glyphs `advance` wide round the outside of a
/// circle of radius `radius`, the circle's length between their centres `fraction` of an advance.
fn round_story(text: &str, advance: f64, radius: f64, fraction: f64) -> String {
let n = text.chars().count();
let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![]);
for i in 0..=n {
// From the circle's left, clockwise on the canvas (y down), feet towards its centre.
let angle = std::f64::consts::PI + i as f64 * advance * fraction / radius;
let along = (-angle.sin(), angle.cos());
xs.push(radius * angle.cos() - along.0 * advance * 0.5);
ys.push(radius * angle.sin() - along.1 * advance * 0.5);
matrices.push(format!("{} {} {} {} 0 0", along.0, along.1, -along.1, along.0));
ends.push(i as f64 * advance);
}
let nums = |v: &[f64]| v.iter().map(|x| format!("{x}")).collect::<Vec<_>>().join(" ");
format!(
"<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {n} >> ] >> \
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 12.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 0 >> ] \
/2 [ << /99 /PC /6 [ << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >> ] >> ] >> >>",
nums(&xs),
nums(&ys),
matrices.join(" "),
nums(&ends)
)
}
/// The file closes type up round the outside of a curve (its layout puts the glyphs' centres
/// closer than their advances), though the frame has no spacing of its own: the type comes in
/// spaced as the layout is, not at its advances, so it doesn't run on past where the page ends it.
#[test]
fn type_round_a_circle_is_spaced_as_the_file_lays_it_out() {
// A circle of radius 40 round the middle of the page, in four cubics from its right, clockwise
// on the page; the type from its left, over the top, round almost all of it.
let k = 40.0 * 0.552_284_75;
let points = [
(90.0, 50.0),
(90.0, 50.0 - k),
(50.0 + k, 10.0),
(50.0, 10.0),
(50.0 - k, 10.0),
(10.0, 50.0 - k),
(10.0, 50.0),
(10.0, 50.0 + k),
(50.0 - k, 90.0),
(50.0, 90.0),
(50.0 + k, 90.0),
(90.0, 50.0 + k),
(90.0, 50.0),
];
let all: Vec<(f64, f64)> = points.iter().map(|(x, y)| canvas(*x, *y)).collect();
let segment = |i: usize| (0..4).map(|j| format!("{} {}", all[i * 3 + j].0, all[i * 3 + j].1)).collect::<Vec<_>>().join(" ");
let frame =
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 2 /6 [ 2.0 5.99 ] >> >> >>", (0..4).map(segment).collect::<Vec<_>>().join(" "));
// Glyphs 5 apart, their centres 5 × 40/48 apart round the circle: spaced 8 above the path.
let doc = document_of(&[frame], &[round_story("abcdefgh", 5.0, 40.0, 40.0 / 48.0)]);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let texts = text_of(&r.document.layers[1]);
assert_eq!(texts.len(), 1, "{:?}", r.warnings);
let t = texts[0];
assert!(matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. }), "{:?}", t.kind);
assert!((t.path_spacing - 8.0).abs() < 0.05, "{}", t.path_spacing);
// Laid out, the glyphs' centres are 40/48 of their advances apart round the circle.
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let centres: Vec<(f64, f64)> = layout
.glyphs
.iter()
.map(|g| (g.origin.x + g.angle.cos() * g.advance * 0.5 - 50.0, g.origin.y + g.angle.sin() * g.advance * 0.5 - 50.0, g.advance))
.map(|(x, y, a)| (y.atan2(x), a))
.collect();
assert_eq!(centres.len(), 8);
for w in centres.windows(2) {
let turn = (w[1].0 - w[0].0 + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
let ratio = turn.abs() * 40.0 / ((w[0].1 + w[1].1) * 0.5);
assert!((ratio - 40.0 / 48.0).abs() < 0.01, "{ratio}");
}
}
#[test]
fn threaded_area_type_flows_through_its_frames() {
let frames = [
+2 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: placed files go where they draw closer to the page) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: type on a path spaced round curves as the file lays it out) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | `.ai`/EPS editing data: type on a path is spaced round curves as the file's own layout spaces it, closer round the outside and further apart round the inside, though its frame keeps no spacing: the Type on a Path Options › Spacing that does that is fitted from the layout's glyph positions, where it came in at the font's advances and ran on past where the page ends it. In 8 files of a user's set with type round curves (19 stories), the fitted spacing is a fifth of the line's ascent outside a curve (17 stories) and half of it inside (2), and the 4 files whose pages were measured space their glyphs as their layouts do. Own-fixture tests in `import::tests_native` and `import::ate`; scores unchanged |
| 2026-10-30 | minor | `.ai` import: a placed (linked) file whose data gives two placements is put at the corner of its box when that draws closer to the page and the layers still match it, not only when the first placement fails the comparison: a small logo in the wrong place differed too little to fail, and stayed there. Test `import::tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`; scores unchanged |
| 2026-10-30 | minor | `.ai` import: a file whose layers hold placed (linked) PDFs and no text object comes in from its layers when all of its page's type lies inside the placed files (at either of their placements): the PDF part draws each placed file, type and all, where the layers show its preview, so that type needs no text object. Such a file came in as its PDF part, saying its layers had no text objects for its page's text; type outside the placed files still needs one. Own-fixture test in `import::tests_native`; scores unchanged |
| 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh takes the transparency (`Xy`) written right after its `/End X!`, before its `Xd` and `XW`, as a group takes the one after its `U`; it was read as the next object's, so a Multiply shadow mesh painted opaque white over the art under it, where the file's page shows the shadow. A user's file now draws such a mesh as its page does. Own-fixture test in `import::ai::tests`; scores unchanged |