mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 14:41:52 +00:00
- 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.
492 lines
21 KiB
Rust
492 lines
21 KiB
Rust
//! 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;
|