diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 28f67f215..e0946f24a 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 0c3587e58..c598e2cd9 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 a22c94de5..7707f4bc4 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 6c84eff23..c0d9852d8 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 7b208caf3..2c219d2b0 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 c5c1c4c26..6b553b8d1 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 c3ca85c08..979cd2a43 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 cf601e939..c95940b95 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 a18b71705..013697e50 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, 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` 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` 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) | | 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) |