From d054b5b4c72c9b7125b52f6948f0b66b422659ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 19:23:29 -0700 Subject: [PATCH] Legacy .ai export: the title and spot color and gradient names are written in Windows-1252 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving a version 7 .ai wrote the title, spot color names and gradient names as the UTF-8 bytes of their characters: a spot color named Café was written (Caf\303\251) in the header's custom colors and in each x. Type and, since #2722, layer names are written in Windows-1252, the encoding the file installs with TE. These names now go through #2722's Windows-1252 path, so Café is written (Caf\351). A character outside Windows-1252 is written as ?, with one warning for all of these names. A spot color keeps one name in the header, in each x and X and in gradient stops, and a gradient keeps one name in its definition and in each Bg. Two spot colors or two gradients whose names become the same that way are numbered (? 2). Docs: docs/file-format-parity.md. --- crates/eps/src/legacy_ai.rs | 110 ++++++++++++++++++++------- crates/eps/src/tests_legacy_ai.rs | 121 ++++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 3 files changed, 204 insertions(+), 30 deletions(-) diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 741fc2d6d..6b400ab05 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -50,7 +50,9 @@ //! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden //! layer they stay where they are). Type that 8-bit text can't hold (characters outside //! Windows-1252, vertical type, inline graphics, gradient or pattern fills) is written as its -//! outlines, and all type with [`LegacyAiOptions::outline_text`]. +//! outlines, and all type with [`LegacyAiOptions::outline_text`]. Layer, spot color and gradient +//! names and the title are written in Windows-1252 too, and a character outside it is written as +//! `?`. Two spot colors or two gradients whose names become the same that way are numbered (`? 2`). //! //! The prolog is a procedure set of the writer's own ([`PROLOG`]) that gives the operators their //! meaning in PostScript, so the file also prints and previews as a PostScript program (its @@ -87,6 +89,8 @@ pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)"; pub const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is written opaque: legacy .ai files have none"; pub const LAYER_NAME: &str = "characters outside Windows Latin-1 in layer names are written as ? (legacy .ai files can't hold them)"; +pub const TITLE_AND_COLOR_NAMES: &str = + "characters outside Windows Latin-1 in the title and in spot color and gradient names are written as ? (legacy .ai files can't hold them)"; pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)"; const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape"; const FREEFORM: &str = "freeform gradients are written as their average colour"; @@ -163,11 +167,12 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result Result { ps::push_nums(s, &cmyk.map(f64::from)); - let _ = write!(s, "{} ", ps::string(&name)); + let _ = write!(s, "{} ", latin_string(w.spot_name(&name))); ps::push_num(s, f64::from(1.0 - tint)); s.push_str(" 3 "); } @@ -393,6 +398,13 @@ fn invertible(a: Affine) -> bool { det.is_finite() && det.abs() > 1e-12 } +/// Name `name` as a PostScript string of its characters' Windows-1252 codes (a character outside +/// Windows-1252 is left out: [`Writer::latin_name`] replaces them first). +fn latin_string(name: &str) -> String { + let bytes: Vec = name.chars().filter_map(latin::encode).collect(); + ps::bytes(&bytes) +} + struct Writer<'a> { doc: &'a Document, /// The document → art space. @@ -401,8 +413,10 @@ struct Writer<'a> { body: String, /// Gradient definitions, by the name objects use. gradients: Vec<(String, Gradient)>, - /// Spot colours used: name and full CMYK equivalent. + /// Spot colors used: the name written and full CMYK equivalent. spots: Vec<(String, [f32; 4])>, + /// The name each spot color (by its swatch's name) is written with ([`Self::spot`]). + spot_names: Vec<(String, String)>, warnings: Vec, /// The lock state the last `A` set. locked: bool, @@ -430,6 +444,7 @@ impl<'a> Writer<'a> { body: String::new(), gradients: vec![], spots: vec![], + spot_names: vec![], warnings: vec![], locked: false, nest: 0, @@ -454,23 +469,62 @@ impl<'a> Writer<'a> { // ---------- layers and objects ---------- - /// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it - /// is written as `?`, with a warning. - fn layer_name(&mut self, name: &str) -> String { + /// `name` with each character outside Windows-1252 (the encoding type and names are written + /// in) replaced by `?`; adds the warning `warning` when it replaces one. + fn latin_name(&mut self, name: &str, warning: &str) -> String { let mut lost = false; - let bytes: Vec = name + let kept = name .chars() .map(|c| { - latin::encode(c).unwrap_or_else(|| { + if latin::encode(c).is_some() { + c + } else { lost = true; - b'?' - }) + '?' + } }) .collect(); if lost { - self.warn(LAYER_NAME); + self.warn(warning); } - ps::bytes(&bytes) + kept + } + + /// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it + /// is written as `?`, with a warning. + fn layer_name(&mut self, name: &str) -> String { + latin_string(&self.latin_name(name, LAYER_NAME)) + } + + /// Note spot color `name` (its swatch's name), of full CMYK equivalent `cmyk`, as a custom + /// color of the document, and give the name it is written with. Its characters outside + /// Windows-1252 are written as `?` (with a warning), and it is numbered when another spot color + /// is already written with that name; the name is the same each time. + fn spot(&mut self, name: &str, cmyk: [f32; 4]) -> String { + let written = match self.spot_names.iter().find(|(n, _)| n == name) { + Some((_, w)) => w.clone(), + None => { + let base = self.latin_name(name, TITLE_AND_COLOR_NAMES); + let mut written = base.clone(); + let mut i = 1; + while self.spot_names.iter().any(|(_, w)| *w == written) { + i += 1; + written = format!("{base} {i}"); + } + self.spot_names.push((name.to_string(), written.clone())); + written + } + }; + if !self.spots.iter().any(|(n, _)| *n == written) { + self.spots.push((written.clone(), cmyk)); + } + written + } + + /// The name spot color `name` (its swatch's name) is written with, once [`Self::spot`] has + /// noted it. + fn spot_name<'n>(&'n self, name: &'n str) -> &'n str { + self.spot_names.iter().find(|(n, _)| n == name).map_or(name, |(_, w)| w.as_str()) } /// A top-level layer. False when it isn't a layer. @@ -965,7 +1019,10 @@ impl<'a> Writer<'a> { /// Set the fill (or with `stroke`, the stroke) colour to solid paint `p`. fn color(&mut self, p: &Paint, stroke: bool) { - let ink = self.ink(p); + let ink = match self.ink(p) { + Ink::Spot { cmyk, name, tint } => Ink::Spot { name: self.spot(&name, cmyk), cmyk, tint }, + ink => ink, + }; let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill }; let s = &mut self.body; match ink { @@ -983,13 +1040,10 @@ impl<'a> Writer<'a> { } Ink::Spot { cmyk, name, tint } => { ps::push_nums(s, &cmyk.map(f64::from)); - let _ = write!(s, "{} ", ps::string(&name)); + let _ = write!(s, "{} ", latin_string(&name)); // The format's tint is how much lighter than the full colour (0: full). ps::push_num(s, f64::from(1.0 - tint)); let _ = writeln!(s, " {}", op("x", "X")); - if !self.spots.iter().any(|(n, _)| *n == name) { - self.spots.push((name, cmyk)); - } } } } @@ -1009,10 +1063,8 @@ impl<'a> Writer<'a> { if let Some(sw) = &stop.swatch { // Spot colours in stops are custom colours too. let p = Paint::Solid { color: stop.color, swatch: Some(sw.clone()), tint: stop.tint }; - if let Ink::Spot { cmyk, name, .. } = self.ink(&p) - && !self.spots.iter().any(|(n, _)| *n == name) - { - self.spots.push((name, cmyk)); + if let Ink::Spot { cmyk, name, .. } = self.ink(&p) { + self.spot(&name, cmyk); } } } @@ -1036,21 +1088,21 @@ impl<'a> Writer<'a> { s.push_str("Bh\n"); } s.push_str("1 "); - s.push_str(&ps::string(&name)); + s.push_str(&latin_string(&name)); s.push(' '); ps::push_nums(&mut s, &[start.x, start.y, angle, length]); s.push_str("1 0 0 1 0 0 Bg\n"); s } - /// The name of the definition of `g` (its swatch's name when it has one), defined the first - /// time. + /// The name of the definition of `g` (its swatch's name when it has one, with each character + /// outside Windows-1252 written as `?` and a warning), defined the first time. fn define(&mut self, g: &Gradient, swatch: Option<&str>) -> String { if let Some((name, _)) = self.gradients.iter().find(|(_, d)| d == g) { return name.clone(); } let taken = |n: &str, defs: &[(String, Gradient)]| defs.iter().any(|(m, _)| m == n); - let base = swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient").to_string(); + let base = self.latin_name(swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient"), TITLE_AND_COLOR_NAMES); let mut name = base.clone(); let mut i = 1; while taken(&name, &self.gradients) { diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index 1e5b2c2c5..3644576d6 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -612,6 +612,127 @@ fn spot_colours_are_custom_colours() { assert!((tint - 0.5).abs() < 1e-6); } +/// A document with a spot color swatch for each of `spots`, a square filled with each at half +/// tint, and a square whose linear gradient fill (the first spot color to white) names the +/// gradient swatch `gradient` (the document holds no such swatch; the writer reads only the +/// fill's name). +fn spots_and_gradient(spots: &[&str], gradient: &str) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for (i, name) in spots.iter().enumerate() { + let c = i as f32 * 0.5; + doc.swatches.push(vectorcraft_color::Swatch { + name: (*name).into(), + paint: Paint::solid(Color::cmyk(c, 0.5, 1.0, 0.0)), + global: true, + spot: true, + }); + let fill = Paint::Solid { color: Color::cmyk(c / 2.0, 0.25, 0.5, 0.0), swatch: Some((*name).into()), tint: 0.5 }; + let x = 10.0 + 40.0 * i as f64; + let n = shape(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 30.0, 40.0)), fill, Paint::None); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let mut first = GradientStop::new(0.0, Color::cmyk(0.0, 0.5, 1.0, 0.0)); + first.swatch = Some(spots[0].into()); + let g = Gradient { kind: GradientKind::Linear, stops: vec![first, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))] }; + let mut paint = GradientPaint::new(g); + paint.swatch = Some(gradient.into()); + let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 50.0, 50.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + doc +} + +/// `doc` written with the title `title`, and run as a PostScript program ([`prints_as`]). +fn write_titled(doc: &Document, title: &str) -> LegacyAiOutput { + let o = LegacyAiOptions { title: title.into(), created: Some(0), ..LegacyAiOptions::default() }; + let out = export_legacy_ai(doc, &o).unwrap(); + prints_as(&out.bytes, doc, &o); + out +} + +/// The spot colors and gradients of the objects of `doc`'s first layer, in paint order: each +/// solid fill's swatch, and each gradient fill's swatch with its first stop's. +fn inks(doc: &Document) -> Vec> { + let mut out = vec![]; + for n in children(&doc.layers[0]) { + match n.appearance.fill_paint() { + Paint::Solid { swatch, .. } => out.push(swatch.clone()), + Paint::Gradient(g) => { + out.push(g.swatch.clone()); + out.push(g.gradient.stops.first().and_then(|s| s.swatch.clone())); + } + _ => {} + } + } + out +} + +/// The title, spot color names and gradient names are written in Windows-1252, as layer names +/// are: é is 351 (octal) in the header's custom colors, each `x`, the gradient's definition, its +/// stops and `Bg`. The spot color and gradient names read back as they were. +#[test] +fn spot_gradient_and_title_names_are_written_in_windows_1252() { + let doc = spots_and_gradient(&["Caf\u{e9}"], "D\u{e9}grad\u{e9}"); + let out = write_titled(&doc, "R\u{e9}sum\u{e9}"); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + for needle in [ + "%%Title: (R\\351sum\\351)\n", + "%%DocumentCustomColors: (Caf\\351)\n", + "%%CMYKCustomColor: 0 0.5 1 0 (Caf\\351)\n", + "0 0.5 1 0 (Caf\\351) 0.5 x\n", + "%AI5_BeginGradient: (D\\351grad\\351)\n(D\\351grad\\351) 0 2 Bd\n", + "0 0.5 1 0 (Caf\\351) 0 3 50 0 %_Bs\n", + "1 (D\\351grad\\351) ", + ] { + assert!(text.contains(needle), "missing {needle:?} in\n{text}"); + } + assert!(!text.contains("\\303"), "{text}"); + assert!(!out.warnings.iter().any(|w| w == TITLE_AND_COLOR_NAMES), "{:?}", out.warnings); + let read = crate::import(&out.bytes).unwrap().document; + let names = inks(&read); + let want = ["Caf\u{e9}", "D\u{e9}grad\u{e9}", "Caf\u{e9}"].map(|n| Some(n.to_string())); + assert_eq!(names, want); +} + +/// A character Windows-1252 can't hold is written as `?`, with one warning for all of these +/// names. Names that become the same are numbered, so the header, each `x` and each `Bg` still +/// tell the colors and gradients apart. +#[test] +fn names_outside_windows_1252_are_replaced_and_kept_apart() { + // 赤 and 青 (spot colors), 渐变 and グラ (gradients), 日本 (the title). + let mut doc = spots_and_gradient(&["\u{8d64}", "\u{9752}"], "\u{6e10}\u{53d8}"); + let g = Gradient { + kind: GradientKind::Linear, + stops: vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))], + }; + let mut paint = GradientPaint::new(g); + paint.swatch = Some("\u{30b0}\u{30e9}".into()); + let n = shape(&mut doc, shapes::rectangle(Rect::new(60.0, 50.0, 100.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = write_titled(&doc, "\u{65e5}\u{672c}"); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + for needle in [ + "%%Title: (??)\n", + "%%DocumentCustomColors: (?)\n%%+ (? 2)\n", + "%%CMYKCustomColor: 0 0.5 1 0 (?)\n%%+ 0.5 0.5 1 0 (? 2)\n", + "0 0.5 1 0 (?) 0.5 x\n", + "0.5 0.5 1 0 (? 2) 0.5 x\n", + "(??) 0 2 Bd\n", + "(?? 2) 0 2 Bd\n", + "0 0.5 1 0 (?) 0 3 50 0 %_Bs\n", + "1 (??) ", + "1 (?? 2) ", + ] { + assert!(text.contains(needle), "missing {needle:?} in\n{text}"); + } + assert_eq!(out.warnings.iter().filter(|w| *w == TITLE_AND_COLOR_NAMES).count(), 1, "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == LAYER_NAME), "{:?}", out.warnings); + let read = crate::import(&out.bytes).unwrap().document; + let want = [Some("?"), Some("? 2"), Some("??"), Some("?"), Some("?? 2"), None].map(|n| n.map(String::from)); + assert_eq!(inks(&read), want); +} + #[test] fn losses_are_warned() { let mut doc = sample(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b50d846ff..728827e92 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -67,7 +67,8 @@ private editing data. VectorCraft: all type as outlines. The format can hide only layers: hidden objects go to a hidden layer of their own above their layer, are omitted, or are made visible (Hidden Objects, `hiddenObjects: "layer"|"omit"|"visible"`). Sublayers become groups; appearances, effects and brushes are expanded; object names are left out; layer - names are written in Windows-1252, with `?` for characters outside it. + names, spot color and gradient names and the title are written in Windows-1252, with `?` for characters outside + it (two spot colors or two gradients whose names become the same are numbered). The file is also a PostScript program that runs: its prolog, a procedure set of VectorCraft's own written from the PostScript Language Reference, gives the format's operators their meaning, so it prints and previews (gradients filled with their first stop's colour, type left to the apps that read the format). VectorCraft