diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index 27d0233bb..1916d9dd2 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -125,6 +125,30 @@ fn break_area_type_into_lines_paragraphs_and_glyphs() { } } +/// Tracked area type broken into lines or paragraphs stays where it was, however it is aligned: +/// a wrap after a hyphen or at a space becomes a line break or a paragraph's end, and the line +/// leaves the tracking after its last character out of its width there as it did at the wrap. +/// (Words chosen without a kerning pair across the wrap, which the pieces don't keep.) +#[test] +fn break_tracked_area_type_keeps_its_aligned_lines_in_place() { + for justify in ["center", "right"] { + for text_of in ["Mode-Mode-Mode", "Wave Wave Wave"] { + for by in ["paragraphs", "lines"] { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 40, "text": text_of, "size": 20, "area": {"width": 90, "height": 200}})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": 200})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": justify})).unwrap(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)); + assert!(lay.lines.len() > 1, "{text_of}: it wraps"); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let pieces = ids(&s.execute("addon.cleanup.breakText", &json!({"by": by})).unwrap()); + assert_same_places(&was, &glyphs(&s, &pieces)); + } + } + } +} + #[test] fn break_text_refuses_what_it_cannot_break() { let mut s = session(); diff --git a/crates/engine/src/cmd/fileio/affinity/layout.rs b/crates/engine/src/cmd/fileio/affinity/layout.rs index 8b6d4c4a8..262f688b1 100644 --- a/crates/engine/src/cmd/fileio/affinity/layout.rs +++ b/crates/engine/src/cmd/fileio/affinity/layout.rs @@ -157,7 +157,8 @@ pub(super) fn align_lines(obj: &mut TextObject) { return; } let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines; - let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); + // Each line as wide as VectorCraft aligns it: without the tracking after its last character. + let w = lines.iter().map(|l| l.x1 - l.trailing - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); if !(w > 0.0 && w < 1e6) { return; } diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 8d40002b3..0ff46b1c8 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -293,11 +293,20 @@ fn to_area(s: &mut Session, p: &Value) -> Result { continue; } let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + let mut b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + // Across, the frame spans the lines as they are aligned: from their starts to their ends + // without the tracking after their last characters (`LineInfo::trailing`), not to the + // ink, which tracking can pull past an advance. Area type aligns them alike. + let (x0, x1) = lay.lines.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, z), l| (a.min(l.x0), z.max(l.x1 - l.trailing))); + if x0.is_finite() && x1.is_finite() && x0 <= x1 { + (b.x0, b.x1) = (x0, x1); + } + // Line breaking counts that tracking: the slack holds the most of it. + let trailing = lay.lines.iter().map(|l| l.trailing).filter(|t| t.is_finite()).fold(0.0, f64::max); let size = t.first_style().size; // A little slack on the right so the text doesn't rewrap. let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0); - let slack = size * 0.5; + let slack = size * 0.5 + trailing; // Slack goes where the alignment leaves room, so the text doesn't move or rewrap. let (sl, sr) = match t.para_at(0).justify { Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index f49a88c80..6aa42f7d9 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2467,6 +2467,62 @@ fn convert_area_point_keeps_text_in_place_for_every_alignment() { } } +/// Each line's first glyph in the document, and the line count. +fn line_starts_doc(t: &TextObject) -> Vec { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + lay.lines.iter().map(|l| t.xf * lay.glyphs[l.glyph_start].origin).collect() +} + +/// Tracked type converted between point and area type stays where it is, however it is aligned, +/// as often as it goes round: an aligned line leaves the tracking after its last character out +/// of its width, and the frame Convert To Area Type makes ends where the lines are aligned, with +/// room for that tracking (line breaking counts it). Lines area type wraps at a space or after a +/// hyphen keep their place as the paragraphs Convert To Point Type makes of them (words without a +/// kerning pair across the wrap, which a new paragraph doesn't keep). +#[test] +fn converting_tracked_type_between_point_and_area_keeps_it_in_place() { + for j in ["left", "center", "right"] { + for tracking in [200.0, 1000.0, -100.0] { + let mut s = session(); + let id = + NodeId(s.execute("text.create", &json!({"x": 50, "y": 100, "text": "Wave\nWaves", "size": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": tracking})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert_eq!(before.len(), 2); + for round in 0..2 { + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let area = line_starts_doc(&obj(&s, id)); + assert_eq!(area.len(), 2, "{j} {tracking}: no rewrap"); + for (a, b) in area.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "{j} {tracking}, round {round}, area: {before:?} → {area:?}"); + } + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let point = line_starts_doc(&obj(&s, id)); + for (a, b) in point.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "{j} {tracking}, round {round}, point: {before:?} → {point:?}"); + } + } + } + // Area type wrapped at a space and after a hyphen, to point type. + for text in ["Wave Wave Wave", "Mode-Mode-Mode"] { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 50, "y": 100, "text": text, "size": 20, "area": {"width": 90, "height": 200}})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": 200})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert!(before.len() > 1, "{text}: it wraps"); + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let after = line_starts_doc(&obj(&s, id)); + assert_eq!(after.len(), before.len(), "{text}"); + for (a, b) in after.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "{j} {text}: {before:?} → {after:?}"); + } + } + } +} + #[test] fn convert_area_to_point_keeps_a_deeper_list_item_in_place() { let mut s = session(); diff --git a/crates/engine/tests/it/affinity_features.rs b/crates/engine/tests/it/affinity_features.rs index 511fbb25f..6a0338ee1 100644 --- a/crates/engine/tests/it/affinity_features.rs +++ b/crates/engine/tests/it/affinity_features.rs @@ -568,12 +568,14 @@ fn artistic_text_aligns_its_lines_within_the_longest() { assert!(matches!(t.kind, TextKind::Point)); let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines; let (wide, mid) = (&lines[0], &lines[1]); - let w = wide.x1 - wide.x0; + // Each line as wide as it is aligned: without the tracking after its last character. + let width = |l: &vectorcraft_text::LineInfo| l.x1 - l.trailing - l.x0; + let w = width(wide); assert!(w > 0.0); // The block stays where the first paragraph's alignment puts it on the anchor. let start = [0.0, -0.5, -1.0][first as usize] * w; assert!((wide.x0 - start).abs() < 1e-6, "{first}/{second}: {} vs {start}", wide.x0); - let expected = wide.x0 + (w - (mid.x1 - mid.x0)) * at; + let expected = wide.x0 + (w - width(mid)) * at; assert!((mid.x0 - expected).abs() < 1e-6, "{first}/{second}: {} vs {expected}", mid.x0); // Each paragraph keeps its own alignment. use vectorcraft_doc::text::Justify; diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 904b76cd1..b11ce6af3 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -1060,6 +1060,7 @@ impl Ctx { } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); } + keep_reader_anchors(&mut obj); // The placeholder covers the text (laid out when a paint server needs its bounding box). let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default(); let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0)); @@ -1264,6 +1265,55 @@ fn lines( (Justify::Left, left, None) } +/// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor` +/// aligns a line's advance, which ends after the letter spacing of its last character (SVG +/// export anchors lines so); VectorCraft aligns a line without that tracking +/// ([`vectorcraft_text::LineInfo::trailing`]). Each such line moves back by its share of it: the +/// type moves when every line moves alike, otherwise each paragraph's right indent takes the +/// tracking (a line of imported point type is a paragraph). Browsers add that letter spacing +/// today; CSS WG issue 1518 and Firefox bugs 1907305 and 1913554 may drop it at a line's end. +fn keep_reader_anchors(obj: &mut TextObject) { + let share = |j: Justify| match j { + Justify::Center => 0.5, + Justify::Right => 1.0, + _ => 0.0, + }; + // Laid out only when a line may move: tracked type with a centred or right-aligned paragraph. + if !matches!(obj.kind, TextKind::Point) + || obj.vertical + || obj.runs.iter().all(|r| r.style.tracking == 0.0) + || obj.paragraph_styles().iter().all(|p| share(p.justify) == 0.0) + { + return; + } + let plain = obj.plain_text(); + // Each line's paragraph, its tracking left out and how far that moves it. + let lines: Vec<(usize, f64, f64)> = measure(obj) + .lines + .iter() + .map(|l| { + let para = plain.get(..l.start).map_or(0, |before| before.matches('\n').count()); + let trailing = if l.trailing.is_finite() { l.trailing } else { 0.0 }; + (para, trailing, share(obj.para_at(para).justify) * trailing) + }) + .collect(); + let Some(&(_, _, first)) = lines.first() else { return }; + if first == 0.0 && lines.iter().all(|l| l.2 == 0.0) { + return; + } + if lines.iter().all(|l| (l.2 - first).abs() < 1e-9) { + obj.xf *= Affine::translate((-first, 0.0)); + return; + } + let mut styles = obj.paragraph_styles(); + for &(para, trailing, moves) in &lines { + if let Some(style) = styles.get_mut(para).filter(|_| moves != 0.0) { + style.right_indent += trailing; + } + } + obj.set_paragraph_styles(styles); +} + /// A left-aligned point text of `runs` at the origin (for measuring). fn with_runs(runs: Vec) -> TextObject { let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index a30bc193b..35289a0c1 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -245,6 +245,38 @@ fn per_character_positions_with_middle_anchor() { assert!(close(mid(0), 50.0, 0.05) && close(mid(1), 100.0, 0.05), "{} {}", mid(0), mid(1)); } +/// `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, +/// and VectorCraft aligns a line without that tracking: the imported lines move back by their +/// share of it and stay where a reader draws them, and a centred or right-aligned tracked line +/// exported and read back stays where it was (export anchors lines the same way). +#[test] +fn anchored_letter_spaced_lines_stay_where_a_reader_draws_them() { + let starts = |t: &TextObject| { + let lay = layout(FontDb::global(), t); + lay.lines.iter().map(|l| (t.xf * lay.glyphs[l.glyph_start].origin).x).collect::>() + }; + let advances = |t: &TextObject| { + let lay = layout(FontDb::global(), t); + lay.lines.iter().map(|l| lay.glyphs[l.glyph_start..l.glyph_end].iter().map(|g| g.advance).sum::()).collect::>() + }; + for (anchor, share) in [("middle", 0.5), ("end", 1.0)] { + let d = open(&format!(r#"WAVE"#)); + let t = only_text(&d); + let (start, advance) = (starts(t)[0], advances(t)[0]); + assert!(close(start, 200.0 - share * advance, 1e-6), "{anchor}: the line starts at {start}, not {}", 200.0 - share * advance); + let back = import(&vectorcraft_svg::export(&d, &vectorcraft_svg::ExportOptions::default())).unwrap(); + let again = starts(only_text(&back))[0]; + assert!(close(again, start, 1e-6), "{anchor}: read back, the line starts at {again}, not {start}"); + } + // Lines anchored differently: each moves by its own share. + let d = open( + r#"WaveLine"#, + ); + let t = only_text(&d); + let (s, a) = (starts(t), advances(t)); + assert!(close(s[0], 200.0 - a[0] / 2.0, 1e-6) && close(s[1], 200.0 - a[1], 1e-6), "{s:?} {a:?}"); +} + #[test] fn tspan_lines_dx_and_baseline_shift() { let d = open( diff --git a/crates/text/src/justify.rs b/crates/text/src/justify.rs index a3a141940..53de928be 100644 --- a/crates/text/src/justify.rs +++ b/crates/text/src/justify.rs @@ -89,6 +89,7 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g let base = g.adv; if g_des != 1.0 { g.adv *= g_des; + g.tracking *= g_des; g.sx *= g_des; g.dx *= g_des; g.kern_shift *= g_des; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 15b71ae23..11dcd1eeb 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -659,6 +659,7 @@ mod emit_kashida_mark_last { len: 2, run: 0, adv: 10.0, + tracking: 0.0, dx: 0.0, dy: 0.0, kern_shift: 0.0, @@ -719,6 +720,7 @@ mod emit_kashida_mark_last { len: 4, run: 1, adv: 0.0, + tracking: 0.0, dx: 0.0, dy: 0.0, kern_shift: 0.0, diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index e1d55eece..56c71c2f4 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -389,6 +389,9 @@ struct LineGeometry { ax1: f64, width: f64, render_width: f64, + /// The tracking after the line's last character, when that character ends the line (see + /// [`prepare_line_geometry`]); 0 otherwise. + trailing: f64, order: Vec, drawn: Vec, opt_l: f64, @@ -413,6 +416,9 @@ struct LineJustification { /// Right-aligned area type: the line's last tab, which takes the text after it to the right /// edge (the text before it sits at its stops, from the left edge). last_tab: Option, + /// The tracking after the line's last character that its alignment leaves out of its width: + /// [`LineGeometry::trailing`] for a line that isn't justified, 0 for one that is. + trailing: f64, } fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { @@ -582,6 +588,31 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData // ends with the empty line after it (`run_paragraph_lines`). let soft_return = position.end > index && sg.get(position.end - 1).is_some_and(|g| g.ch == '\u{2028}'); let last_of_para = position.end >= n && !soft_return; + // The tracking after the line's last character: an aligned line is measured without it. The + // page of users' `.ai` files sets a centred line of tracked type half that tracking right of + // where a line measured with it goes, and a right-aligned one the whole tracking right (in + // lines that end a paragraph). The last character is the last one the line draws before a + // paragraph's end, a forced line break, or the spaces the layout wraps the line at (they hang + // past it). Spaces or a tab typed before a paragraph's end or a forced line break are the + // line's last characters, and the letter before them keeps its tracking: the page counts it in + // the 2 lines of the files that end so. Forced line breaks and wraps aren't in the files; they + // take the rule so that turning a wrap into a forced line break or a new paragraph (Break + // Text, Convert To Point Type) keeps the line where it was. A line a hyphen ends, one whose + // last character hangs, and vertical and right-to-left lines keep the tracking. + let tail = sg.get(trimmed..position.end).unwrap_or_default(); + let ends_drawn = match tail.split_last() { + None => true, + Some((last, before)) if last.ch == '\u{2028}' => before.is_empty(), + // Spaces at a wrap: the paragraph goes on. + Some(_) => position.end < n, + }; + let trailing = trimmed + .checked_sub(1) + .and_then(|k| sg.get(k)) + .filter(|g| { + ends_drawn && trimmed > index && !g.level.is_rtl() && !cx.vertical && !paragraph.rtl && hyphen.is_none() && hang.is_none() && opt_r == 0.0 + }) + .map_or(0.0, |g| if g.tracking.is_finite() { g.tracking } else { 0.0 }); LineGeometry { position, index, @@ -593,6 +624,7 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData ax1, width, render_width, + trailing, order, drawn, opt_l, @@ -738,6 +770,9 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin // A tab ending the line counts: its text sits at the left edge, the tab up to the right one. .then(|| (line.index..line.position.end).rev().find(|&j| sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0))) .flatten(); + // An aligned line is as wide as its characters without the tracking after the last one. + let trailing = if justify || pushed_in { 0.0 } else { line.trailing }; + let width = width - trailing; let start_x = if justify || pushed_in || last_tab.is_some() { line.ax0 - line.opt_l } else if paragraph.regions.is_none() { @@ -753,7 +788,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab } + LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing } } /// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop @@ -780,10 +815,11 @@ fn tab_glyph<'p>( return (advance, leader); } // The width of what follows up to the line's end (nothing, when the tab ends the line), with - // the hyphen a hyphenated line ends with. + // the hyphen a hyphenated line ends with, and without the tracking after its last character, + // as a right-aligned line is measured. let after = sg.get(glyph_index + 1..line.trimmed).unwrap_or_default(); let hyphen = line.hyphen.as_ref().map_or(0.0, |g| g.adv); - let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim + hyphen }; + let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim + hyphen - justification.trailing }; ((line.ax1 + line.opt_r - rest - x).max(0.0), leader) } @@ -905,6 +941,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData baseline: position.baseline, x0: start_x, x1: x_end, + trailing: justification.trailing, ascent: position.m.asc, descent: position.m.desc, first_baseline: if pen.model == LeadingModel::EmBoxTop { diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index 398928ee9..ac5a53d47 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -190,6 +190,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, baseline: 0.0, x0: 0.0, x1: 0.0, + trailing: 0.0, ascent: m.0, descent: m.1, first_baseline: 0.0, @@ -417,6 +418,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, baseline: ps.y, x0: ps.x, x1: pe.x, + trailing: 0.0, ascent: m.0, descent: m.1, first_baseline: 0.0, diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index c94d25f89..920e802b7 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -16,6 +16,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { len, run: 0, adv, + tracking: 0.0, dx: 0.0, dy: 0.0, kern_shift: 0.0, diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 011bb6eb2..04b971dfb 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -285,6 +285,14 @@ pub struct LineInfo { pub x0: f64, /// Right edge of the line content, trailing spaces excluded. pub x1: f64, + /// The tracking after the line's last character (in `x1` and in its glyph's advance) that the + /// line's alignment leaves out: the line is aligned as if it ended this much before `x1` (a + /// centred line is centred and a right-aligned one, or the text after the last tab of + /// right-aligned area type, ends there; a left-aligned line starts where it would anyway). 0 + /// when spaces or a tab typed before a paragraph's end or a forced line break end the line, + /// when a hyphen ends it or its last character hangs, in justified lines and lines that push + /// characters in, in vertical and right-to-left lines, and in type on a path. + pub trailing: f64, pub ascent: f64, pub descent: f64, /// Area type: how far the line's baseline sits below the top of its frame cell (inside the diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index a58e8ef57..3c56b2c91 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -30,6 +30,10 @@ pub(crate) struct SGlyph { pub run: usize, /// Advance in points (tracking, manual kerning and horizontal scale applied). pub adv: f64, + /// The tracking `adv` holds after the glyph's character, in points: on the last glyph of its + /// cluster, 0 on the others. An aligned line leaves its last character's out of its width + /// ([`crate::layout`]). + pub tracking: f64, /// Offset from the pen position, y down. pub dx: f64, pub dy: f64, @@ -177,6 +181,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph { let mut h = g.clone(); h.gid = gid; h.adv = g.face.advance(gid) * g.sx; + h.tracking = 0.0; h.dx = 0.0; h.dy = 0.0; h.kern_shift = 0.0; @@ -976,9 +981,9 @@ fn inline_glyph( _ => (kurbo::Affine::IDENTITY, h, None), }; let spacing_em = size * spacing_scale(st, vertical); - let tracking = st.tracking / 1000.0 * spacing_em; + let tracking = Some(st.tracking / 1000.0 * spacing_em).filter(|t| t.is_finite()).unwrap_or(0.0); let manual_kern = st.kerning.map(|v| v / 1000.0 * spacing_em).filter(|k| k.is_finite()).unwrap_or(0.0); - let adv = width + if tracking.is_finite() { tracking } else { 0.0 } + manual_kern; + let adv = width + tracking + manual_kern; SGlyph { face: face.clone(), gid: 0, @@ -986,6 +991,7 @@ fn inline_glyph( len: range.len().max(1), run, adv: if adv.is_finite() { adv } else { 0.0 }, + tracking: if adv.is_finite() { tracking } else { 0.0 }, dx: 0.0, dy: 0.0, kern_shift: if level.is_rtl() && !vertical { manual_kern } else { 0.0 }, @@ -1396,6 +1402,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe None => gid, }; let mut kern_shift = 0.0; + let mut tracked = 0.0; if ch == SOFT_HYPHEN || ch == '\u{2028}' { adv = 0.0; } else { @@ -1407,8 +1414,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe kern_shift = manual_kern; } if last_in_cluster { - let tracking = gst.tracking / 1000.0 * spacing_em; - adv += tracking + manual_kern; + tracked = gst.tracking / 1000.0 * spacing_em; + adv += tracked + manual_kern; } } out.push(SGlyph { @@ -1418,6 +1425,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe len: end.saturating_sub(cl).max(1), run, adv, + tracking: tracked, dx: xo as f64 * k * hs, dy: -(yo as f64) * k * vs, kern_shift, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a7a0724ee..8934090d5 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -252,6 +252,137 @@ fn tracking_and_manual_kerning_take_the_horizontal_scale() { } } +/// A centred or right-aligned line is as wide as its characters without the tracking after the +/// last one, as the page of users' `.ai` files aligns tracked lines: centred half that tracking +/// right of a line measured with it, right-aligned the whole tracking right, so the last glyph's +/// own advance ends on the anchor or the frame's edge. The tracking is still in the last glyph's +/// advance (the caret after it, and a selection of it, keep it). +#[test] +fn an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width() { + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + // 200/1000 of a 20 pt em: 4 pt after each character, also at 50% Horizontal Scale with 400. + for (tracking, h_scale, after) in [(200.0, 100.0, 4.0), (-100.0, 100.0, -2.0), (400.0, 50.0, 4.0)] { + let st = CharStyle { tracking, h_scale, ..style(20.0) }; + let mut t = point("Wave", st.clone()); + let w = width(&t); + let start = |t: &TextObject| layout(db(), t).glyphs[0].origin.x; + let end = |t: &TextObject| { + let l = layout(db(), t); + let last = l.glyphs.last().unwrap(); + last.origin.x + last.advance + }; + assert!(near(start(&t), 0.0), "left aligned point type starts at its anchor"); + t.para.justify = Justify::Center; + assert!(near(start(&t), -(w - after) / 2.0), "{tracking} at {h_scale}%: centred from {}, not {}", -(w - after) / 2.0, start(&t)); + t.para.justify = Justify::Right; + assert!(near(end(&t) - after, 0.0), "{tracking} at {h_scale}%: right aligned, the last glyph ends {} past the anchor", end(&t) - after); + let plain = layout(db(), &TextObject { runs: vec![TextRun::new("Wave", CharStyle { tracking: 0.0, ..st.clone() })], ..t.clone() }); + let tracked = layout(db(), &t); + let last_advance = |l: &TextLayout| l.glyphs.last().unwrap().advance; + assert!(near(last_advance(&tracked) - last_advance(&plain), after), "{tracking}: the last glyph keeps its tracking"); + // Area type: the same in its frame. + let frame = Rect::new(0.0, 0.0, 300.0, 100.0); + let centred = area("Wave", st.clone(), frame, Justify::Center); + assert!(near(start(¢red), (300.0 - (w - after)) / 2.0), "{tracking}: centred area type starts at {}", start(¢red)); + let right = area("Wave", st.clone(), frame, Justify::Right); + assert!(near(end(&right) - after, 300.0), "{tracking}: right aligned area type ends {} past its edge", end(&right) - after - 300.0); + // A line that ends in a space keeps its last letter's tracking: the space comes after it. + let spaced = area("Wave ", st, frame, Justify::Right); + let l = layout(db(), &spaced); + let e = l.glyphs.iter().find(|g| g.byte == 3).unwrap(); + assert!(near(e.origin.x + e.advance, 300.0), "{tracking}: before a space the line ends at {}", e.origin.x + e.advance); + } +} + +/// Right-aligned area type with a tab sets the text after its last tab as it sets a right-aligned +/// line: the last glyph's advance, less the tracking after it, ends on the frame's edge. +#[test] +fn the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out() { + let frame = Rect::new(0.0, 0.0, 336.0, 100.0); + let lay = layout(db(), &area("Item\t24.00", CharStyle { tracking: 200.0, ..style(13.0) }, frame, Justify::Right)); + assert!(lay.glyphs[0].origin.x.abs() < 1e-6, "the item starts at the left edge: {}", lay.glyphs[0].origin.x); + let last = lay.glyphs.last().unwrap(); + let after = 200.0 / 1000.0 * 13.0; + let end = last.origin.x + last.advance - after; + assert!((end - 336.0).abs() < 1e-6, "the price ends at {end}, not on the edge"); +} + +/// Which lines leave the tracking after their last character out of their width +/// (`LineInfo::trailing`): those a paragraph's end, a forced line break or a wrap ends, also a +/// wrap at a space (the space hangs past the line). Spaces typed before a paragraph's end or a +/// forced line break end the line themselves, and the letter before them keeps its tracking. So +/// do a line a hyphen ends, one whose last character hangs (burasagari, Optical Margin +/// Alignment), a justified line, right-to-left lines and lines ending in a right-to-left run, +/// vertical type and type on a path. +#[test] +fn which_lines_leave_the_tracking_after_their_last_character_out() { + let st = CharStyle { tracking: 200.0, ..style(20.0) }; + // 200/1000 of a 20 pt em. + let t = 4.0; + let trailing = |o: &TextObject| layout(db(), o).lines.iter().map(|l| l.trailing).collect::>(); + let centred = |text: &str| { + let mut o = point(text, st.clone()); + o.para.justify = Justify::Center; + o + }; + let narrow = Rect::new(0.0, 0.0, 90.0, 300.0); + assert_eq!(trailing(&area("Wave Wave", st.clone(), narrow, Justify::Center)), [t, t], "a wrap at a space"); + assert_eq!(trailing(&area("Wave-Wave", st.clone(), narrow, Justify::Center)), [t, t], "a wrap after a hyphen"); + assert_eq!(trailing(¢red("Wave\u{2028}Wave")), [t, t], "a forced line break"); + assert_eq!(trailing(¢red("Wave \u{2028}Wave")), [0.0, t], "a space typed before a forced line break"); + assert_eq!(trailing(¢red("Wave \nWave")), [0.0, t], "a space typed before a paragraph's end"); + // Hyphenated: the hyphen ends the line, and has no tracking. + let mut hyphenated = area("internationalization", st.clone(), Rect::new(0.0, 0.0, 120.0, 300.0), Justify::Right); + hyphenated.para.hyphenate = true; + hyphenated.para.composer = vectorcraft_doc::Composer::SingleLine; + let lay = layout(db(), &hyphenated); + assert!(lay.lines.len() > 1 && lay.lines[0].end < "internationalization".len(), "the word is hyphenated: {:?}", lay.lines); + assert_eq!(lay.lines[0].trailing, 0.0, "a hyphenated line"); + // Justified: justification counts it (only the paragraph's last line is aligned). + assert_eq!(trailing(&area("Wave Wave", st.clone(), narrow, Justify::JustifyLeft)), [0.0, t], "a justified line"); + // A last character that hangs. + let mut hanging = area("あいう。", st.clone(), Rect::new(0.0, 0.0, 300.0, 100.0), Justify::Center); + hanging.para.burasagari = vectorcraft_doc::Burasagari::Forced; + assert_eq!(trailing(&hanging), [0.0], "a full stop burasagari hangs"); + let mut optical = area("Wave.", st.clone(), Rect::new(0.0, 0.0, 300.0, 100.0), Justify::Right); + optical.optical_margin = true; + assert_eq!(trailing(&optical), [0.0], "a full stop Optical Margin Alignment hangs"); + optical.runs = vec![TextRun::new("Wave", st.clone())]; + assert_eq!(trailing(&optical), [t], "a letter that doesn't hang"); + // Right to left: a right-to-left paragraph ending in Latin, a Latin one ending in Hebrew. + assert_eq!(trailing(¢red("שלום abc")), [0.0], "a right-to-left paragraph"); + assert_eq!(trailing(¢red("abc שלום")), [0.0], "a right-to-left run ending the line"); + // Vertical type. + let mut vertical = centred("Wave"); + vertical.vertical = true; + assert_eq!(trailing(&vertical), [0.0], "vertical type"); + // Type on a path, centred: as wide as all its advances. + let mut on_path = centred("Wave"); + let w = width(&on_path); + let mut line = kurbo::BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let lay = layout(db(), &on_path); + assert_eq!(lay.lines[0].trailing, 0.0, "type on a path"); + assert!((lay.glyphs[0].origin.x - (500.0 - w) / 2.0).abs() < 1e-6, "{}", lay.glyphs[0].origin.x); +} + +/// Justification… Desired Glyph Scaling scales each glyph's advance with the tracking in it: an +/// aligned line leaves out its last character's tracking as scaled. +#[test] +fn an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it() { + let mut t = point("Wave", CharStyle { tracking: 400.0, ..style(20.0) }); + t.para.justify = Justify::Center; + t.para.justification.glyph_scaling = [80.0, 90.0, 110.0]; + let lay = layout(db(), &t); + let w: f64 = lay.glyphs.iter().map(|g| g.advance).sum(); + // 400/1000 of a 20 pt em, at 90%. + let left_out = 8.0 * 0.9; + assert!((lay.lines[0].trailing - left_out).abs() < 1e-9, "{}", lay.lines[0].trailing); + assert!((lay.glyphs[0].origin.x + (w - left_out) / 2.0).abs() < 1e-6, "centred from {}", lay.glyphs[0].origin.x); +} + /// The factor tracking and manual kerning take from the Horizontal Scale: the scale in horizontal /// type, 1 in vertical type and for a scale that isn't a positive number. #[test] diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index d7cd58401..69f9bdb66 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -176,6 +176,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout baseline, x0: 0.0, x1: x, + trailing: 0.0, ascent: 10.0, descent: 3.0, first_baseline: 0.0, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index df33fa8b6..d42e770df 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **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 @@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | | 2026-11-11 | minor | `.ai` save (PDF-compatible editing copy and legacy) and `.ai`/EPS open: a layer's `Lb` colour index follows the file's numbering, where Magenta, Cyan and Yellow are 4, 5 and 6 and `LAYER_COLORS` has Yellow, Magenta and Cyan; Yellow, Magenta and Cyan layers no longer come out as Magenta, Cyan and Yellow, and the other 24 presets are unchanged (#6502, from the index and RGB pairs in users' files). `LAYER_COLORS` keeps its order, so `.vectorcraft` files and the Layers panel don't change. Scores and estimates unchanged. | | 2026-11-11 | minor | `.ai` save: stroked type writes its stroke's cap, join and miter limit into the text document (keys 61, 62 and 64, as `.ai` import reads them since #6500), so they come back when the file opens again and no longer count among the attributes of type left out; a run without a stroke doesn't count them either, as they draw nothing. Tests `ai_editing::tests::type_comes_back_as_type`, `an_unstroked_run_with_a_stroke_style_loses_nothing` (follow-up to #6472) | diff --git a/docs/type-parity.md b/docs/type-parity.md index 4013f8be1..033979a24 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | Tracking and manual kerning in horizontally scaled type: they are thousandths of the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), so at 50% a tracking of 100 adds 0.05 em after each character, half as much as at 100%. They were added at the full em whatever the scale, so narrow tracked type drifted further right with each letter. Illustrator's own layout in users' `.ai` files (used locally, not committed) spaces such type so, and PDF scales its character spacing by its horizontal scaling alike (ISO 32000-1 §9.4.4). Inline graphics take it after them too; vertical type keeps the full em, its advances down the column not all taking the Horizontal Scale. The automatic kerning the Character panel reads, Touch Type's limits and Artwork Cleanup's Combine Point Text measure in the same em, and Scale Text Attributes now keeps tracked type where a horizontal stretch of its transform drew it. Type that has both a Horizontal Scale other than 100% and tracking or manual kerning lays out differently than before in every document. Tests `tests::tracking_and_manual_kerning_take_the_horizontal_scale`, `tests::typo::optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking`, `tests::inline::shift_tracking_and_missing_symbols`, `tests::vertical::vertical_tracking_keeps_the_em_at_any_horizontal_scale` and the engine's `horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it`, `scale_text_attributes_keeps_tracked_type_where_it_was` and `combine_puts_horizontally_scaled_pieces_back_where_they_were`. Scores and estimates unchanged. | | 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. Scores and estimates unchanged. | | 2026-11-10 | minor | Type on a path (#3565): Direct Selection shows the direction handles of selected type on a path's path, as of a path selected whole, and drags them (`text.setPathHandle`; Alt: one side, Shift: 45° steps); the text reflows along the new curve. Scores and estimates unchanged. |