Merge pull request #5903 from dotCooCoo/fix/ai-swatch-name-collisions

.ai import: a pattern or color named as another swatch keeps its swatch
This commit is contained in:
BFlat
2026-11-06 16:46:34 +08:00
committed by GitHub
4 changed files with 174 additions and 31 deletions
+107 -30
View File
@@ -22,6 +22,19 @@ fn ruler_unit(v: u8) -> Option<Unit> {
})
}
/// `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<String>, 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<String, String> {
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<String> = 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<String> =
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::<Vec<_>>() {
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<String> = 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<String> = 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<vectorcraft_brush::Brush>, 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<String> = 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::<Vec<_>>() {
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<String>, _: &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);
+65
View File
@@ -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::<Vec<_>>(), ["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() {
File diff suppressed because one or more lines are too long
+1
View File
@@ -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).