diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index b5437b670..e07e318b1 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -142,8 +142,9 @@ pub(crate) struct Scene<'a> { cms: Arc, body: String, setup: String, - /// Spot colour spaces defined in the setup (`VCsp`): swatch name and CMYK equivalent. - spots: Vec<(String, [f32; 4])>, + /// Spot colour spaces defined in the setup (`VCsp`): swatch name, colorant name + /// ([`Scene::colorant_name`]) and CMYK equivalent. + spots: Vec<(String, String, [f32; 4])>, /// DeviceN color spaces defined in the setup (`VCdn`), as their definitions. device_n: Vec, warnings: Vec, @@ -191,7 +192,12 @@ impl<'a> Scene<'a> { } fn page(self) -> Page { - let custom = self.spots.iter().filter(|(n, _)| n != vectorcraft_color::swatch::REGISTRATION).cloned().collect(); + let custom = self + .spots + .iter() + .filter(|(n, ..)| n != vectorcraft_color::swatch::REGISTRATION) + .map(|(_, colorant, cmyk)| (colorant.clone(), *cmyk)) + .collect(); Page { setup: self.setup, body: self.body, custom, warnings: self.warnings } } @@ -235,7 +241,7 @@ impl<'a> Scene<'a> { /// The index of the Separation colour space of spot swatch (or Registration) `name`, defined /// in the setup the first time; `None` when `name` is neither. fn spot_space(&mut self, name: &str) -> Option { - if let Some(i) = self.spots.iter().position(|(n, _)| n == name) { + if let Some(i) = self.spots.iter().position(|(n, ..)| n == name) { return Some(i); } let registration = name == vectorcraft_color::swatch::REGISTRATION; @@ -249,17 +255,37 @@ impl<'a> Scene<'a> { }; let i = self.spots.len(); let _ = write!(self.setup, "/VCsp{i} [/Separation "); - if registration { + let colorant = if registration { self.setup.push_str("/All /DeviceCMYK {dup dup dup}"); + "All".to_string() } else { + let colorant = self.colorant_name(name); let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v))); - let _ = write!(self.setup, "{} cvn /DeviceCMYK {{dup {c} mul exch dup {m} mul exch dup {y} mul exch {k} mul}}", ps::string(name)); - } + let _ = write!(self.setup, "{} cvn /DeviceCMYK {{dup {c} mul exch dup {m} mul exch dup {y} mul exch {k} mul}}", ps::string(&colorant)); + colorant + }; self.setup.push_str("] def\n"); - self.spots.push((name.to_string(), cmyk)); + self.spots.push((name.to_string(), colorant, cmyk)); Some(i) } + /// The colorant name spot swatch `name` is written under: its own name, except that `All` + /// (every plate in PostScript) 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 + } + fn set_ink(&mut self, ink: Ink) { match ink { Ink::Process(space, v) => { @@ -819,26 +845,28 @@ impl<'a> Scene<'a> { } /// A DeviceN color space of process inks `process` (indices into CMYK) and spot inks `names`, - /// defined in the setup the first time, each spot ink with the CMYK equivalent its Separation - /// space has. `None` when a spot ink has no Separation space, two colorants share a name, a - /// spot ink has the name of a process ink, `All` or `None`, or there are more than - /// [`MAX_DEVICE_N`] colorants. + /// defined in the setup the first time, each spot ink under the colorant name and with the + /// CMYK equivalent its Separation space has. `None` when a spot ink has no Separation space, + /// two colorants share a name, a spot ink's colorant has the name of a process ink, `All` or + /// `None`, or there are more than [`MAX_DEVICE_N`] colorants. fn device_n_space(&mut self, process: &[usize], names: &[String]) -> Option { const PROCESS: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"]; if process.len() + names.len() > MAX_DEVICE_N { return None; } - let mut colorants: Vec<&str> = process.iter().map(|&i| PROCESS[i]).collect(); + let mut colorants: Vec = process.iter().map(|&i| PROCESS[i].to_string()).collect(); let mut weights: Vec<[f32; 4]> = process.iter().map(|&i| [0, 1, 2, 3].map(|j| if i == j { 1.0 } else { 0.0 })).collect(); - for (i, name) in names.iter().enumerate() { - if PROCESS.contains(&name.as_str()) || name == "All" || name == "None" || names[..i].contains(name) { + let spots: Vec = names.iter().map(|n| self.colorant_name(n)).collect(); + for (i, name) in spots.iter().enumerate() { + if PROCESS.contains(&name.as_str()) || name == "All" || name == "None" || spots[..i].contains(name) { return None; } } for name in names { - colorants.push(name); let i = self.spot_space(name)?; - weights.push(self.spots.get(i)?.1); + let (_, colorant, cmyk) = self.spots.get(i)?; + colorants.push(colorant.clone()); + weights.push(*cmyk); } let mut def = String::from("[/DeviceN ["); for (i, c) in colorants.iter().enumerate() { diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 5b89922d5..2067a7d9c 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -381,8 +381,50 @@ fn a_gradient_mixing_a_spot_ink_with_rgb_is_written_without_the_ink() { } #[test] -fn spot_inks_named_like_process_inks_all_or_none_are_not_device_n_colorants() { - for name in ["Cyan", "Black", "All", "None"] { +fn spot_inks_named_all_or_none_are_written_under_names_of_their_own() { + let rect = |d: &mut Document, fill: &str, stroke: &str| { + let solid = |name: &str| Paint::Solid { color: GOLD, swatch: Some(name.into()), tint: 1.0 }; + path(d, shapes::rectangle(Rect::new(50.0, 60.0, 150.0, 100.0)), solid(fill), solid(stroke), 2.0) + }; + for (name, colorant) in [("All", "All Spot"), ("None", "None Spot")] { + // A fill of the spot ink and a Registration stroke. + let mut d = doc_with(|_| vec![]); + d.swatches.push(Swatch { name: name.into(), paint: Paint::solid(GOLD), global: true, spot: true }); + let n = rect(&mut d, name, vectorcraft_color::swatch::REGISTRATION); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let ps = text(&export(&d, &opts(&d), None, None).unwrap()); + assert_eq!(dsc(&ps, "DocumentCustomColors"), Some(format!("({colorant})")), "{ps}"); + assert_eq!(dsc(&ps, "CMYKCustomColor"), Some(format!("0 0.2 0.8 0.1 ({colorant})")), "{ps}"); + assert!(ps.contains(&format!("[/Separation ({colorant}) cvn /DeviceCMYK {{dup 0 mul")), "{name}: {ps}"); + assert!(ps.contains("[/Separation /All /DeviceCMYK {dup dup dup}]"), "Registration prints on every plate: {ps}"); + assert!(!ps.contains(&format!("({name}) cvn")), "{name}: {ps}"); + checked_page(&ps); + + // In a gradient with another spot ink, the DeviceN space has the same colorant. + let mut d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, name, LEAF, 1.0)]); + d.swatches.push(Swatch { name: name.into(), paint: Paint::solid(LEAF), global: true, spot: true }); + let out = export(&d, &opts(&d), None, None).unwrap(); + let ps = text(&out); + assert!(ps.contains(&format!("/VCdn0 [/DeviceN [(Gold Ink) cvn ({colorant}) cvn ] /DeviceCMYK")), "{name}: {ps}"); + assert!(ps.contains(&format!("%%DocumentCustomColors: (Gold Ink)\n%%+ ({colorant})\n")), "{name}: {ps}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings); + } + + // A spot swatch already named All Spot keeps its name, and All is numbered past it. + let mut d = doc_with(|_| vec![]); + for name in ["All", "All Spot"] { + d.swatches.push(Swatch { name: name.into(), paint: Paint::solid(GOLD), global: true, spot: true }); + } + let n = rect(&mut d, "All", "All Spot"); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let ps = text(&export(&d, &opts(&d), None, None).unwrap()); + assert!(ps.contains("[/Separation (All Spot 2) cvn ") && ps.contains("[/Separation (All Spot) cvn "), "{ps}"); + assert!(ps.contains("%%DocumentCustomColors: (All Spot 2)\n%%+ (All Spot)\n"), "{ps}"); +} + +#[test] +fn spot_inks_named_like_process_inks_are_not_device_n_colorants() { + for name in ["Cyan", "Black"] { // Gold Ink to the spot ink, with no process ink in the stops. let mut d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, name, LEAF, 1.0)]); d.swatches.retain(|s| s.name != name); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 33633b4cc..cff2683f8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1082,7 +1082,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Open and Place dialogs list PostScript `.ps` files and `.epsf` files. A `.ps` file opens and places through the EPS reader (its first page), also when its header is only `%!`, as the `ps` format, which exports don't write (Print to PostScript does); an `.epsf` file opens and places as EPS. Copied in a file manager, these files paste as EPS, and `clipboard.importEps` also reads PostScript whose header is only `%!` (`crates/engine/src/cmd/fileio/tests_epsimport.rs`). `document.pdfInfo` reads an EPS, a `.ps` file or a `.ai` in PostScript form as one page the size of its bounding box (a US Letter page without one), and its `thumbnail: 1` is a PNG of what that page prints (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). - EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards; a document with no visible art gets only the artboard files (`crates/engine/src/cmd/fileio/tests_eps.rs`). - EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space 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. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow, Black, All or None, are also written in process colors with a warning (`crates/eps/src/tests.rs`). + EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space 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. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow or Black, are also written in process colors with a warning (`crates/eps/src/tests.rs`). 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), is written 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 and DSC comments; Registration still prints on every plate (`tests::spot_inks_named_all_or_none_are_written_under_names_of_their_own`). - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`). - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. The last valid `resc` or `resd` resolution value wins on each axis; an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`).