diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 5a253f21a3..3721ce7960 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -205,6 +205,10 @@ pub struct Structure { pub warped_type: super::envelope::WarpedType, /// The document's graphic styles are those of the file's style list, in place of the defaults. pub styles_read: bool, + /// The patterns that paint the text document's characters, placed in the document, by the + /// name of the art style that gives each: the styles that are one paint, a pattern this read + /// (see `ate::Texts::with_patterns`). + pub text_patterns: HashMap, /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. pub canvas_scale: f64, /// The objects by their XML id (`AI10_ArtUID`), a text object by its slot's id: `None` for an diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ad7f266036..ca943e8817 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1335,6 +1335,17 @@ impl<'a> Reader<'a> { doc.limit_depth_noting(&mut warnings); } warnings.extend(self.names.notes()); + // The patterns of the styles that are one paint, which the text document's characters + // name for their fills and strokes: those that place in the document. + let text_patterns = self + .styles + .iter() + .filter_map(|(style, st)| { + let Some(crate::import::ai::style::StyleItem::Pattern { name, matrix, controls }) = &st.simple_pattern else { return None }; + let paint = super::ops::style_pattern(name, *matrix, controls, &self.pattern_tiles, &self.v7_patterns, &self.global_names, to_doc)?; + Some((style.clone(), paint)) + }) + .collect(); Ok(Structure { doc, to_doc, @@ -1358,6 +1369,7 @@ impl<'a> Reader<'a> { page, }, styles_read, + text_patterns, canvas_scale: self.canvas_scale, xml_ids: self.xml_ids, bindings, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index c0d9852d88..82f16c04a9 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -943,6 +943,29 @@ fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) } +/// A style's pattern (`StyleItem::Pattern`): the file's pattern `name` placed by `matrix` (its +/// space → art space; `controls` apply first for a pattern defined in the version 7 form, `v7` +/// those patterns' names) and carried into the document by `space`, with the name `names` give +/// it there. `None` when the pattern wasn't read (`tiles`: the tiles of those that were, by their +/// names in the file) or doesn't place. +pub(super) fn style_pattern( + name: &str, + matrix: Option<[f64; 6]>, + controls: &[f64], + tiles: &HashMap, + v7: &HashSet, + names: &ShownNames, + space: Affine, +) -> Option { + let tile = tiles.get(name).copied()?; + let mut matrix = matrix.and_then(|m| paint::affine(&m)); + if v7.contains(name) { + matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); + } + let xf = pattern_xf(space, matrix, tile)?; + Some(Paint::Pattern { pattern: names.get(Global::Pattern, name).to_string(), xf }) +} + /// A pattern fill's `x y scaleX scaleY angle reflect axis shear shearAxis` as one transform of the /// pattern's space, applied before its initial matrix: the shear (by `shear` degrees along the line /// at `shearAxis` degrees), the reflection about the line at `axis` degrees when `reflect` isn't @@ -1147,15 +1170,15 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p } StyleItem::Pattern { name, matrix, controls } => match placed.get(&format!("{PATTERN_KEY}{}", paints.names.get(Global::Pattern, name))) { Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), - None => { - let tile = paints.patterns.get(name).copied()?; - let mut matrix = matrix.and_then(|m| paint::affine(&m)); - if paints.v7_patterns.contains(name) { - matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); - } - let xf = pattern_xf(paints.space, matrix, tile)?; - AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: paints.names.get(Global::Pattern, name).to_string(), xf })) - } + None => AppearanceItem::Fill(FillLayer::new(style_pattern( + name, + *matrix, + controls, + paints.patterns, + paints.v7_patterns, + paints.names, + paints.space, + )?)), }, StyleItem::Stroke { brush, .. } => { // A brushed stroke draws as its look only with the brush the Brushes panel keeps diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index f15744f30c..a884fd9296 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -202,6 +202,10 @@ pub(super) struct ArtStyle { pub transparency: Option<(f32, BlendMode, bool, Knockout)>, /// [`Self::effects`] holds Effect › Pathfinder effects, which only a group is read with. pub pathfinder: bool, + /// A style that is one paint (`/SimpleStyle :`, its paint's operators, `/Paint ;`), when that + /// paint is a pattern: its [`StyleItem::Pattern`]. A text document's characters name such + /// styles for their fills and strokes (`ate`). `None` for any other paint or style. + pub simple_pattern: Option, } impl ArtStyle { @@ -406,9 +410,14 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap = vec![]; let mut pending: Option = None; let mut in_basic = false; - let mut finish = |name: Option, filters: &mut Vec| { + // A style that is one paint being read, and its pattern so far. + let mut simple: Option> = None; + let mut finish = |name: Option, filters: &mut Vec, simple_pattern: Option| { if let Some(name) = name { - styles.insert(name, style(filters)); + // Only a style that is one paint holds a pattern for type: one with filters as well + // paints with more than that. + let simple_pattern = simple_pattern.filter(|_| filters.is_empty()); + styles.insert(name, ArtStyle { simple_pattern, ..style(filters) }); } filters.clear(); }; @@ -445,11 +454,12 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap match n { b"KnownStyle" => { - finish(name.take(), &mut filters); + finish(name.take(), &mut filters, simple.take().flatten()); operands.clear(); open.clear(); (pending, in_basic, style_entry, other_entry) = (None, false, None, None); } + b"SimpleStyle" if filters.is_empty() => simple = Some(None), b"FillStyle" => style_entry = Some(Kind::Fill), b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other), b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)), @@ -549,9 +559,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap HashMap { - let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v)); - let matrix = vals.iter().rev().find_map(|v| match v { - V::Arr(a) => { - let n: Vec = a.iter().filter_map(V::num).collect(); - <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) - } - _ => None, - }); - let controls: Vec = - vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); - if let Some(name) = name { - f.pattern = Some((name, matrix, controls)); + if let Some(pattern) = pattern_operands(vals, names) { + f.pattern = Some(pattern); } } _ => {} } } +/// The operands of `(name) … [matrix] p`: the pattern's name read by `names`, its matrix (six +/// finite numbers) and the numbers between the name and the matrix. `None` without a name. +fn pattern_operands(vals: &[V], names: &mut Names) -> Option<(String, Option<[f64; 6]>, Vec)> { + let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v))?; + let matrix = vals.iter().rev().find_map(|v| match v { + V::Arr(a) => { + let n: Vec = a.iter().filter_map(V::num).collect(); + <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) + } + _ => None, + }); + let controls: Vec = + vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); + Some((name, matrix, controls)) +} + +/// A paint operator in a style being read: one of the last filter's (`filters`), else one of a style +/// that is one paint (`simple`, while one is read). +fn style_paint_op(filters: &mut [Filter], simple: &mut Option>, op: &str, vals: &[V], names: &mut Names) { + if let Some(f) = filters.last_mut() { + paint_op(f, op, vals, names); + } else if let Some(paint) = simple.as_mut() { + simple_paint_op(paint, op, vals, names); + } +} + +/// A paint operator of a style that is one paint: a pattern (`p`) is kept as its +/// [`StyleItem::Pattern`], and any other paint after it (a colour, a stroke's pattern or a +/// gradient) leaves the style without one. +fn simple_paint_op(paint: &mut Option, op: &str, vals: &[V], names: &mut Names) { + match op { + "p" => *paint = pattern_operands(vals, names).map(|(name, matrix, controls)| StyleItem::Pattern { name, matrix, controls }), + "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" | "xa" | "xx" | "xk" | "xs" + | "xz" | "P" | "Bg" => *paint = None, + _ => {} + } +} + /// A variable-width stroke's profile: its widths (fractions of the stroke's) at positions along the /// path, the same both sides unless it gives each side's. fn width_profile(arrays: &HashMap>) -> Option { diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index d39a87b5c0..ebe161a251 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1496,3 +1496,18 @@ fn art_styles_read_registration_and_named_colors() { let [StyleItem::Stroke { paint: Paint::Solid { swatch, tint, .. }, .. }] = st_stroke.items.as_slice() else { panic!("{st_stroke:?}") }; assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0)); } + +/// Only a style that is one paint holds a pattern for type: the same pattern paint in a style that +/// also has filters gives none, whichever comes first. +#[test] +fn only_a_style_of_one_pattern_paint_holds_a_pattern_for_type() { + let simple = "/SimpleStyle :\n0 O\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 1 Xd\n/Paint ;\n"; + let active = "/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n1 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n1 0 0 0 k\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = |body: &str| art_styles_in(format!("/KnownStyle :\n(S) /Name ,\n{body} /Def ;\n").as_bytes(), &mut Names::default()); + assert!(styles(simple).get("S").is_some_and(|s| s.simple_pattern.is_some())); + for body in [format!("{simple}{active}"), format!("{active}{simple}")] { + assert!(styles(&body).get("S").is_none_or(|s| s.simple_pattern.is_none()), "{body}"); + } +} diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 194c0ff1e2..efaed14199 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -21,7 +21,7 @@ //! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. A story without //! a layout has its point type's anchor at its frame's point. See [`Story::place`]. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; @@ -461,6 +461,11 @@ struct Style { v_scale: f64, fill: Option, stroke: Option, + /// The pattern that paints the fill or the stroke in place of its colour: the one the art + /// style named by key 5 of its paint gives ([`Texts::with_patterns`]), placed in the document. + /// `None` for any other paint, which keeps its colour. + fill_pattern: Option, + stroke_pattern: Option, stroke_width: f64, /// Key 61, the stroke's cap: 0 butt, 1 round, 2 projecting (square). stroke_cap: LineCap, @@ -491,9 +496,23 @@ pub(super) struct Texts { /// attributes (key 5) and the paragraph style it is based on (key 6, an index into these). para_sheets: Vec, frames: Vec, + /// The patterns that paint characters, by the name of the art style that gives each + /// ([`Self::with_patterns`]). + patterns: HashMap, } impl Texts { + /// The text document with its characters painted by `patterns`: the patterns of the editing + /// data's art styles that paint with one, each placed in the document, by the style's name. + /// A run's fill or stroke (key 53 or 54) is a paint whose key 0 is a colour and whose key 5 + /// names the art style that paints it. In users' files (used locally, never committed), such a + /// style is one paint: a colour, which key 0 already has, or a pattern, whose key 0 is black + /// (`/0 0 /1 [ 1 0 ]`). The pattern's matrix is in art space, as a path's pattern fill has it: + /// the files' own pages tile the type with the matrix a path's fill would get there. + pub(super) fn with_patterns(self, patterns: HashMap) -> Texts { + Texts { patterns, ..self } + } + /// The text document in the editing copy `data`, if it has one that can be read. pub(super) fn read(data: &[u8]) -> Option { fn find(h: &[u8], n: &[u8], from: usize) -> Option { @@ -540,6 +559,7 @@ impl Texts { char_sheets: sheets("5"), para_sheets: sheets("6"), frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(), + patterns: HashMap::new(), }) } @@ -747,6 +767,8 @@ impl Texts { let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned(); let size = num("1").filter(|v| *v > 0.0 && *v < 1e5).unwrap_or(12.0); + // Key 5 of a paint names the art style that paints it ([`Self::with_patterns`]). + let pattern = |k: &str| pick(k).and_then(|p| p.get("5")).and_then(Val::str).and_then(|name| self.patterns.get(name)).cloned(); Style { font, size, @@ -757,6 +779,8 @@ impl Texts { v_scale: num("7").filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0), fill: if on("56") { Some(pick("53").and_then(paint).unwrap_or(Color::BLACK)) } else { None }, stroke: if matches!(pick("57"), Some(Val::Bool(true))) { pick("54").and_then(paint) } else { None }, + fill_pattern: pattern("53"), + stroke_pattern: pattern("54"), stroke_width: num("63").filter(|v| *v >= 0.0 && *v < 1e4).unwrap_or(1.0), // Numbered as PostScript's `setlinecap` and `setlinejoin` operators number them // (PostScript Language Reference, 3rd ed.): in users' files the page strokes type with @@ -1109,6 +1133,10 @@ impl Story { // The text document measures in the art's units too: the document's scale carries its // sizes and spacings onto the page (a Large Canvas copy is written at 1:10; #2031). let scale = scale * art_scale(to_doc); + // Patterns stay where the document has them, wherever the type's text space is (`xf`'s + // determinant is 1, so it can be inverted; a pattern that still doesn't map to finite + // numbers keeps its colour, see `char_style`). + let to_text = xf.inverse(); let chars: Vec = text.chars().collect(); let mut runs = vec![]; let mut from = 0usize; @@ -1119,13 +1147,13 @@ impl Story { if piece.is_empty() { continue; } - runs.push(TextRun::new(piece, char_style(st, scale))); + runs.push(TextRun::new(piece, char_style(st, scale, to_text))); } if from < chars.len() { let last = self.runs.last().map(|r| &r.1)?; - runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale))); + runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale, to_text))); } - let first = self.runs.first().map(|r| char_style(&r.1, scale))?; + let first = self.runs.first().map(|r| char_style(&r.1, scale, to_text))?; let mut t = TextObject::point(Point::ZERO, "", first); if !runs.is_empty() { t.runs = runs; @@ -1295,10 +1323,20 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { } /// The character style of character attributes `s`: its font from the fonts the app has, its -/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`. -fn char_style(s: &Style, scale: f64) -> CharStyle { +/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`, and its patterns placed +/// in the type's text space by `to_text` (the document → its text space), where they stay as the +/// document has them. +fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + // A fill or stroke that is on takes its pattern in place of its colour. + let paint = |color: Option, pattern: &Option| match (color, pattern) { + // A pattern that can't be placed (the type's text space out of range) keeps the colour. + (Some(_), Some(Paint::Pattern { pattern, xf })) if (to_text * *xf).is_finite() => { + Paint::Pattern { pattern: pattern.clone(), xf: to_text * *xf } + } + (color, _) => color.map_or(Paint::None, Paint::solid), + }; // Type without a stroke keeps the stroke's defaults, as its weight is 0. let (stroke_cap, stroke_join, stroke_miter_limit) = if s.stroke.is_some() { (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) } else { (LineCap::Butt, LineJoin::Miter, 10.0) }; @@ -1314,8 +1352,8 @@ fn char_style(s: &Style, scale: f64) -> CharStyle { baseline_shift: s.baseline_shift * scale, h_scale: s.h_scale * 100.0, v_scale: s.v_scale * 100.0, - fill: s.fill.map_or(Paint::None, Paint::solid), - stroke: s.stroke.map_or(Paint::None, Paint::solid), + fill: paint(s.fill, &s.fill_pattern), + stroke: paint(s.stroke, &s.stroke_pattern), stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 }, stroke_cap, stroke_join, @@ -1350,6 +1388,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let justify = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justify; // Right to left from the document's defaults, or the paragraph's own. @@ -1373,6 +1412,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading; assert_eq!(auto(b""), JustificationOptions::default().auto_leading); @@ -1395,6 +1435,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation; let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true }; @@ -1412,6 +1453,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation; assert_eq!(own(b""), d); @@ -1431,6 +1473,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let tabs = |own: &[u8], scale: f64| { para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale) @@ -1497,6 +1540,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), } .para(None); let mut s = story(" 80 mm ", &[0.0], ¶); @@ -1544,6 +1588,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: cases.iter().map(|c| frame(c)).collect(), + patterns: HashMap::new(), }; let vertical: Vec = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect(); assert_eq!(vertical, [true, false, false, false, false, false, false, false, false, false, false]); @@ -1561,6 +1606,7 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Block) /6 << /35 3 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let tcy = |run: &[u8]| texts.style(Some(&read(run).unwrap())).tate_chu_yoko; assert!(tcy(b"/6 << /35 3 >>"), "the run's own"); @@ -1584,6 +1630,7 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rotated) /6 << /35 2 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let roman = |defaults: &[u8], run: &[u8]| texts(defaults).style(Some(&read(run).unwrap())).standard_vertical_roman; assert!(roman(b"/35 1", b"/6 << >>"), "the document's defaults"); @@ -1614,9 +1661,10 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rounded) /6 << /61 1 /62 1 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let stroke = |run: &[u8]| { - let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0); + let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0, Affine::IDENTITY); (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) }; assert_eq!(stroke(b"/6 << /61 2 /62 2 /64 2.5 >>"), (LineCap::Square, LineJoin::Bevel, 2.5), "the run's own"); @@ -1638,6 +1686,88 @@ mod tests { assert_eq!(stroke(b"/5 1 /6 << /57 false >>"), (LineCap::Butt, LineJoin::Miter, 10.0), "no stroke"); } + /// The pattern "Dots", its tile's corner at `at`. + fn dots_at(at: (f64, f64)) -> Paint { + Paint::Pattern { pattern: "Dots".into(), xf: Affine::translate(at) } + } + + /// Texts whose characters may name the art style "Anon 1", which paints with "Dots" at (10, 20) + /// in the document, and "Solid", which isn't a pattern. + fn patterned_texts() -> Texts { + let patterns = HashMap::from([("Anon 1".to_string(), dots_at((10.0, 20.0))), ("Solid".to_string(), Paint::solid(Color::WHITE))]); + Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + patterns: HashMap::new(), + } + .with_patterns(patterns) + } + + /// Key 5 of a run's fill or stroke names the art style that paints it: one that paints with a + /// pattern gives the run that pattern in place of the colour of key 0 (black), placed in the + /// type's text space where the document has it. A fill or stroke that is off stays off, and a + /// key 5 that isn't the name of such a style, or isn't a name at all, keeps the colour. + #[test] + fn a_run_painted_by_a_patterns_art_style_takes_the_pattern() { + // GUARDS: type a pattern paints came in black, the colour key 0 keeps for it. + let texts = patterned_texts(); + let black_paint = Paint::solid(Color::gray(1.0)); + let to_text = Affine::translate((-4.0, -5.0)); + let paints = |run: &str| { + let s = char_style(&texts.style(Some(&read(run.as_bytes()).unwrap())), 1.0, to_text); + (s.fill, s.stroke) + }; + let black = |style: &str| format!("<< /99 /CAITextPaint /0 << /0 0 /1 [ 1.0 0.0 ] >> {style} >>"); + let fill = |style: &str| paints(&format!("/6 << /53 {} >>", black(style))); + assert_eq!(fill("/5 (Anon 1)"), (dots_at((6.0, 15.0)), Paint::None)); + let stroke = paints(&format!("/6 << /53 {} /57 true /54 {} >>", black(""), black("/5 (Anon 1)"))); + assert_eq!(stroke, (black_paint.clone(), dots_at((6.0, 15.0)))); + // Off. + assert_eq!(paints(&format!("/6 << /56 false /53 {} >>", black("/5 (Anon 1)"))), (Paint::None, Paint::None)); + assert_eq!(paints(&format!("/6 << /57 false /54 {} >>", black("/5 (Anon 1)"))).1, Paint::None); + // A stroke whose colour can't be read paints nothing, pattern or not, as before. + assert_eq!(paints("/6 << /57 true /54 << /0 << /0 9 /1 [ 1.0 ] >> /5 (Anon 1) >> >>").1, Paint::None); + for other in ["", "/5 (Anon 2)", "/5 (Solid)", "/5 ()", "/5 /Anon", "/5 1", "/5 [ (Anon 1) ]", "/5 << /0 (Anon 1) >>", "/5 true"] { + assert_eq!(fill(other), (black_paint.clone(), Paint::None), "{other}"); + } + // A text space the pattern can't be placed in keeps the colour. + let run = read(format!("/6 << /53 {} >>", black("/5 (Anon 1)")).as_bytes()).unwrap(); + for to_text in [Affine::new([1e308, 0.0, 0.0, 1e308, 0.0, 0.0]), Affine::new([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0])] { + assert_eq!(char_style(&texts.style(Some(&run)), 1.0, to_text).fill, black_paint.clone(), "{to_text:?}"); + } + } + + /// A run's pattern stays where the document has it whatever the type's own text space: turned + /// and moved point type, and area type, whose text space is the document's. + #[test] + fn a_runs_pattern_stays_where_the_document_has_it() { + let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + for frame in [ + FrameKind::Point([0.0, 1.0, -1.0, 0.0, 30.0, 40.0]), + FrameKind::Point([2.0, 0.0, 0.0, 2.0, -7.0, 3.0]), + FrameKind::Area(area_segments(), AreaFrame::default()), + ] { + let mut st = story("type", &[0.0], &Para::default()); + st.frames = vec![frame.clone()]; + st.runs[0].1.fill_pattern = Some(dots_at((10.0, 20.0))); + st.runs[0].1.stroke = Some(Color::BLACK); + st.runs[0].1.stroke_pattern = Some(dots_at((-3.0, 8.0))); + let node = st.node(NodeId(1), 0, (0.0, 0.0), to_doc).unwrap(); + let NodeKind::Text(t) = node.kind else { panic!("not type") }; + for (paint, at) in [(&t.runs[0].style.fill, Point::new(10.0, 20.0)), (&t.runs[0].style.stroke, Point::new(-3.0, 8.0))] { + let Paint::Pattern { pattern, xf } = paint else { panic!("{paint:?}") }; + assert_eq!(pattern, "Dots"); + let corner = t.xf * *xf * Point::ZERO; + assert!((corner - at).hypot() < 1e-9, "{frame:?}: {corner:?}"); + } + } + } + /// A right-to-left paragraph is laid out right to left even when it has no right-to-left /// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one /// that doesn't say, goes by its letters. @@ -1651,6 +1781,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let direction = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).direction; assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults"); @@ -1698,6 +1829,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], + patterns: HashMap::new(), }; texts.frame(0) } @@ -1904,6 +2036,8 @@ mod tests { v_scale: 1.0, fill: Some(Color::BLACK), stroke: None, + fill_pattern: None, + stroke_pattern: None, stroke_width: 1.0, stroke_cap: LineCap::Butt, stroke_join: LineJoin::Miter, @@ -2352,6 +2486,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], + patterns: HashMap::new(), }; texts.frame(0).unwrap() } diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c86cdb9516..978591b2d9 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -217,6 +217,7 @@ pub(super) fn build_from( placed_pages, envelopes_drawn, styles_read, + text_patterns, canvas_scale, xml_ids, bindings, @@ -374,7 +375,7 @@ pub(super) fn build_from( // The page's type that needs no text object of the layers: the layers' own legacy text, or // placed files that show it. let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?; - let texts = Texts::read(data); + let texts = Texts::read(data).map(|t| t.with_patterns(text_patterns)); // Each story is read once, however many text objects name it. let mut read: std::collections::BTreeMap>> = Default::default(); let stories: Vec>> = infos diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 8f64280367..23e8f0f70a 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3366,6 +3366,71 @@ fn stroked_type_keeps_its_cap_join_and_miter_limit() { ); } +#[test] +fn type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names() { + // GUARDS: type filled or stroked with a pattern swatch came in black: the text document keeps + // black as the paint's colour, and the pattern is in the art style that key 5 of the paint + // names, a style of one paint (`/SimpleStyle`) whose matrix is in art space. + use vectorcraft_color::{Color, Paint}; + use vectorcraft_geom::Point; + let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n"; + let simple = |name: &str, paint: &str| { + format!( + "/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 1 Xd\n/Paint ;\n /Def ;\n" + ) + }; + let pattern = |m: &str| format!("(Dots) 0 0 1 1 0 0 0 0 0 [{m}] p"); + let styles = [ + simple("Anon 1", &pattern("1 0 0 1 0 0")), + simple("Anon 2", &pattern("2 0 0 2 10 20")), + // Styles that give no pattern: one the file doesn't define, a matrix that doesn't place, no + // name, a colour after the pattern, a colour, a gradient. + simple("Anon 3", "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), + simple("Anon 4", &pattern("0 0 0 0 0 0")), + simple("Anon 5", "0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), + simple("Anon 6", &format!("{}\n0 0 0 1 k", pattern("1 0 0 1 0 0"))), + simple("Anon 7", "0 0 0 1 k"), + simple("Anon 8", "0 Bb\n2 (Rainbow) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB"), + ] + .concat(); + let styles = format!("%AI9_BeginArtStyles\n{styles}%AI9_EndArtStyles\n"); + let doc = text_document(&[("abcdefghijklmnopq\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let one = "<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 18 >>"; + assert!(doc.contains(one)); + let black = |style: &str| format!("<< /99 /CAITextPaint /0 << /0 0 /1 [ 1.0 0.0 ] >> /5 ({style}) >>"); + let run = |own: &str| format!("<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 {own} >> >> >> /1 2 >>"); + let mut runs = vec![run(&format!("/53 {}", black("Anon 1"))), run(&format!("/53 {} /57 true /54 {} /63 2.0", black("Anon 7"), black("Anon 2")))]; + runs.extend((3..=8).map(|n| run(&format!("/53 {}", black(&format!("Anon {n}")))))); + // A style the file doesn't have, over "q" and the story's end. + runs.push(run(&format!("/53 {}", black("Anon 9")))); + let doc = doc.replacen(one, &runs.join(" "), 1); + let art = native_with_text(&format!("{dots}{styles}{}{}", layer("Back", true, &square(10, 10)), layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let t = texts[0]; + // Each character's fill and stroke, a pattern as where its tile's top left corner is. + let paint = |p: &Paint| match p { + Paint::Pattern { pattern, xf } => { + let at = t.xf * *xf * Point::ZERO; + format!("{pattern} at {:.3} {:.3}", at.x, at.y) + } + Paint::Solid { color, .. } if *color == Color::gray(1.0) => "black".into(), + Paint::None => "none".into(), + other => format!("{other:?}"), + }; + let chars: Vec<(char, String, String)> = + t.runs.iter().flat_map(|r| r.text.chars().map(|c| (c, paint(&r.style.fill), paint(&r.style.stroke))).collect::>()).collect(); + let want = |c: char, fill: &str, stroke: &str| (c, fill.to_string(), stroke.to_string()); + // The tile's top left (0, 4) in art space, on a page 100 high: (0, 96). Scaled twice and moved + // by (10, 20): (10, 28) in art space, (10, 72) in the document. + let mut expected = vec![want('a', "Dots at 0.000 96.000", "none"), want('b', "Dots at 0.000 96.000", "none")]; + expected.extend(['c', 'd'].map(|c| want(c, "black", "Dots at 10.000 72.000"))); + expected.extend("efghijklmnopq".chars().map(|c| want(c, "black", "none"))); + assert_eq!(chars, expected); + assert_eq!(r.document.pattern("Dots").map(|p| p.tile.height()), Some(4.0)); +} + #[test] fn orientation_and_tate_chu_yoko_values_it_cant_use_are_ignored() { for bad in ["(2)", "2.5", "1e300", "-2", "1", "true", "[ 2 ]"] { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e2721d431b..09c34153f7 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, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -54,7 +54,10 @@ private editing data. VectorCraft: own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change. Type from the text document keeps auto leading as auto, each line taking the paragraph's share (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), - and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Area type of one + and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Type filled or + stroked with a pattern keeps its pattern, placed in the document as a path's pattern fill would be: a character's paint names + an art style of one paint, and the pattern is in that style (its colour in the text document is black). A pattern the file + doesn't define, or a style that isn't a pattern, keeps the colour as before. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` @@ -340,6 +343,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern. In the text document a character's fill or stroke is a colour (key 0) and the name of an art style (key 5). In users' files (used locally, never committed) that style is one paint (`/SimpleStyle`): a colour, the one key 0 already has, or a pattern, `(name) … [matrix] p`, whose key 0 is black. Such type came in black, or stroked in black. It now takes the pattern, its matrix in art space as a path's pattern fill has it (the files' PDF parts tile such type with that matrix), placed in the type's text space so that it stays where the document has it. A pattern the file doesn't define or can't place, and a style of any other paint, keep the colour as before. A `.ai` saved again writes such type as its outlines, as it writes all type with pattern paint, with that warning. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 157 in 46, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 10 of the 13 files with patterned type, 8 closer to the file's own page (one by under 0.01 points, in a file whose render differs from its page almost everywhere either way), 1 further by 0.03 points of its pixels (an envelope of type there now opens live in place of its drawn art), 1 not compared (its page renders at another size); notes change in 3 files: one loses its type note, one loses the note for type in an envelope and, now compared with its page, gains its type notes, and one that opens from its PDF part names a smaller difference. Tests `ate::tests::a_run_painted_by_a_patterns_art_style_takes_the_pattern`, `a_runs_pattern_stays_where_the_document_has_it`, `tests_native::type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names`. Scores and estimates unchanged. (#6856) | | 2026-11-11 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: type's strokes in an envelope (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them (used locally, never committed: there the drawn art keeps those strokes as stroked paths at the type's own weights, and its ring is as wide wherever the grid squeezes or stretches the type). With Distort Appearance, VectorCraft bent them with the art, as their filled outlines, so an envelope of stroked type in a squeezed or stretched grid didn't draw as its drawn art and kept it. The same holds in an envelope inside an envelope. Paths' strokes, geometry effects, opacity masks, linear gradients and pattern fills bend as before. On 224 of our own files: envelopes of type that open live go from 135 in 37 files to 154 in 45, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 31 files, by at most 2.3% of their pixels, 19 closer to the file's own page and 12 further, 11 of those where an envelope of type now opens live in place of its drawn art (the page's own art) and one by 4 pixels. Tests `envelope_distort::type_strokes_paint_at_their_own_weight_in_a_squeezed_envelope`, `a_stroke_on_types_appearance_paints_at_its_own_weight_in_a_squeezed_envelope`, `a_paths_stroke_still_bends_in_a_squeezed_envelope`, `tests_native::an_envelope_of_stroked_type_whose_drawn_art_strokes_the_bent_outlines_at_the_weight_opens_live` (#6644) | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) |