Files
vectorcraft/crates/eps/src/dcs.rs
T
dotCooCoo 99f896f9b8 Place: DCS links follow their plate files, clips and document swatches
- The link watch and links.updateChanged look at a DCS file's plate files
  of their own, so a changed plate file is read again.
- links.update and links.relink apply the file's clipping path to the
  images in symbols, graph designs, patterns and masks too. When the new
  file has no clipping path, a clip group holding the old file's clipping
  path loses that path and stays as a plain group of the image, keeping
  its id, name, visibility and transparency. The image keeps that path with its plates to tell it from a
  clipping mask made in the document; a clipping path given another fill
  rule in the document stays. An even-odd clipping path in the file is
  kept as the same area under the nonzero rule.
- links.update and links.relink return the swatch warnings file.place
  gives, including two files that add one ink in two colors.
- links.relink with copy fails for a DCS file with plate files of its own.
- file.place answers the image id of a clipped DCS placement as image,
  and checks page and crop for a DCS file as it does for an EPS file.
- Print separations and Separations Preview take a spot plate's color
  from the document's swatch of its ink, and a reduced image's plates
  keep their coverage.
- Show DCS Transparency Interactions counts a transparent DCS image and
  characters filled or stroked with a transparent gradient.
- %%PlateFile names keep their spaces, and header bytes that are not
  UTF-8 are read as Latin-1.
- A plate program that draws more than one image, art besides its image,
  or its image through a clipping path is an error naming the plate
  instead of printing the wrong ink.
- Nested clipping paths around the composite clip the image together.
- A kept image with more plates than are read, or more TIFF directories
  than its composite and plates, is the CMYK image of its composite
  only, not a set of plates cut short.
- swatch.unused counts a spot or process swatch as used only while an
  image the art shows has a spot plate of its name.
2026-11-12 18:43:04 -08:00

492 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! DCS files (Desktop Color Separation): an EPS file whose header comments point at one EPS
//! program per printing plate. A DCS 2.0 file names each plate with `%%PlateFile: (name) EPS
//! #offset size`, the plate inside the file `offset` bytes from its start, or `%%PlateFile: (name)
//! EPS Local file`, the plate in a file of its own; a DCS 1.0 file names the four process plates'
//! files with `%%CyanPlate:`, `%%MagentaPlate:`, `%%YellowPlate:` and `%%BlackPlate:`. Spot inks
//! take their process equivalents from `%%CMYKCustomColor:` (else `%%RGBCustomColor:`). The comments
//! are described at http://www.telegraphics.net/doc/DCS.txt.
//!
//! [`read`] finds those comments; [`place`] reads every plate's image (its ink amounts: a dark
//! plate pixel is ink) into one [`Separated`] image, with the clipping path the main file's
//! composite is drawn through.
use vectorcraft_color::cms::PROCESS_PLATES;
use vectorcraft_doc::separated::{InkPlate, MAX_NAME, MAX_PLATES, MAX_SAMPLES, Separated};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::{Affine, Rect};
/// Where a plate's program is.
#[derive(Clone, Debug, PartialEq)]
pub enum PlateAt {
/// In the DCS file itself: `len` bytes from byte `offset` of the file.
Here { offset: usize, len: usize },
/// In a file of its own, as the DCS file names it (a path or a file name).
File(String),
}
/// One plate a DCS file names.
#[derive(Clone, Debug, PartialEq)]
pub struct PlateRef {
/// "Cyan", "Magenta", "Yellow", "Black" or a spot ink's name.
pub name: String,
pub at: PlateAt,
}
/// The DCS comments of a file.
#[derive(Clone, Debug, PartialEq)]
pub struct Dcs {
/// 1 or 2.
pub version: u8,
pub plates: Vec<PlateRef>,
/// Spot inks' process equivalents (C, M, Y, K, 0..1), by name.
pub equivalents: Vec<(String, [f32; 4])>,
}
impl Dcs {
/// The names of the plate files kept apart from the DCS file.
pub fn plate_files(&self) -> impl Iterator<Item = &str> {
self.plates.iter().filter_map(|p| match &p.at {
PlateAt::File(f) => Some(f.as_str()),
PlateAt::Here { .. } => None,
})
}
}
/// The DCS comments of EPS file `bytes` (behind its binary header, if it has one); `None` when it
/// names no plate.
pub fn read(bytes: &[u8]) -> Option<Dcs> {
let (ps, _) = crate::sections(bytes)?;
if !ps.starts_with(b"%!") {
return None;
}
// The plates of the DCS 2.0 comments and of the DCS 1.0 ones.
let mut plates: Vec<PlateRef> = vec![];
let mut old: Vec<PlateRef> = vec![];
let mut equivalents: Vec<(String, [f32; 4])> = vec![];
let mut rgb: Vec<(String, [f32; 4])> = vec![];
// The comment a `%%+` line continues.
let mut last = String::new();
for raw in ps.split(|b| matches!(b, b'\n' | b'\r')).filter(|l| !l.is_empty()).take(MAX_HEADER_LINES) {
let line = header_line(raw);
let line = line.trim_end();
if line.starts_with("%%EndComments") || !line.starts_with('%') {
break;
}
let (key, value) = match line.strip_prefix("%%+") {
Some(v) => (last.clone(), v.trim()),
None => match line.split_once(':') {
Some((k, v)) => (k.to_string(), v.trim()),
None => (line.to_string(), ""),
},
};
match key.as_str() {
"%%PlateFile" => {
if let Some(p) = plate_file(value)
&& !plates.iter().any(|q| q.name == p.name)
{
plates.push(p);
}
}
"%%CyanPlate" | "%%MagentaPlate" | "%%YellowPlate" | "%%BlackPlate" => {
let name = key.trim_start_matches("%%").trim_end_matches("Plate").to_string();
if !value.is_empty() && !old.iter().any(|q| q.name == name) {
old.push(PlateRef { name, at: PlateAt::File(value.to_string()) });
}
}
"%%CMYKCustomColor" => equivalents.extend(custom_colors(value, false)),
"%%RGBCustomColor" => rgb.extend(custom_colors(value, true)),
_ => {}
}
last = key;
}
// A file is one version or the other; the plate comments of DCS 2.0 win.
let version = if plates.is_empty() {
plates = old;
1
} else {
2
};
if plates.is_empty() {
return None;
}
for (name, c) in rgb {
if !equivalents.iter().any(|(n, _)| *n == name) {
equivalents.push((name, c));
}
}
Some(Dcs { version, plates, equivalents })
}
/// Most header lines looked at for the DCS comments.
const MAX_HEADER_LINES: usize = 10_000;
/// Header line `raw` as text: UTF-8 when it is, else Latin-1 (each byte one character), as the
/// octal escapes of names are read.
fn header_line(raw: &[u8]) -> std::borrow::Cow<'_, str> {
match std::str::from_utf8(raw) {
Ok(s) => std::borrow::Cow::Borrowed(s),
Err(_) => std::borrow::Cow::Owned(raw.iter().copied().map(char::from).collect()),
}
}
/// `s` without its first word and the whitespace around that word.
fn after_word(s: &str) -> &str {
s.trim_start().split_once(char::is_whitespace).map_or("", |(_, rest)| rest.trim_start())
}
/// A `%%PlateFile:` comment's plate: `(name) EPS #offset size` or `(name) EPS Local file`; `None`
/// for other locations, which only a print server reads.
fn plate_file(v: &str) -> Option<PlateRef> {
let (name, rest) = ps_string(v)?;
let mut words = rest.split_whitespace();
let _kind = words.next()?;
let location = words.next()?;
let name = canonical(name)?;
if let Some(offset) = location.strip_prefix('#') {
let len = words.next()?.parse().ok()?;
return Some(PlateRef { name, at: PlateAt::Here { offset: offset.parse().ok()?, len } });
}
if !location.eq_ignore_ascii_case("Local") {
return None;
}
// The file name as it is written, its spaces kept.
let file = after_word(after_word(rest));
(!file.is_empty()).then(|| PlateRef { name, at: PlateAt::File(file.to_string()) })
}
/// A plate's name as it is kept: a process ink's in its usual case ("cyan" is "Cyan"); `None` when
/// it is empty or longer than [`MAX_NAME`].
fn canonical(name: String) -> Option<String> {
if name.is_empty() || name.len() > MAX_NAME {
return None;
}
Some(PROCESS_PLATES.iter().find(|p| p.eq_ignore_ascii_case(&name)).map_or(name, |p| p.to_string()))
}
/// The custom colours of a `%%CMYKCustomColor:` (four numbers then a name, repeated) or, with
/// `rgb`, a `%%RGBCustomColor:` comment (three numbers), the RGB ones as the process inks that
/// take their light away (`1 − r`, `1 − g`, `1 − b`, no black). Names may be bare words.
fn custom_colors(v: &str, rgb: bool) -> Vec<(String, [f32; 4])> {
let mut out = vec![];
let mut rest = v.trim();
let n = if rgb { 3 } else { 4 };
while !rest.is_empty() && out.len() < MAX_PLATES {
let mut nums = [0.0f32; 4];
for v in nums.iter_mut().take(n) {
let Some((word, after)) = rest.split_once(char::is_whitespace) else { return out };
let Some(x) = word.parse::<f32>().ok().filter(|x| (0.0..=1.0).contains(x)) else { return out };
*v = x;
rest = after.trim_start();
}
let (name, after) = match ps_string(rest) {
Some((s, a)) => (s, a),
None => {
let (w, a) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
(w.to_string(), a)
}
};
rest = after.trim_start();
let cmyk = if rgb { [1.0 - nums[0], 1.0 - nums[1], 1.0 - nums[2], 0.0] } else { nums };
if let Some(name) = canonical(name) {
out.push((name, cmyk));
}
}
out
}
/// The PostScript string at the start of `v` (`(…)`, with nested parentheses and backslash
/// escapes) and what follows it.
fn ps_string(v: &str) -> Option<(String, &str)> {
let body = v.trim_start().strip_prefix('(')?;
let mut out = String::new();
let mut depth = 0usize;
let mut chars = body.char_indices().peekable();
while let Some((i, c)) = chars.next() {
match c {
'\\' => match chars.next()?.1 {
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
d @ '0'..='7' => {
let mut code = d.to_digit(8)?;
for _ in 0..2 {
match chars.peek().and_then(|(_, e)| e.to_digit(8)) {
Some(e) => {
chars.next();
code = code * 8 + e;
}
None => break,
}
}
// Octal escapes name bytes; names are read as Latin-1.
out.push(char::from_u32(code & 0xff)?);
}
e => out.push(e),
},
'(' => {
depth += 1;
out.push(c);
}
')' if depth == 0 => return Some((out, body.get(i + 1..)?)),
')' => {
depth -= 1;
out.push(c);
}
_ => out.push(c),
}
}
None
}
/// The main part of DCS file `bytes`: its PostScript (behind its binary header, if it has one), up
/// to the first plate it holds.
pub fn main_program<'a>(bytes: &'a [u8], dcs: &Dcs) -> Option<&'a [u8]> {
let (ps, _) = crate::sections(bytes)?;
let start = ps.as_ptr() as usize - bytes.as_ptr() as usize;
let first = dcs.plates.iter().filter_map(|p| match p.at {
PlateAt::Here { offset, .. } if offset > start => Some(offset - start),
_ => None,
});
Some(&ps[..first.min().unwrap_or(ps.len()).min(ps.len())])
}
/// A DCS file read for placing.
#[derive(Debug)]
pub struct Placed {
pub separated: Separated,
/// The file's page (its bounding box) with its top-left corner at the origin.
pub page: Rect,
/// The image's pixels onto the page.
pub xf: Affine,
/// The clipping path the composite is drawn through, on the page.
pub clip: Option<Node>,
/// The resolution of the plates (pixels per inch), from how large they print.
pub ppi: Option<(f64, f64)>,
pub warnings: Vec<String>,
}
/// One plate's image: its ink amounts, one byte a pixel, and where its pixels print on its page.
struct PlateImage {
width: u32,
height: u32,
data: Vec<u8>,
xf: Affine,
}
/// Read DCS file `bytes` (its comments `dcs`) for placing: every plate's image, the plates kept
/// apart read with `file` (the name the file gives → the plate file's bytes). The plates are put on
/// the pixel grid of the first plate's image, grown to hold every plate's image where it prints;
/// another plate is resampled onto it. An error when the file names more than [`MAX_PLATES`]
/// plates or a plate can't be read.
pub fn place(bytes: &[u8], dcs: &Dcs, file: &mut dyn FnMut(&str) -> Result<Vec<u8>, String>) -> Result<Placed, String> {
if dcs.plates.len() > MAX_PLATES {
return Err(format!("it names {} plates, and at most {MAX_PLATES} are read", dcs.plates.len()));
}
let main = main_program(bytes, dcs).ok_or("the file's preview header points outside the file")?;
let page = crate::page(main)?;
let mut warnings = vec![];
let (mut images, mut samples) = (vec![], 0u64);
for p in &dcs.plates {
let read = match &p.at {
PlateAt::Here { offset, len } => {
let eps = offset.checked_add(*len).and_then(|end| bytes.get(*offset..end));
let eps = eps.ok_or_else(|| format!("the {} plate lies outside the file", p.name))?;
plate_image(eps)
}
PlateAt::File(f) => file(f).and_then(|eps| plate_image(&eps)),
};
let img = read.map_err(|e| format!("the {} plate can't be read: {e}", p.name))?;
// Checked before the other plates are read.
samples += u64::from(img.width) * u64::from(img.height);
if samples > MAX_SAMPLES {
return Err(format!("its plates are too large: more than {MAX_SAMPLES} pixels together"));
}
images.push((p, img));
}
let Some((w, h, xf)) = grid(images.iter().map(|(_, img)| img)) else { return Err("the file names no plate".into()) };
if !Separated::fits(w, h, images.len()) {
return Err(format!("its plates are too large: {} plates of {w} × {h} pixels", images.len()));
}
let mut plates = vec![];
for (p, img) in images {
let name = p.name.clone();
let aligned = img.xf.as_coeffs().iter().zip(xf.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-9);
let data = if (img.width, img.height) == (w, h) && aligned { img.data } else { resample(&img, w, h, xf) };
let cmyk = match dcs.equivalents.iter().find(|(n, _)| *n == name) {
Some((_, c)) => *c,
None if PROCESS_PLATES.contains(&name.as_str()) => [0.0; 4],
None => {
warnings.push(format!("the file gives no process equivalent for the {name} ink: it shows as black"));
[0.0, 0.0, 0.0, 1.0]
}
};
plates.push(InkPlate { name, cmyk, data });
}
let separated = Separated::new(w, h, plates).ok_or("its plates can't be kept together")?;
let clip = clip_path(main);
// Points one pixel spans along each axis.
let [a, b, c, d, ..] = xf.as_coeffs();
let ppi = [a.hypot(b), c.hypot(d)].map(|l| (l > 1e-12).then(|| 72.0 / l));
let ppi = match ppi {
[Some(x), Some(y)] => Some((x, y)),
_ => None,
};
Ok(Placed { separated, page, xf, clip, ppi, warnings })
}
/// The pixel grid the plates `images` go on: the first image's pixels, grown by whole pixels to
/// hold where every image prints, as its width, height and where its pixels print; `None` without
/// an image. It is the first image's own when its transform can't be inverted.
fn grid<'a>(mut images: impl Iterator<Item = &'a PlateImage>) -> Option<(u32, u32, Affine)> {
let first = images.next()?;
let own = Some((first.width, first.height, first.xf));
if first.xf.determinant().abs() <= 1e-12 {
return own;
}
let to_first = first.xf.inverse();
let mut r = Rect::new(0.0, 0.0, f64::from(first.width), f64::from(first.height));
for img in images {
r = r.union((to_first * img.xf).transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(img.width), f64::from(img.height))));
}
// Edges within a millionth of a pixel of a whole one are on it.
let snap = |v: f64, up: bool| {
let n = v.round();
if (v - n).abs() < 1e-6 {
n
} else if up {
v.ceil()
} else {
v.floor()
}
};
let (x0, y0, x1, y1) = (snap(r.x0, false), snap(r.y0, false), snap(r.x1, true), snap(r.y1, true));
let size = |a: f64, b: f64| Some(b - a).filter(|n| n.is_finite() && (1.0..=f64::from(u32::MAX)).contains(n)).map(|n| n as u32);
match (size(x0, x1), size(y0, y1)) {
(Some(w), Some(h)) => Some((w, h, first.xf * Affine::translate((x0, y0)))),
// Larger than any separated image may be ([`Separated::fits`]).
_ => Some((u32::MAX, u32::MAX, first.xf)),
}
}
/// The ink amounts of `img` on the `w` × `h` pixels that `onto` puts on the page (each the
/// nearest pixel of `img` where it prints there, none outside it). When either transform can't be
/// inverted, the pixels are stretched over one another instead.
fn resample(img: &PlateImage, w: u32, h: u32, onto: Affine) -> Vec<u8> {
let (sw, sh) = (img.width as usize, img.height as usize);
let mut out = Vec::with_capacity(w as usize * h as usize);
let invertible = |m: Affine| m.determinant().abs() > 1e-12;
// From a pixel of the image made to a pixel of `img`.
let to_plate = (invertible(img.xf) && invertible(onto)).then(|| img.xf.inverse() * onto);
for y in 0..h as usize {
for x in 0..w as usize {
let at = match to_plate {
Some(m) => {
let p = m * vectorcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5);
let inside = p.x >= 0.0 && p.y >= 0.0 && p.x < sw as f64 && p.y < sh as f64;
inside.then(|| (p.y as usize).min(sh - 1) * sw + (p.x as usize).min(sw - 1))
}
None => Some((y * sh / h as usize).min(sh - 1) * sw + (x * sw / w as usize).min(sw - 1)),
};
out.push(at.and_then(|i| img.data.get(i).copied()).unwrap_or(0));
}
}
out
}
/// Is an image under `n` (itself included) drawn through a clipping path: inside a clipping group
/// or layer, or `inside` one already?
fn clips_an_image(n: &Node, inside: bool) -> bool {
let inside = inside || matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
match &n.kind {
NodeKind::Image(_) => inside,
_ => n.children().is_some_and(|ch| ch.iter().any(|c| clips_an_image(c, inside))),
}
}
/// The image plate program `eps` draws, as ink amounts (how dark each pixel is: 255 less its
/// luma), and where it prints on its page; an error when it draws more than one image, or art
/// besides it.
fn plate_image(eps: &[u8]) -> Result<PlateImage, String> {
let r = crate::import_with(eps, false)?;
if r.preview {
return Err(r.warnings.first().cloned().unwrap_or_else(|| "its PostScript can't be read".into()));
}
// A plate is one image: one that draws more (several images, or art) would lose ink.
let (mut found, mut images, mut art) = (None, 0usize, false);
r.document.walk(|n| match &n.kind {
NodeKind::Image(im) => {
images += 1;
found.get_or_insert((im.key.clone(), im.xf, im.width, im.height));
}
NodeKind::Path { clipping: true, .. } | NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::RulerGuide { .. } => {}
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
art |= n.visible && n.appearance.items.iter().any(|i| i.visible() && !matches!(i.paint(), vectorcraft_color::Paint::None))
}
_ => art |= n.visible,
});
if images > 1 || (art && images == 1) {
return Err(if images > 1 { "it draws more than one image".into() } else { "it draws art besides its image".into() });
}
// Ink outside a clipping path would print: a plate whose image is clipped is refused.
if images == 1 && r.document.layers.iter().any(|l| clips_an_image(l, false)) {
return Err("it draws its image through a clipping path".into());
}
let (key, xf, iw, ih) = found.ok_or("it draws no image")?;
let blob = r.document.images.get(&key).ok_or("it draws no image")?;
let (width, height, data) = blob.coverage().ok_or("its image is too large to read")?;
// The object's pixel space may differ from the pixels it keeps.
let xf = xf * Affine::scale_non_uniform(f64::from(iw) / f64::from(width.max(1)), f64::from(ih) / f64::from(height.max(1)));
Ok(PlateImage { width, height, data, xf })
}
/// The clipping path the first image of `main` is drawn through, when it is drawn through one.
fn clip_path(main: &[u8]) -> Option<Node> {
let r = crate::import_with(main, false).ok().filter(|r| !r.preview)?;
first_clip(&r.document)
}
/// The clipping path the first image of `d` is drawn through: the clipping path of the innermost
/// clipping group that holds an image, cut to the clipping paths of the clipping groups around it.
fn first_clip(d: &Document) -> Option<Node> {
/// The clipping paths around the innermost clipping group under `n` that holds an image,
/// outermost first.
fn find(n: &Node) -> Option<Vec<Node>> {
let NodeKind::Group { children, clip } = &n.kind else {
return n.children().and_then(|ch| ch.iter().find_map(|c| find(c)));
};
let first = children.first().filter(|f| *clip && matches!(f.kind, NodeKind::Path { clipping: true, .. }));
if let Some(mut inner) = children.iter().find_map(|c| find(c)) {
if let Some(f) = first {
inner.insert(0, (**f).clone());
}
return Some(inner);
}
let holds_image = || {
let mut any = false;
n.walk(&mut |c: &Node| any |= matches!(c.kind, NodeKind::Image(_)));
any
};
first.filter(|_| holds_image()).map(|f| vec![(**f).clone()])
}
let mut clips = d.layers.iter().find_map(|l| find(l))?;
let mut innermost = clips.pop()?;
// The clipping paths together clip to where they overlap.
for outer in clips.iter().rev() {
if let (NodeKind::Path { path: o, rule: or, .. }, NodeKind::Path { path, rule, .. }) = (&outer.kind, &mut innermost.kind) {
*path = vectorcraft_pathops::boolean(path, *rule, o, *or, vectorcraft_pathops::BoolOp::Intersect);
*rule = vectorcraft_geom::FillRule::NonZero;
}
}
// An even-odd clipping path is kept as the same area under the nonzero rule, the rule of the
// clip groups the Links commands make and match again.
if let NodeKind::Path { path, rule: rule @ vectorcraft_geom::FillRule::EvenOdd, .. } = &mut innermost.kind
&& let Ok(nonzero) = vectorcraft_pathops::try_boolean_n(&[(&*path, vectorcraft_geom::FillRule::EvenOdd)], |inside| !inside.is_empty())
{
(*path, *rule) = (nonzero, vectorcraft_geom::FillRule::NonZero);
}
Some(innermost)
}
#[cfg(test)]
mod tests;