diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 06c08bc2d..e8dfd4a07 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -36,18 +36,37 @@ impl Exporter<'_> { Some(SeparationSpace::new(SeparationColorant::Custom(ink), alt)) } - /// The ink name of spot swatch `name` and its CMYK equivalent at 100% (a Lab spot color is - /// noted for [`crate::lab_spot`]); `None` when `name` isn't a spot color. + /// The ink name of spot swatch `name` ([`Self::colorant_name`]) and its CMYK equivalent at + /// 100% (a Lab spot color is noted for [`crate::lab_spot`]); `None` when `name` isn't a spot + /// color. pub(super) fn spot_ink(&mut self, name: &str) -> Option<(String, [f32; 4])> { let doc = self.doc; let sw = doc.swatch(name).filter(|s| s.spot)?; let color = doc.linked_color(sw.paint.color()?, true); + let ink = self.colorant_name(&sw.name); if let Color::Lab { l, a, b } = color - && !self.lab_spots.iter().any(|(n, _)| n == name) + && !self.lab_spots.iter().any(|(n, _)| *n == ink) { - self.lab_spots.push((name.to_string(), vectorcraft_color::cms::Lab::new(l, a, b))); + self.lab_spots.push((ink.clone(), vectorcraft_color::cms::Lab::new(l, a, b))); } - Some((sw.name.clone(), self.out.cmyk(&color, self.intent))) + Some((ink, self.out.cmyk(&color, self.intent))) + } + + /// The colorant name spot swatch `name` is written under: its own name, except that `All` + /// (every plate in PDF) and `None` (no plate) become `All Spot` and `None Spot`, numbered + /// `All Spot 2`, `All Spot 3`, … past the names of the document's spot swatches. + fn colorant_name(&self, name: &str) -> String { + if name != "All" && name != "None" { + return name.to_string(); + } + let taken = |c: &str| self.doc.swatches_iter().any(|s| s.spot && s.name == c); + let mut colorant = format!("{name} Spot"); + let mut n = 2; + while taken(&colorant) { + colorant = format!("{name} Spot {n}"); + n += 1; + } + colorant } /// A solid paint, in the Separation colour space at its tint when it's linked to a spot swatch @@ -109,12 +128,13 @@ impl Exporter<'_> { fn spot_of<'s>(doc: &vectorcraft_doc::Document, s: &'s vectorcraft_color::GradientStop) -> Option<&'s str> { s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)) } - let mut spots: Vec<(String, [f32; 4])> = Vec::new(); + // Each spot ink: its swatch's name, then its colorant name and CMYK equivalent. + let mut spots: Vec<(&str, (String, [f32; 4]))> = Vec::new(); for s in &g.stops { if let Some(n) = spot_of(doc, s) - && !spots.iter().any(|(m, _)| m == n) + && !spots.iter().any(|(m, _)| *m == n) { - spots.push(self.spot_ink(n)?); + spots.push((n, self.spot_ink(n)?)); } } // Each stop's ink coverage: the four process inks, then one tint per spot ink. @@ -123,7 +143,7 @@ impl Exporter<'_> { let mut v = vec![0.0; 4 + spots.len()]; match spot_of(doc, s) { Some(n) => { - let i = spots.iter().position(|(m, _)| m == n)?; + let i = spots.iter().position(|(m, _)| *m == n)?; v[4 + i] = s.tint; } None if self.paper_stop(s) => {} @@ -142,7 +162,7 @@ impl Exporter<'_> { let colorants = process .iter() .map(|&i| Colorant::Process(Process::ALL[i])) - .chain(spots.iter().map(|(name, f)| Colorant::Spot { name: name.clone(), alternate: cmyk::Color::new_f32(f[0], f[1], f[2], f[3]) })); + .chain(spots.iter().map(|(_, (name, f))| Colorant::Spot { name: name.clone(), alternate: cmyk::Color::new_f32(f[0], f[1], f[2], f[3]) })); let Some(space) = DeviceNSpace::new(colorants) else { self.warn("gradients whose spot inks can't share a DeviceN color space are exported without the spot inks"); return None; diff --git a/crates/pdf/src/tests/output.rs b/crates/pdf/src/tests/output.rs index 36eb939ff..f7732821c 100644 --- a/crates/pdf/src/tests/output.rs +++ b/crates/pdf/src/tests/output.rs @@ -161,6 +161,70 @@ fn spot_alternates_and_device_n_gradient_inks_are_written_as_the_document_holds_ assert!(text.contains("/C0[0 0 0.75]/C1[0.75 0.04 0]"), "the stops' C75, K4 and 75% Gold are written as they are"); } +/// A spot swatch's Separation space with alternate C0 M20 Y80 K10 under `colorant` (a PDF name). +fn gold_separation(colorant: &str) -> String { + format!("[/Separation{colorant}/DeviceCMYK<>]") +} + +#[test] +fn spot_inks_named_all_or_none_are_written_under_names_of_their_own() { + let solid = |name: &str, color: Color| Paint::Solid { color, swatch: Some(name.into()), tint: 0.5 }; + let gold = Color::cmyk(0.0, 0.2, 0.8, 0.1); + let rect = |d: &mut Document, i: usize, y: f64, paint: Paint, stroke: Paint| { + let l = d.layers[0].id; + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, y, 140.0, y + 20.0)), Appearance::basic(paint, stroke, 1.0)); + d.insert(Some(l), i, n).unwrap(); + }; + let stop = |offset: f32, ink: &str, tint: f32| { + let mut s = GradientStop::new(offset, gold.tinted(tint)); + s.swatch = Some(ink.into()); + s.tint = tint; + s + }; + let gradient = |stops| Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + for (name, colorant) in [("All", "/All#20Spot"), ("None", "/None#20Spot")] { + let mut d = doc(ColorMode::Cmyk, &[]); + for ink in [name, "Gold"] { + d.swatches.push(Swatch { name: ink.into(), paint: Paint::solid(gold), global: true, spot: true }); + } + // A fill of the spot ink with a Registration stroke, a gradient of the spot ink's tints, + // and a gradient from Gold to the spot ink. + let registration = solid(vectorcraft_color::swatch::REGISTRATION, Color::cmyk(1.0, 1.0, 1.0, 1.0)); + rect(&mut d, 0, 10.0, solid(name, gold), registration); + rect(&mut d, 1, 40.0, gradient(vec![stop(0.0, name, 1.0), stop(1.0, name, 0.25)]), Paint::None); + rect(&mut d, 2, 70.0, gradient(vec![stop(0.0, "Gold", 1.0), stop(1.0, name, 1.0)]), Paint::None); + let text = String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned(); + let spaces = text.matches(&format!("[/Separation{colorant}/")).count(); + assert!(spaces > 0 && text.matches(&gold_separation(colorant)).count() == spaces, "{name}: {text}"); + assert!(!text.contains(&gold_separation(&format!("/{name}"))), "{name} is not the colorant: {text}"); + assert!(text.contains("[/Separation/All/DeviceCMYK<<"), "Registration prints on every plate: {text}"); + assert!(text.contains(&format!("[/DeviceN[/Gold{colorant}]")), "{name}: {text}"); + } + + // A spot swatch already named All Spot keeps its name, and All is numbered past it. + let mut d = doc(ColorMode::Cmyk, &[]); + for name in ["All", "All Spot"] { + d.swatches.push(Swatch { name: name.into(), paint: Paint::solid(gold), global: true, spot: true }); + } + rect(&mut d, 0, 10.0, solid("All", gold), solid("All Spot", gold)); + let text = String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned(); + assert!(text.contains(&gold_separation("/All#20Spot#202")) && text.contains(&gold_separation("/All#20Spot")), "{text}"); +} + +#[test] +fn a_lab_spot_ink_named_all_keeps_its_lab_alternate() { + let mut d = doc(ColorMode::Cmyk, &[]); + let lab = Color::Lab { l: 60.0, a: 40.0, b: 50.0 }; + d.swatches.push(Swatch { name: "All".into(), paint: Paint::solid(lab), global: true, spot: true }); + let fill = Paint::Solid { color: lab, swatch: Some("All".into()), tint: 1.0 }; + let l = d.layers[0].id; + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 40.0, 30.0, 60.0)), Appearance::basic(fill, Paint::None, 0.0)); + d.insert(Some(l), 0, n).unwrap(); + let text = String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned(); + assert!(text.contains("[/Separation/All#20Spot[/Lab<<"), "{text}"); + assert!(!text.contains("[/Separation/All/") && !text.contains("[/Separation/All["), "{text}"); +} + /// The uncompressed PDF of a CMYK document whose rectangle is filled with gradient `stops` /// (Perceptual when `perceptual`), with spot swatch "Gold" (C0 M20 Y80 K10). fn gold_gradient_pdf(stops: Vec, perceptual: bool) -> String { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e3241865..23cc1efa3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1066,6 +1066,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. 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, and a gradient with the Perceptual method is written as an image, without the spot ink. 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). A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing). A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`).