From df50bbf0f3706c15aaac650dacf540e570a3d1dd Mon Sep 17 00:00:00 2001 From: jmverges Date: Wed, 11 Nov 2026 06:32:40 +0000 Subject: [PATCH 1/2] Type: tracking and manual kerning take the Horizontal Scale In horizontal type, tracking and manual kerning are now thousandths of the em as the Horizontal Scale draws it: at 50%, a tracking of 100 adds 0.05 em after each character. They were added at the full em while the glyph advances took the scale, so narrow tracked type drifted from the page of users' .ai files letter by letter. That page writes such type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 9.4.4). Vertical type keeps the full em: its advances down the column don't all take the scale. vectorcraft_text::spacing_scale gives the factor. Shaping, inline graphics and the Optical pairs use it, and so do the conversions from points back to thousandths of an em: the automatic kerning the Character panel reads and Touch Type's limits (glyph_em), Combine Point Text, and PDF import, whose tracking is now the file's Tc. Scale Text Attributes now keeps tracked type where a horizontal stretch drew it. SVG export writes letter-spacing in thousandths of the font size it writes (the vertical scale), so uniformly scaled type keeps its spacing in a viewer. Type with both a Horizontal Scale other than 100% and tracking or manual kerning lays out differently than before, in every document. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/text/char_style.rs | 4 +- crates/engine/src/cmd/addons/cleanup/text.rs | 6 ++- .../src/cmd/addons/cleanup/text/tests.rs | 18 +++++++++ crates/engine/src/cmd/textedit.rs | 10 +++-- crates/engine/src/tests/textedit.rs | 31 ++++++++++++++ crates/engine/src/tests/typescale.rs | 24 +++++++++++ crates/pdf/src/import_text.rs | 29 +++++++++----- crates/pdf/src/tests/text_placement.rs | 15 +++++++ crates/svg/src/css.rs | 7 +++- crates/svg/tests/it/text_layout.rs | 12 ++++++ crates/text/src/lib.rs | 1 + crates/text/src/shape.rs | 34 ++++++++++++---- crates/text/src/tests.rs | 40 +++++++++++++++++++ crates/text/src/tests/inline.rs | 4 ++ crates/text/src/tests/typo.rs | 18 +++++++++ crates/text/src/tests/vertical.rs | 14 +++++++ docs/file-format-parity.md | 3 +- docs/type-parity.md | 3 +- 18 files changed, 247 insertions(+), 26 deletions(-) diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 67ccb60a4..fd828c79a 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -22,10 +22,10 @@ pub struct CharStyle { /// Leading in points; None = Auto (120% of size). #[serde(default, skip_serializing_if = "Option::is_none")] pub leading: Option, - /// Tracking in 1/1000 em. + /// Tracking in 1/1000 em; in horizontal type, of the em as [`Self::h_scale`] draws it. #[serde(default)] pub tracking: f64, - /// Manual pair spacing in 1/1000 em; None uses the automatic mode. + /// Manual pair spacing in 1/1000 em, as [`Self::tracking`]; None uses the automatic mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub kerning: Option, /// Use glyph shapes for automatic pair spacing; false uses font metrics. diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 73b21356b..6e6abfaa4 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -544,7 +544,11 @@ fn merge(chain: &[&Piece]) -> std::result::Result { for (k, w) in off.windows(2).enumerate() { let (delta, Some(&(s, e))) = (w[1] - w[0], before.get(k)) else { continue }; if delta.abs() > 1e-9 { - edit::style_range(&mut m.runs, s, e, |st| st.tracking += delta / st.size.max(1e-6) * 1000.0); + // Thousandths of the em as the Horizontal Scale draws it (`spacing_scale`). + let vertical = m.vertical; + edit::style_range(&mut m.runs, s, e, |st| { + st.tracking += delta / (st.size * vectorcraft_text::spacing_scale(st, vertical)).max(1e-6) * 1000.0; + }); if !TRACKING.contains(&edit::style_at(&m.runs, s).tracking) { return Err(k + 1); } diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index c75446b10..27d0233bb 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -176,6 +176,24 @@ fn combine_joins_only_pieces_on_one_line_turned_alike() { assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine")); } +/// Pieces in horizontally scaled type: the tracking that puts each back where it was is measured in +/// the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), as layout spaces it. +#[test] +fn combine_puts_horizontally_scaled_pieces_back_where_they_were() { + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 160, "y": 100, "text": "Right", "size": 20})); + for id in [a, b] { + s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 200})).unwrap(); + } + let was = glyphs(&s, &[a, b]); + select(&mut s, &[a, b]); + let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap(); + assert_eq!(ids(&r), [a]); + assert_eq!(obj(&s, a).plain_text(), "Left Right"); + assert_same_places(&was, &glyphs(&s, &[a])); +} + /// Join Point Text makes one object of stacked one-line pieces: lines top to bottom as paragraphs, /// pieces on a line left to right, each line's baseline where it was (its leading is the distance /// from the line above), in one undo step. A piece turned unlike the top-left one stays. diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index c315c2a1c..2650aa9b2 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1087,12 +1087,16 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result { } /// The em a laid-out glyph's kerning and tracking are measured in, in text space: the size it -/// was shaped at (superscript, small caps and Shrink Text to Fit included). +/// was shaped at (superscript, small caps and Shrink Text to Fit included), in horizontal type +/// as its Horizontal Scale draws it ([`vectorcraft_text::spacing_scale`]). fn glyph_em(db: &vectorcraft_text::FontDb, t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option { let face = db.face_by_id(g.font_id)?; - let h_scale = edit::slice_runs(&t.runs, g.byte, g.byte + g.len).first()?.style.h_scale / 100.0; + let runs = edit::slice_runs(&t.runs, g.byte, g.byte + g.len); + let style = &runs.first()?.style; + // The glyph's own scale takes in the Horizontal Scale. + let h_scale = style.h_scale / 100.0; let [a, b, ..] = g.xf.as_coeffs(); - Some(a.hypot(b) * face.units_per_em() / h_scale).filter(|em| em.is_finite() && *em > 0.0) + Some(a.hypot(b) * face.units_per_em() / h_scale * vectorcraft_text::spacing_scale(style, t.vertical)).filter(|em| em.is_finite() && *em > 0.0) } /// The current pair correction in thousandths of an em, including an automatic correction. diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index e584e3e32..e04c2c728 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -280,6 +280,37 @@ fn metrics_pair_step_preserves_the_drawn_auto_position() { assert!((moved - s.prefs.tracking_increment * 0.1).abs() < 0.01, "V moves by one kerning increment: {moved}"); } +/// At 50% Horizontal Scale the font's pair reads as the same thousandths of an em as at 100%: +/// kerning is measured in the em as the scale draws it (`vectorcraft_text::spacing_scale`), so the +/// value read keeps V where the font's kerning draws it, and a step moves V half as far as at 100%. +#[test] +fn horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it() { + use vectorcraft_text::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font}; + + let db = vectorcraft_text::FontDb::global(); + db.add_font(pair_kerning_font().unwrap()); + let mut s = session(); + let id = NodeId( + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "AV", "size": 100, "font": PAIR_KERNING_FAMILY})).unwrap()["id"].as_u64().unwrap(), + ); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 2, "hScale": 50})).unwrap(); + let before = obj(&s, id); + assert_eq!(before.runs[0].style.h_scale, 50.0); + let auto = vectorcraft_text::layout(db, &before); + let drawn_v = |layout: &vectorcraft_text::TextLayout| layout.glyphs.iter().find(|g| g.byte == 1).unwrap().xf.as_coeffs()[4]; + let read = crate::cmd::effective_kerning(db, &before, &auto, 0, 1); + assert!((read + 160.0).abs() < 0.01, "the font's -160/1000 em: {read}"); + let mut same = before.clone(); + vectorcraft_text::edit::style_range(&mut same.runs, 0, 1, |st| st.kerning = Some(read)); + assert!((drawn_v(&vectorcraft_text::layout(db, &same)) - drawn_v(&auto)).abs() < 0.01); + + s.execute("type.step", &json!({"id": id.0, "start": 1, "end": 1, "attribute": "kerning", "by": 1})).unwrap(); + let after = obj(&s, id); + assert!((after.runs[0].style.kerning.unwrap() + 160.0 - s.prefs.tracking_increment).abs() < 0.01); + let moved = drawn_v(&vectorcraft_text::layout(db, &after)) - drawn_v(&auto); + assert!((moved - s.prefs.tracking_increment * 0.05).abs() < 0.01, "V moves by half a kerning increment at 100%: {moved}"); +} + #[test] fn effective_auto_kerning_at_area_line_end_survives_unkerned_wrap() { use vectorcraft_text::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font}; diff --git a/crates/engine/src/tests/typescale.rs b/crates/engine/src/tests/typescale.rs index 5bc09827b..f96193a6c 100644 --- a/crates/engine/src/tests/typescale.rs +++ b/crates/engine/src/tests/typescale.rs @@ -345,3 +345,27 @@ fn scale_text_attributes_bakes_group_scale_and_undoes() { s.execute("edit.undo", &json!({})).unwrap(); near(text(&s, id).runs[0].style.size, 12.0); } + +/// Scale Text Attributes bakes a horizontal stretch into the Horizontal Scale: tracked type stays +/// where the stretch drew it, as tracking is measured in the em the Horizontal Scale draws +/// (`vectorcraft_text::spacing_scale`). It used to lose the stretch's share of the tracking. +#[test] +fn scale_text_attributes_keeps_tracked_type_where_it_was() { + let mut s = session(); + let id = create(&mut s, 20.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": 300})).unwrap(); + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let pens = |s: &Session| { + let t = text(s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + lay.glyphs.iter().map(|g| t.xf * (g.xf * Point::ZERO)).collect::>() + }; + let before = pens(&s); + s.execute("type.scaleTextAttributes", &json!({})).unwrap(); + near(text(&s, id).runs[0].style.h_scale, 200.0); + let after = pens(&s); + assert_eq!(before.len(), after.len()); + for (b, a) in before.iter().zip(&after) { + assert!((*a - *b).hypot() < 1e-6, "{before:?} {after:?}"); + } +} diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index a90d6a33b..b7163c0e5 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -633,7 +633,7 @@ impl TextLine { let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum(); let size = self.at.size; if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 { - let (tracking, spaces) = spacing(&laid, &[], length - natural, chars, size); + let (tracking, spaces) = spacing(&laid, &[], length - natural, chars, size, spacing_em(&t, size)); t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces); } } @@ -675,7 +675,7 @@ impl TextLine { // left in the file's order. let total: usize = t.runs.iter().map(|r| r.text.len()).sum(); let gaps: Vec<(usize, f64)> = if t.para.direction.is_none() { gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] }; - let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size); + let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size, spacing_em(&t, size)); t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces); // Tracked: laid out again for its bounds. bounds = vectorcraft_text::layout(db, &t).bounds; @@ -701,16 +701,19 @@ fn pens(laid: &TextLayout, chars: &[usize]) -> Vec> { } /// `t`'s runs with kerning `kern` and each character's tracking from `own` (points, one per -/// character, in thousandths of each run's em), characters alike kept in one run. +/// character, in thousandths of each run's em as its Horizontal Scale draws it), characters alike +/// kept in one run. fn restyled(t: &TextObject, kern: Option, own: &[f64]) -> Vec { let mut out: Vec = Vec::with_capacity(t.runs.len()); // The run of `t` the last character came from. let mut from = None; let mut k = 0; for (i, r) in t.runs.iter().enumerate() { - let size = r.style.size.max(1e-3); + // A PDF scales its character spacing by its horizontal scaling (ISO 32000-1 §9.4.4), as + // layout scales tracking ([`vectorcraft_text::spacing_scale`]). + let em = (r.style.size * vectorcraft_text::spacing_scale(&r.style, t.vertical)).max(1e-3); for c in r.text.chars() { - let tracking = own.get(k).map_or(0.0, |e| (e / size * 1000.0).clamp(-1000.0, 10000.0)); + let tracking = own.get(k).map_or(0.0, |e| (e / em * 1000.0).clamp(-1000.0, 10000.0)); k += 1; // Hundredths of a thousandth of an em: no drift along a line, no noise in the panels. let tracking = (tracking * 100.0).round() / 100.0; @@ -971,8 +974,9 @@ pub(crate) fn median(mut v: Vec) -> Option { /// as `laid`, `extra` points longer, as the file set it: the same after every character, except /// the spaces read from gaps or drawn between glyphs (`gaps`: byte offset, gap in points) where /// that would put the glyphs after them more than a tenth of an em off (a barcode's digits, set in -/// groups apart). Those take their own tracking, given with their byte offsets. -fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, size: f64) -> (f64, Vec<(usize, f64)>) { +/// groups apart). Those take their own tracking, given with their byte offsets. `em`: the em the +/// tracking is thousandths of, in points ([`spacing_em`]). +fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, size: f64, em: f64) -> (f64, Vec<(usize, f64)>) { let pairs = chars.saturating_sub(1).max(1) as f64; // The advance of the first glyph laid out at each byte offset. let space: HashMap = laid.glyphs.iter().rev().map(|g| (g.byte, g.advance)).collect(); @@ -995,8 +999,15 @@ fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, s tracking = if rest >= 1.0 { (extra - own.iter().map(|(_, need)| need).sum::()) / rest } else { 0.0 }; } own.sort_by_key(|(b, _)| *b); - let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 10000.0).round(); - (em(tracking), own.into_iter().map(|(b, need)| (b, em(need - tracking))).collect()) + let thousandths = |pts: f64| (pts / em.max(1e-3) * 1000.0).clamp(-1000.0, 10000.0).round(); + (thousandths(tracking), own.into_iter().map(|(b, need)| (b, thousandths(need - tracking))).collect()) +} + +/// The em the tracking of type `t` set `size` points is thousandths of, in points: the size as its +/// first run's Horizontal Scale draws it in horizontal type ([`vectorcraft_text::spacing_scale`]), +/// as a PDF scales its character spacing by its horizontal scaling (ISO 32000-1 §9.4.4). +fn spacing_em(t: &TextObject, size: f64) -> f64 { + size * t.runs.first().map_or(1.0, |r| vectorcraft_text::spacing_scale(&r.style, t.vertical)) } /// `runs` with tracking `tracking`, and each space at a byte offset of `spaces` (in order) a run diff --git a/crates/pdf/src/tests/text_placement.rs b/crates/pdf/src/tests/text_placement.rs index 45bf5e409..f275def4d 100644 --- a/crates/pdf/src/tests/text_placement.rs +++ b/crates/pdf/src/tests/text_placement.rs @@ -286,6 +286,21 @@ fn spacing_and_scaling_keep_each_glyph_where_the_file_put_it() { } } +/// Character spacing in horizontally scaled type: a PDF scales `Tc` by its horizontal scaling +/// (ISO 32000-1 §9.4.4), as layout scales tracking by the Horizontal Scale +/// (`vectorcraft_text::spacing_scale`), so the type's tracking is the file's `Tc` in thousandths of +/// its size: 1.5 pt at 16 pt is 93.75, set 80% wide. +#[test] +fn character_spacing_in_horizontally_scaled_type_is_the_files_tc() { + let bytes = page("BT /F1 16 Tf 1.5 Tc 80 Tz 20 50 Td (Spaced and narrow) Tj ET", ""); + let t = texts(&open_as(&bytes, TextAs::Text)); + assert_eq!(t.len(), 1); + assert!(t[0].runs.iter().all(|r| (r.style.h_scale - 80.0).abs() < 1e-6), "{:?}", t[0].runs); + let tracking: Vec = t[0].runs.iter().map(|r| r.style.tracking).collect(); + assert!(tracking.iter().all(|v| (v - 93.75).abs() < 0.5), "{tracking:?}"); + assert!(displacement(&bytes) < 0.02); +} + /// Word spacing stays on the spaces, as tracking of their own; evenly spaced letters keep one /// tracking value for the line, and a file that didn't kern opens with kerning off. #[test] diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 78113dcd8..bdabab2ac 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -160,10 +160,13 @@ pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { /// written by `len`. pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { let mut p = Props::new(); - // Tracking and manual kerning both add space after every character. + // Tracking and manual kerning both add space after every character, in thousandths of the em + // of the font size written (the vertical scale, see `font_props`). The canvas spaces them by + // the horizontal scale: a uniform scale is the same, and `textLength` stretches type scaled + // horizontally alone, glyphs and spacing together, from the one to the other. let spacing = st.tracking + st.kerning.unwrap_or(0.0); if spacing != 0.0 { - p.push(("letter-spacing", len(spacing / 1000.0 * st.size))); + p.push(("letter-spacing", len(spacing / 1000.0 * st.size * st.v_scale / 100.0))); } if st.kerning.is_some() { // Manual kerning replaces the font's pair kerning. diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index adcc101f2..a031de741 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -554,6 +554,18 @@ fn ragged_lines_keep_their_character_spacing() { } } +/// Tracking and manual kerning in type scaled uniformly, which no `textLength` stretches, take the +/// scale in a viewer as they do on the canvas. +#[test] +fn spacing_in_uniformly_scaled_type_takes_the_scale() { + for st in [ + CharStyle { h_scale: 200.0, v_scale: 200.0, tracking: 100.0, ..style(12.0) }, + CharStyle { h_scale: 150.0, v_scale: 150.0, kerning: Some(80.0), ..style(12.0) }, + ] { + assert_drawn_in_place(&area_doc(vec![run("tracked wide", st)], 280.0, Justify::Left)); + } +} + /// Type scaled horizontally before a tab is stretched to its own width, not to the tab's gap. #[test] fn scaled_type_before_a_tab_keeps_its_width() { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index cbc5157ed..011bb6eb2 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -49,6 +49,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout}; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; pub use layout::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled}; +pub use shape::spacing_scale; pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign}; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index ae44ce433..a58e8ef57 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -357,6 +357,19 @@ pub(crate) fn cap_x_heights(face: Option<&FontFace>, st: &CharStyle) -> (f64, f6 (face.cap_height * k, face.x_height * k) } +/// The share of the em that tracking and manual kerning (thousandths of an em) of characters in +/// style `st` are measured in. In horizontal type that is the Horizontal Scale: they are +/// thousandths of the em as the scale draws it, so at 50% a tracking of 100 adds 0.05 em, half as +/// much as at 100%, as the page of users' `.ai` files spaces such type (and as PDF scales its +/// character and word spacing by its horizontal scaling, ISO 32000-1 §9.4.4). Baking a horizontal +/// scale of the type's transform into the Horizontal Scale then keeps the spacing. In vertical +/// type, whose advances down the column don't all take the Horizontal Scale, the em itself: 1. A +/// scale that isn't a positive number counts as 100%. +pub fn spacing_scale(st: &CharStyle, vertical: bool) -> f64 { + let hs = st.h_scale / 100.0; + if vertical || !(hs.is_finite() && hs > 0.0) { 1.0 } else { hs } +} + /// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style, `inlines` /// (parallel to `runs`) the inline graphic of each run that is one, and `levels` each byte's bidi /// embedding level from `range.start` (empty: all left to right). Glyphs come out in logical @@ -736,7 +749,8 @@ fn optical_pairs(db: &FontDb, text: &str, glyphs: &mut [SGlyph], runs: &[(Range< let right_y = right.dy - right.bshift; let Some(left_style) = runs.get(left.run).map(|(_, s)| *s) else { continue }; let em = left.sx * left.face.upem / (left_style.h_scale / 100.0); - let tracking = left_style.tracking / 1000.0 * em; + // The tracking shaping added: thousandths of the em as its Horizontal Scale draws it. + let tracking = left_style.tracking / 1000.0 * em * spacing_scale(left_style, false); // A manual kerning value on the left glyph is part of its advance and, right to left, of // its offset too (it spaces the left glyph from its left neighbor): it is not taken off. let advance = glyphs[cluster_start..=cluster_end].iter().map(|g| g.adv).sum::() - tracking; @@ -931,8 +945,10 @@ fn shaping_script(c: char) -> Script { /// vertical centre on the middle of the cap height raised by the inline's shift (see /// [`InlineArt`]). Its advance is the scaled art width plus tracking and its character's manual /// kerning, which right to left in horizontal type also moves the art right by the value, as it -/// moves a letter's glyph. The glyph's ascent and descent grow to the art's extent so a tall -/// graphic opens up its line. A missing symbol takes a square of the same height and draws nothing. +/// moves a letter's glyph. The art doesn't take the run's Horizontal Scale; the tracking and the +/// kerning do, as after a letter ([`spacing_scale`]). The glyph's ascent and descent grow to the +/// art's extent so a tall graphic opens up its line. A missing symbol takes a square of the same +/// height and draws nothing. #[allow(clippy::too_many_arguments)] fn inline_glyph( face: &Arc, @@ -959,8 +975,9 @@ fn inline_glyph( } _ => (kurbo::Affine::IDENTITY, h, None), }; - let tracking = st.tracking / 1000.0 * size; - let manual_kern = st.kerning.map(|v| v / 1000.0 * size).filter(|k| k.is_finite()).unwrap_or(0.0); + let spacing_em = size * spacing_scale(st, vertical); + let tracking = st.tracking / 1000.0 * spacing_em; + 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; SGlyph { face: face.clone(), @@ -1063,6 +1080,9 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let km = full / face.upem; let hs = st.h_scale / 100.0; let vs = st.v_scale / 100.0; + // The em the tracking and manual kerning are thousandths of: in horizontal type, as the + // Horizontal Scale draws it ([`spacing_scale`]; the runs shaped together share the scale). + let spacing_em = size * spacing_scale(st, feats.vertical); let ascent = face.ascent * km * vs; let descent = face.descent * km * vs; let cap = face.cap_height * km * vs; @@ -1379,7 +1399,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe if ch == SOFT_HYPHEN || ch == '\u{2028}' { adv = 0.0; } else { - let manual_kern = gst.kerning.map(|v| v / 1000.0 * size).unwrap_or(0.0); + let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0); // Manual kerning spaces a character from the next one in reading order. Right to left, // that one is drawn to its left: the cluster's glyphs move right along the baseline by // the value, so that the space its last glyph's advance gains is at the cluster's left. @@ -1387,7 +1407,7 @@ 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 * size; + let tracking = gst.tracking / 1000.0 * spacing_em; adv += tracking + manual_kern; } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 2dd3c424c..a7a0724ee 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -225,6 +225,46 @@ fn tracking_and_scale() { assert!(b.y1 < -4.0, "shifted up: {b:?}"); } +/// In horizontally scaled type, tracking and manual kerning are thousandths of the em as the scale +/// draws it (`spacing_scale`): at 50% they add half the space they add at 100%, after every +/// character, as the page of users' `.ai` files spaces such type. +#[test] +fn tracking_and_manual_kerning_take_the_horizontal_scale() { + let laid = + |h_scale: f64, tracking: f64, kerning: Option| layout(db(), &point("Wave", CharStyle { h_scale, tracking, kerning, ..style(20.0) })); + let advances = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::>(); + let line = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + // 200/1000 of a 20 pt em: 4 pt after each character at 100%, 2 pt at 50%. + for (h_scale, extra) in [(100.0, 4.0), (50.0, 2.0)] { + for (name, plain, spaced) in [ + ("tracking", laid(h_scale, 0.0, None), laid(h_scale, 200.0, None)), + ("manual kerning", laid(h_scale, 0.0, Some(0.0)), laid(h_scale, 0.0, Some(200.0))), + ] { + let (a, b) = (advances(&plain), advances(&spaced)); + assert_eq!(a.len(), 4, "{name}: {a:?}"); + assert_eq!(a.len(), b.len(), "{name}"); + for (p, s) in a.iter().zip(&b) { + assert!((s - p - extra).abs() < 1e-9, "{name} at {h_scale}%: {extra} pt more after each character: {a:?} {b:?}"); + } + let longer = line(&spaced) - line(&plain); + assert!((longer - 4.0 * extra).abs() < 1e-9, "{name} at {h_scale}%: the line is {} pt longer, not {longer}", 4.0 * extra); + } + } +} + +/// 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] +fn spacing_scale_is_the_horizontal_scale_of_horizontal_type() { + let at = |h_scale: f64, vertical: bool| spacing_scale(&CharStyle { h_scale, ..style(20.0) }, vertical); + assert_eq!(at(50.0, false), 0.5); + assert_eq!(at(250.0, false), 2.5); + assert_eq!(at(50.0, true), 1.0, "vertical type keeps the em"); + for junk in [0.0, -50.0, f64::NAN, f64::INFINITY] { + assert_eq!(at(junk, false), 1.0, "{junk}"); + } +} + #[test] fn underline_and_strikethrough_bars() { let mut t = point("ab", style(20.0)); diff --git a/crates/text/src/tests/inline.rs b/crates/text/src/tests/inline.rs index 76c90e65b..01db5393c 100644 --- a/crates/text/src/tests/inline.rs +++ b/crates/text/src/tests/inline.rs @@ -71,6 +71,10 @@ fn shift_tracking_and_missing_symbols() { let mut t = text("A", art(1.0), "B", 20.0); t.runs[1].style.tracking = 100.0; assert!((inline_glyph(&layout(db(), &t)).advance - 42.0).abs() < 1e-6); + // At 50% Horizontal Scale the tracking adds half as much, as after a glyph; the art keeps its + // width. + t.runs[1].style.h_scale = 50.0; + assert!((inline_glyph(&layout(db(), &t)).advance - 41.0).abs() < 1e-6); // Unresolved (missing symbol): a square of the height, nothing to draw. let t = text("A", InlineArt::new("gone"), "B", 20.0); let l = layout(db(), &t); diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 4b8bdbc09..4790b642a 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -66,6 +66,24 @@ fn optical_pairs_cross_paint_runs_and_manual_and_tracking_are_independent() { assert!((pair_advance(&rtl_manual) - pair_advance(&rtl_pair_zero) - 20.0).abs() < 0.01, "manual spacing changes the adjacent right-to-left pair"); } +/// At 50% Horizontal Scale the tracking adds half the space it adds at 100% (`spacing_scale`), +/// and the Optical pairs measure their gaps without it: tracked, each advance is the untracked +/// Optical one and the tracking, the corrections unchanged. +#[test] +fn optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking() { + let st = CharStyle { font_family: "JetBrains Mono".into(), size: 100.0, optical_kerning: true, h_scale: 50.0, ..CharStyle::default() }; + let advances = |st: CharStyle| layout(db(), &TextObject::point(Point::ZERO, "IIAV", st)).glyphs.iter().map(|g| g.advance).collect::>(); + let optical = advances(st.clone()); + let unkerned = advances(CharStyle { kerning: Some(0.0), ..st.clone() }); + assert!((optical[0] - unkerned[0]).abs() > 0.1, "Optical changes the monospaced pair at 50%: {optical:?} {unkerned:?}"); + let tracked = advances(CharStyle { tracking: 100.0, ..st }); + assert_eq!(optical.len(), tracked.len()); + for (o, t) in optical.iter().zip(&tracked) { + // 100/1000 of the 100 pt em drawn 50% wide. + assert!((t - o - 5.0).abs() < 0.01, "{optical:?} {tracked:?}"); + } +} + #[test] fn rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order() { for optical_kerning in [false, true] { diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 225395d34..f05f2c476 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -343,6 +343,20 @@ fn tracking_spaces_upright_marks_without_moving_them_off_the_centre_line() { assert!((step - 24.0).abs() < 1e-6, "one em plus 200/1000 em of tracking: {step}"); } +/// Vertical type keeps its tracking in thousandths of the em itself at any Horizontal Scale +/// (`spacing_scale`): its advances down the column don't all take the scale. +#[test] +fn vertical_tracking_keeps_the_em_at_any_horizontal_scale() { + let step = |h_scale: f64, tracking: f64| { + let l = layout(FontDb::global(), &vertical_styled("§§", CharStyle { size: 20.0, h_scale, tracking, ..CharStyle::default() })); + ink(&l, 1).center().y - ink(&l, 0).center().y + }; + for h_scale in [100.0, 50.0] { + let more = step(h_scale, 200.0) - step(h_scale, 0.0); + assert!((more - 4.0).abs() < 1e-6, "{h_scale}%: 200/1000 of the 20 pt em: {more}"); + } +} + /// Baseline shift moves an upright glyph across the column, as it moves a letter lying on its /// side, at any Character Rotation, and leaves its neighbors where they are. The glyph turns about /// its shifted pen position (`xf * pivot`), and `xf * unturned` places the shifted glyph without diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6ef0a1d86..f0b4b5bae 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: 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, 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: 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, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. | | 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) | | 2026-11-11 | minor | `.ai` and EPS import: type stroked in the text document keeps its stroke's cap, join and miter limit (keys 61, 62 and 64 of its character attributes, numbered as PostScript's `setlinecap` and `setlinejoin`: 0 butt or miter, 1 round, 2 projecting or bevel). They were left at a miter join with a limit of 10 whatever the file said, so type the file strokes with round joins, or with the usual limit of 4 or less, drew spikes at its sharp corners, some many points long. In users' files, used locally and never committed, the page strokes the type with the cap, join and miter limit these keys give; no file had a bevel join, which is read by the same numbering. A value that isn't one of theirs keeps the default, a miter limit past 500 is 500, and type without a stroke keeps the defaults. Dashes are not read: no file had dashed type to tell which keys hold them. Tests `ate::tests::a_run_keeps_its_stroke_cap_join_and_miter_limit`, `tests_native::stroked_type_keeps_its_cap_join_and_miter_limit`; scores unchanged | | 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, which weighs the envelope as the art it draws (not as type laid out again, which the comparison allows more for), and when they don't draw as it the drawn art and the hidden type are put back. Layers used though they don't draw as the page (the editing data trusted, or the objects that differ coming in as the page's art) leave envelopes of type their drawn art and hidden type too. A `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text or a visibility variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone`, `an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live`, `trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art`, `layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art`, `an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art` | diff --git a/docs/type-parity.md b/docs/type-parity.md index defb38473..4013f8be1 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-10 · **Change:** 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 (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 | 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. | | 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on type has Spelling › Auto Spell Check, checked while it is on; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. | From 668465042537856589a4f62d7a2e761abfe6b916 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 00:01:48 -0800 Subject: [PATCH 2/2] Reshape: an empty focal list cannot be combined with radius path.reshape refused focal with radius only when focal named an anchor. Since an empty focal list chooses the focal-point rule, a call with "focal": [] and a radius was accepted and used the radius falloff, although the params text and docs/mcp.md say the two cannot be used together. Any focal other than null now goes with radius as an error. --- crates/engine/src/cmd/path.rs | 3 ++- crates/engine/src/tests.rs | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 7d7d635c9..d00ba7192 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1578,7 +1578,8 @@ fn reshape(s: &mut Session, p: &Value) -> Result { Some(v) if v.is_array() => super::select::checked_refs(Some(v), "path.reshape")?.into_iter().collect(), Some(_) => return Err(bad("path.reshape", "`focal` must be an array of [subpath, anchor] pairs")), }; - if radius.is_some() && !focal.is_empty() { + // An empty `focal` chooses the focal-point rule too. + if radius.is_some() && !matches!(p.get("focal"), None | Some(Value::Null)) { return Err(bad("path.reshape", "`focal` and `radius` cannot be used together")); } // A step that names neither `focal` nor `radius` (as the Reshape tool recorded one before it diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5021c879f..9ad671843 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3622,9 +3622,12 @@ fn reshape_refuses_a_focal_point_that_is_not_an_anchor() { } assert_eq!(reshape_anchors(&s, id), before); let steps = s.doc().unwrap().history.undo.len(); - let e = - s.execute("path.reshape", &json!({"id": id.0, "x": 420.0, "y": 0.0, "dx": 0.0, "dy": 10.0, "focal": [[0, 2]], "radius": 50.0})).unwrap_err(); - assert!(e.to_string().contains("`focal` and `radius` cannot be used together"), "{e}"); + // An empty `focal` chooses the focal-point rule too, so it cannot go with `radius` either. + for focal in [json!([[0, 2]]), json!([])] { + let e = + s.execute("path.reshape", &json!({"id": id.0, "x": 420.0, "y": 0.0, "dx": 0.0, "dy": 10.0, "focal": focal, "radius": 50.0})).unwrap_err(); + assert!(e.to_string().contains("`focal` and `radius` cannot be used together"), "{focal}: {e}"); + } assert_eq!(reshape_anchors(&s, id), before); assert_eq!(s.doc().unwrap().history.undo.len(), steps); }