diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 2023b8d47c..8efa0637ad 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -525,6 +525,51 @@ fn many_objects_of_one_name_are_numbered_in_turn() { assert!(text.contains("(Dot_00000000000000019999_)")); } +/// A document with one top-level layer for each of the 27 layer colour presets, named after its +/// position. +fn every_layer_preset() -> Document { + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + for i in 0..vectorcraft_doc::LAYER_COLORS.len() { + let id = doc.alloc_id(); + doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), LayerColor::Preset(u8::try_from(i).unwrap())))); + } + doc +} + +/// #6502: Yellow, Magenta and Cyan (presets 4, 5 and 6) are written as the file's indexes 6, 4 +/// and 5, with their own RGB beside them; every other preset keeps its position. +#[test] +fn layer_presets_are_written_with_the_files_colour_index() { + let text = program(&every_layer_preset()); + for (i, (_, [r, g, b])) in vectorcraft_doc::LAYER_COLORS.iter().enumerate() { + let index = match i { + 4 => 6, + 5 => 4, + 6 => 5, + i => i, + }; + let want = format!(" {index} {r} {g} {b} 0 50 0 Lb\n(P{i}) Ln\n"); + assert!(text.contains(&want), "missing {want:?}"); + } +} + +/// #6502: every preset comes back as itself after a save and an open. +#[test] +fn layer_presets_survive_a_round_trip() { + let doc = read_back(&every_layer_preset()); + let colors: Vec = doc + .layers + .iter() + .map(|l| match l.kind { + NodeKind::Layer { color, .. } => color, + _ => panic!("{:?}", l.kind), + }) + .collect(); + let want: Vec = (0..27u8).map(LayerColor::Preset).collect(); + assert_eq!(colors, want); +} + #[test] fn layers_have_fourteen_operands() { let text = program(&sample()); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 0c3587e586..c598e2cd9a 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::swatch::{REGISTRATION, Swatch}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; +use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath}; use vectorcraft_pathops::BoolOp; @@ -449,14 +449,7 @@ impl<'a> Writer<'a> { /// (`Xy`, `0 0 Xd`, `7 () XW`) when it has any, `LB`, `%AI5_EndLayer--`. fn write_layer(&mut self, n: &Node, after: &[Arc]) { let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return }; - let [r, g, b] = match color { - LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), - LayerColor::Custom(rgb) => *rgb, - }; - let index = match color { - LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), - _ => -1, - }; + let (index, [r, g, b]) = ai_ops::layer_color(*color); if *template { self.warn(TEMPLATES); } diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index a22c94de53..7707f4bc45 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -6,7 +6,7 @@ use std::fmt::Write; use vectorcraft_color::{Color, Gradient}; -use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer}; +use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer}; use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath}; use crate::ps; @@ -62,6 +62,43 @@ pub(crate) fn guide(s: &mut String, xf: Affine, path: &PathData, editing: bool) } } +/// The colour index an `.ai` layer's `Lb` gives preset `preset` (its position in +/// [`LAYER_COLORS`]). The file numbers Magenta, Cyan and Yellow 4, 5 and 6, where +/// [`LAYER_COLORS`] has Yellow, Magenta and Cyan; every other preset has its own position (#6502, +/// from the index and RGB pairs of the `Lb` records in files users sent). A reader takes the index +/// and ignores the RGB beside it, so the index decides the colour. +pub(crate) fn ai_layer_index(preset: u8) -> u8 { + match preset { + 4 => 6, + 5 => 4, + 6 => 5, + i => i, + } +} + +/// The preset (position in [`LAYER_COLORS`]) of the colour index `index` in an `.ai` layer's +/// `Lb`: the inverse of [`ai_layer_index`]. +pub(crate) fn preset_of_ai_layer_index(index: u8) -> u8 { + match index { + 6 => 4, + 4 => 5, + 5 => 6, + i => i, + } +} + +/// The colour index and RGB an `.ai` layer's `Lb` writes for `color`: a preset's index +/// ([`ai_layer_index`]) with its RGB, or `-1` with a custom colour's RGB. +pub(crate) fn layer_color(color: LayerColor) -> (i64, [u8; 3]) { + match color { + LayerColor::Preset(i) => match LAYER_COLORS.get(usize::from(i)) { + Some((_, rgb)) => (i64::from(ai_layer_index(i)), *rgb), + None => (-1, [79, 128, 255]), + }, + LayerColor::Custom(rgb) => (-1, rgb), + } +} + pub(crate) fn unit(v: f32) -> f32 { if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } } diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 6c84eff236..c0d9852d88 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -471,8 +471,9 @@ impl<'a> Reader<'a> { a.dim = flag(4, false).then_some(dim_pct.round() as u8); let index = n.get(at).copied().unwrap_or(0.0); let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); + // The file numbers some presets differently from `LAYER_COLORS` (#6502). a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { - LayerColor::Preset(index as u8) + LayerColor::Preset(crate::ai_ops::preset_of_ai_layer_index(index as u8)) } else { LayerColor::Custom([rgb(1), rgb(2), rgb(3)]) }); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 7b208caf38..2c219d2b07 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -8465,20 +8465,44 @@ fn layer_with(ops: &str) -> Node { /// the colour index and the last one change nothing. #[test] fn the_template_flag_of_a_layer() { + // Index 4 is the file's Magenta (#6502). let l = layer_with("1 1 0 0 1 0 0 4 255 79 255 1 50 0"); let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() }; assert!(template && !printable && l.locked); - assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(4))); + assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(5))); + // Index 5 is the file's Cyan (#6502). for ops in ["1 1 1 1 1 0 1 5 1 2 3 1 30 0", "1 1 1 1 1 0 0 5 1 2 3 1 30 1", "1 1 1 1 1 0 1 5 1 2 3 1 30"] { let l = layer_with(ops); let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() }; assert!(template && printable && !l.locked, "{ops}"); - assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(5)), "{ops}"); + assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(6)), "{ops}"); } let l = layer_with("1 1 1 1 0 0 1 0 79 128 255 0 50 0"); assert!(matches!(l.kind, NodeKind::Layer { template: false, .. })); } +/// #6502: an `.ai` file numbers Magenta, Cyan and Yellow 4, 5 and 6 (the index and RGB pairs of +/// the `Lb` records in files users sent), so they open as those presets; the index decides, not +/// the RGB beside it, and the other presets keep their position. +#[test] +fn layer_colour_indexes_follow_the_file_format() { + let color = |ops: &str| match layer_with(ops).kind { + NodeKind::Layer { color, .. } => color, + _ => panic!(), + }; + let preset = |name: &str| { + let i = vectorcraft_doc::LAYER_COLORS.iter().position(|(n, _)| *n == name).unwrap(); + LayerColor::Preset(u8::try_from(i).unwrap()) + }; + for (index, rgb, name) in + [(3, "79 79 255", "Blue"), (4, "255 79 255", "Magenta"), (5, "79 255 255", "Cyan"), (6, "255 255 79", "Yellow"), (7, "128 128 128", "Gray")] + { + assert_eq!(color(&format!("1 1 1 1 0 0 0 {index} {rgb} 0 50 0")), preset(name), "index {index}"); + } + // The RGB beside an index doesn't change the preset. + assert_eq!(color("1 1 1 1 0 0 0 4 255 255 79 0 50 0"), preset("Magenta")); +} + /// The colour index is one place earlier in eleven and twelve operands, as in ten, and the twelfth /// is the dimming percentage; with nine it is one place earlier still. #[test] diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index c5c1c4c269..6b553b8d1a 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -65,8 +65,8 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ - AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, - NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, + AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, Node, NodeKind, ParaDirection, + ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, }; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2}; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index c3ca85c083..979cd2a436 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -940,6 +940,35 @@ fn names_outside_windows_1252_are_replaced_and_kept_apart() { assert_eq!(inks(&read), want); } +/// #6502: the legacy writer gives Yellow, Magenta and Cyan (presets 4, 5 and 6) the file's +/// indexes 6, 4 and 5, and they open as the same presets. +#[test] +fn layer_presets_are_written_with_the_files_colour_index() { + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + for i in 3..8u8 { + let id = doc.alloc_id(); + doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), vectorcraft_doc::LayerColor::Preset(i)))); + } + let out = write(&doc); + let text = String::from_utf8_lossy(&out.bytes).replace("\r\n", "\n"); + for (i, index) in [(3, 3), (4, 6), (5, 4), (6, 5), (7, 7)] { + let [r, g, b] = vectorcraft_doc::LAYER_COLORS[i].1; + let want = format!(" 0 {index} {r} {g} {b} Lb\n(P{i}) Ln\n"); + assert!(text.contains(&want), "missing {want:?} in\n{text}"); + } + let read = crate::import(&out.bytes).unwrap().document; + let colors: Vec<_> = read + .layers + .iter() + .filter_map(|l| match l.kind { + NodeKind::Layer { color, .. } => Some(color), + _ => None, + }) + .collect(); + assert_eq!(colors, (3..8u8).map(vectorcraft_doc::LayerColor::Preset).collect::>()); +} + #[test] fn losses_are_warned() { let mut doc = sample(); diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index cf601e9391..c95940b95d 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -5,9 +5,9 @@ use super::encoding::{ use super::{ Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, - LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, - ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, - TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, + LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData, + ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, + TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, }; pub(super) struct Writer<'a> { @@ -135,14 +135,7 @@ impl<'a> Writer<'a> { /// A top-level layer. False when it isn't a layer. pub(super) fn layer(&mut self, n: &Node) -> bool { let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return false }; - let [r, g, b] = match color { - LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), - LayerColor::Custom(rgb) => *rgb, - }; - let index = match color { - LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), - _ => -1, - }; + let (index, [r, g, b]) = ai_ops::layer_color(*color); let flag = |b: bool| u8::from(b); let name = self.layer_name(n.name.as_deref().unwrap_or("Layer")); // A template layer doesn't print and shows dimmed. @@ -173,14 +166,7 @@ impl<'a> Writer<'a> { } let art = std::mem::take(&mut self.hidden_art); let NodeKind::Layer { color, template, printable, .. } = &n.kind else { return false }; - let [r, g, b] = match color { - LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), - LayerColor::Custom(rgb) => *rgb, - }; - let index = match color { - LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), - _ => -1, - }; + let (index, [r, g, b]) = ai_ops::layer_color(*color); let name = format!("{} (hidden)", n.name.as_deref().unwrap_or("Layer")); let name = self.layer_name(&name); let _ = writeln!( diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8e38543a22..5cad08fbf7 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` 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` 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` 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) | | 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: stroked type in an envelope with Distort Appearance is tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, for a stroke on its appearance or on a group round it as well as its characters'; it was mapped from its bounds with strokes, which reach as far as a miter join may at a corner. Tests `ai::envelope::tests::stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke`, `type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes`, `a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight` (part of #3179) |