From 968bf1dc4818e633f12e123f483ff8b69b2024e9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 15:33:00 +0700 Subject: [PATCH] .ai import: a pattern or color named as another swatch keeps its swatch Squashed from #5903 onto main. --- crates/eps/src/import/ai/read/document.rs | 137 +++++++++++++++++----- crates/eps/src/import/ai/tests.rs | 65 ++++++++++ docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 1 + 4 files changed, 174 insertions(+), 31 deletions(-) diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 2408b11cc..2fd918fe6 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -22,6 +22,19 @@ fn ruler_unit(v: u8) -> Option { }) } +/// `base`, or the first of "base 2", "base 3"… not in `taken`, as [`Document::free_swatch_name`] +/// gives it when `taken` holds the document's swatch and colour group names; `taken` takes the +/// name returned. +fn free_swatch_name(taken: &mut std::collections::HashSet, base: &str) -> String { + let name = if taken.contains(base) { + (2..).map(|i| format!("{base} {i}")).find(|n| !taken.contains(n)).unwrap_or_else(|| base.to_string()) + } else { + base.to_string() + }; + taken.insert(name.clone()); + name +} + /// An artboard of the `/Document`'s `ArtboardArray`: its name, its rect in art space and its /// display options. #[derive(Clone, Debug)] @@ -820,6 +833,86 @@ impl<'a> Reader<'a> { Some((swatches, groups)) } + /// The swatches of the file's named colors and patterns, added to `doc`'s, and the new name of + /// each color that took a numbered one. + /// + /// The colour operators' definitions come first: a gradient, defined before the art, names a + /// global process colour as a stop and would otherwise make it an ink (#1771). The header's + /// custom colors follow. The first definition of a name makes its swatch, and takes the place + /// of a default swatch of that name when the file has no palette of its own (`own_palette`; a + /// reserved name in brackets, such as `[None]`, keeps its swatch). Then each pattern gets a + /// swatch. A color whose name a gradient or pattern swatch of the palette has, and a pattern + /// whose name another swatch has, take the first free "Name 2", "Name 3"… + /// ([`Document::free_swatch_name`]) once the other colors and the patterns have their swatches; + /// a pattern a swatch already paints with gets no other. + fn file_swatches(&self, doc: &mut Document, own_palette: bool) -> HashMap { + let mut defined = std::collections::HashSet::new(); + let mut clashes = vec![]; + // The swatch names there are, looked up rather than scanned for each name a file defines + // (`Document::swatch` also knows the registration colour). + let mut have: std::collections::HashSet = doc.swatches_iter().map(|s| s.name.clone()).collect(); + have.insert(vectorcraft_color::swatch::REGISTRATION.to_string()); + // The names a swatch of another kind than a gradient or pattern has. + let mut solid: std::collections::HashSet = + doc.swatches_iter().filter(|s| !matches!(s.paint, Paint::Gradient(_) | Paint::Pattern { .. })).map(|s| s.name.clone()).collect(); + let colors = self.spots.iter().chain(&self.stop_spots).map(|n| (n, n.spot)).chain(self.custom_colors.iter().map(|n| (n, true))); + for (n, spot) in colors { + let first = defined.insert(n.name.clone()); + let swatch = Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot }; + let reserved = n.name.starts_with('[') && n.name.ends_with(']'); + if have.insert(n.name.clone()) { + solid.insert(n.name.clone()); + doc.swatches.push(swatch); + } else if first && !reserved { + if !own_palette && let Some(default) = doc.swatch_mut(&n.name) { + *default = swatch; + solid.insert(n.name.clone()); + } else if !solid.contains(&n.name) { + clashes.push(swatch); + } + } + } + let mut unnamed = vec![]; + for pattern in doc.patterns.iter().map(|p| p.name.clone()).collect::>() { + if have.insert(pattern.clone()) { + doc.swatches.push(Swatch { + paint: Paint::Pattern { pattern: pattern.clone(), xf: Affine::IDENTITY }, + name: pattern, + global: false, + spot: false, + }); + } else { + unnamed.push(pattern); + } + } + // Every color and pattern name now has a swatch, so a numbered name takes none of them. The + // names taken and the patterns painted with are looked up rather than scanned for each one. + let mut taken: std::collections::HashSet = doc.swatches_iter().map(|s| s.name.clone()).collect(); + taken.extend(doc.swatch_groups.iter().map(|g| g.name.clone())); + taken.insert(vectorcraft_color::swatch::REGISTRATION.to_string()); + let mut painted: std::collections::HashSet = doc + .swatches_iter() + .filter_map(|s| match &s.paint { + Paint::Pattern { pattern, .. } => Some(pattern.clone()), + _ => None, + }) + .collect(); + let mut renamed = HashMap::new(); + for swatch in clashes { + let name = free_swatch_name(&mut taken, &swatch.name); + renamed.insert(swatch.name.clone(), name.clone()); + doc.swatches.push(Swatch { name, ..swatch }); + } + for pattern in unnamed { + if !painted.insert(pattern.clone()) { + continue; + } + let name = free_swatch_name(&mut taken, &pattern); + doc.swatches.push(Swatch { paint: Paint::Pattern { pattern, xf: Affine::IDENTITY }, name, global: false, spot: false }); + } + renamed + } + /// The brushes read ([`brush::finish_brushes`]) and how many weren't; `None` when the file has /// no brush objects (the document keeps the default brushes). fn brush_library(&mut self) -> Option<(Vec, usize)> { @@ -957,39 +1050,11 @@ impl<'a> Reader<'a> { } None => false, }; - // The colour operators' definitions first: a gradient, defined before the art, names a - // global process colour as a stop and would otherwise make it an ink (#1771). The header's - // custom colors follow. The first definition of a name makes its swatch, and takes the place - // of a default swatch of that name when the file has no palette of its own (a reserved name - // in brackets, such as `[None]`, keeps its swatch). - let mut defined = std::collections::HashSet::new(); - // The swatch names there are, looked up rather than scanned for each name a file defines - // (`Document::swatch` also knows the registration colour). - let mut have: std::collections::HashSet = doc.swatches_iter().map(|s| s.name.clone()).collect(); - have.insert(vectorcraft_color::swatch::REGISTRATION.to_string()); - let colors = self.spots.iter().chain(&self.stop_spots).map(|n| (n, n.spot)).chain(self.custom_colors.iter().map(|n| (n, true))); - for (n, spot) in colors { - let first = defined.insert(n.name.clone()); - let swatch = Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot }; - if have.insert(n.name.clone()) { - doc.swatches.push(swatch); - } else if first - && !own_palette - && !(n.name.starts_with('[') && n.name.ends_with(']')) - && let Some(default) = doc.swatch_mut(&n.name) - { - *default = swatch; - } - } + let renamed = self.file_swatches(&mut doc, own_palette); if let Some(author) = self.author.take() { doc.metadata.author = author; } - for name in doc.patterns.iter().map(|p| p.name.clone()).collect::>() { - if have.insert(name.clone()) { - doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false }); - } - } - let brushes = self.brush_library(); + let mut brushes = self.brush_library(); // The file's graphic styles in place of the defaults, and the objects linked to them. let (styles, ids) = self.graphic_styles(); let styles_read = !styles.is_empty(); @@ -1003,6 +1068,18 @@ impl<'a> Reader<'a> { } doc.graphic_styles = styles.into(); } + // The colors linked to a color that took a free name follow it. + if !renamed.is_empty() { + let mut follow = |_: &mut vectorcraft_color::Color, link: &mut Option, _: &mut f32| { + let Some(new) = link.as_ref().and_then(|l| renamed.get(l)) else { return false }; + *link = Some(new.clone()); + true + }; + doc.map_solid_paints(&mut follow); + for a in brushes.iter_mut().flat_map(|(lib, _)| lib.iter_mut().flat_map(|b| b.art_mut())) { + vectorcraft_doc::swatches::map_node_solid_paints(a, &mut follow); + } + } let mut warnings = self.warnings; if let Some((lib, unread)) = brushes { vectorcraft_brush::store(&mut doc, &lib); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index e7bdd2bd7..c883719e3 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -818,6 +818,71 @@ fn pattern_names_are_numbered_around_the_documents_swatches() { assert_eq!(names(&d), ["Sea 2"]); } +/// A pattern whose name has no newline keeps it, and its swatch takes the first free "Name 2" when +/// another swatch has the name: a default swatch, a named color the art uses, or a color of the +/// palette. A pattern a swatch already paints with gets no other. +#[test] +fn a_pattern_swatch_named_as_another_swatch_is_numbered() { + let def = |name: &str| DOTS.replace("(Dots)", &format!("({name})")); + let filled = |name: &str| format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0)); + let ink = format!("0 1 1 0 (Ink) 0 x\n{}f\n", rect_path(0.0, 0.0, 5.0, 5.0)); + let paints = |d: &Document, name: &str| d.swatches_iter().find(|s| s.name == name).map(|s| s.paint.clone()); + let doc = read_ok(&format!("{}{}{}", def("White"), def("Ink"), layer(&format!("{}{}{ink}", filled("White"), filled("Ink"))))).doc; + assert_eq!(doc.patterns.iter().map(|p| p.name.as_str()).collect::>(), ["White", "Ink"]); + for name in ["White", "Ink"] { + assert!(matches!(paints(&doc, name), Some(Paint::Solid { .. })), "{name}"); + let numbered = format!("{name} 2"); + assert!(matches!(paints(&doc, &numbered), Some(Paint::Pattern { pattern, .. }) if pattern == name), "{numbered}"); + } + let fills: Vec<_> = art(&doc).iter().map(|n| fill(n)).collect(); + assert!(matches!(&fills[..2], [Paint::Pattern { pattern: a, .. }, Paint::Pattern { pattern: b, .. }] if a == "White" && b == "Ink")); + // A pattern defined later under the numbered name keeps its swatch's name. + let doc = read_ok(&format!("{}{}{}", def("White"), def("White 2"), layer(&filled("White")))).doc; + assert!(matches!(paints(&doc, "White 2"), Some(Paint::Pattern { pattern, .. }) if pattern == "White 2")); + assert!(matches!(paints(&doc, "White 3"), Some(Paint::Pattern { pattern, .. }) if pattern == "White")); + // A color of the palette. + let palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0.5 k\n(Mist)\nPc\nPB\n%AI5_EndPalette\n"; + let doc = read_ok(&format!("{}{palette}{}", def("Mist"), layer(&filled("Mist")))).doc; + assert!(matches!(paints(&doc, "Mist"), Some(Paint::Solid { .. }))); + assert!(matches!(paints(&doc, "Mist 2"), Some(Paint::Pattern { pattern, .. }) if pattern == "Mist")); + // A palette swatch under another name already paints with the pattern. + let palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0.5 k\n(Mist)\nPc\n(Mist) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Fog)\nPc\nPB\n%AI5_EndPalette\n"; + let doc = read_ok(&format!("{}{palette}{}", def("Mist"), layer(&filled("Mist")))).doc; + let names: Vec<&str> = doc.swatches_iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["[None]", "Mist", "Fog"]); +} + +/// A named color whose name a gradient or pattern swatch of the palette has takes the first free +/// "Name 2", and the fills and gradient stops linked to it follow it. +#[test] +fn a_color_named_as_a_palette_gradient_or_pattern_is_numbered() { + let gradient = "%AI5_BeginGradient: (Brand)\n(Brand) 0 2 Bd\n[\n\ + %_0 1 1 0 (Brand) 0 0 5 1 6 50 0 Bs\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let palette = "%AI5_BeginPalette\n0 0 Pb\nBb\n2 (Brand) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(Brand)\nPc\n\ + (Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Ink)\nPc\nPB\n%AI5_EndPalette\n"; + let colored = |name: &str| format!("0 1 1 0 ({name}) 0 x\n{}f\n", rect_path(0.0, 0.0, 5.0, 5.0)); + let doc = read_ok(&format!("{gradient}{DOTS}{palette}{}", layer(&format!("{}{}", colored("Brand"), colored("Ink"))))).doc; + let paints = |name: &str| doc.swatches_iter().find(|s| s.name == name).map(|s| s.paint.clone()); + assert!(matches!(paints("Brand"), Some(Paint::Gradient(_)))); + assert!(matches!(paints("Ink"), Some(Paint::Pattern { .. }))); + for name in ["Brand 2", "Ink 2"] { + let sw = doc.swatches_iter().find(|s| s.name == name).unwrap_or_else(|| panic!("{name}")); + assert!(matches!(sw.paint, Paint::Solid { .. }) && sw.global && sw.spot, "{sw:?}"); + } + let links: Vec<_> = art(&doc).iter().map(|n| if let Paint::Solid { swatch, .. } = fill(n) { swatch } else { None }).collect(); + assert_eq!(links, [Some("Brand 2".to_string()), Some("Ink 2".to_string())]); + let Some(Paint::Gradient(g)) = paints("Brand") else { panic!() }; + assert_eq!(g.gradient.stops[0].swatch.as_deref(), Some("Brand 2"), "the stop's link"); + // A color defined later under the numbered name keeps it and its links. + let later = format!("0 0 1 0 (Brand 2) 0 x\n{}f\n", rect_path(0.0, 0.0, 5.0, 5.0)); + let doc = read_ok(&format!("{gradient}{DOTS}{palette}{}", layer(&format!("{}{later}", colored("Brand"))))).doc; + let color = |name: &str| doc.swatches_iter().find(|s| s.name == name).and_then(|s| s.paint.color()); + assert_eq!(color("Brand 2"), Some(Color::cmyk(0.0, 0.0, 1.0, 0.0))); + assert_eq!(color("Brand 3"), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0))); + let links: Vec<_> = art(&doc).iter().map(|n| if let Paint::Solid { swatch, .. } = fill(n) { swatch } else { None }).collect(); + assert_eq!(links, [Some("Brand 3".to_string()), Some("Brand 2".to_string())]); +} + /// A palette's pattern swatch named by its pattern takes the name the pattern is shown with. #[test] fn a_palette_pattern_swatch_takes_its_patterns_shown_name() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9b8d2aca6..811a01483 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -141,7 +141,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 49824735f..ab402e685 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1175,6 +1175,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source. Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches. + A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`). PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). PDF export writes a spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4) as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients, colorized images and Lab alternates; Registration still prints on every plate (`pdf/src/tests/output.rs`). - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).