Files
vectorcraft/crates/doc/src/separated.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

534 lines
25 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.
//! Separated images: an image whose inks come as one plate each (a DCS file placed linked), kept
//! as a TIFF of several images. The first is the composite, the plates mixed into CMYK
//! ([`Separated::composite`]), which every reader of a CMYK TIFF sees. Each later image is one
//! plate's ink amounts in grayscale (0 is no ink), with its ink's name and CMYK equivalent in its
//! ImageDescription. Print separations take each plate from these ([`Separated::coverage`]).
use std::io::Cursor;
use std::sync::Arc;
use vectorcraft_color::cms::PROCESS_PLATES;
use vectorcraft_geom::PathData;
use crate::ImageBlob;
use crate::cmyk::{Inks, MAX_CMYK_PIXELS};
/// Most plates a separated image holds.
pub const MAX_PLATES: usize = 64;
/// Most directories a separated image's TIFF holds: its composite and its plates.
const MAX_DIRECTORIES: usize = MAX_PLATES + 1;
/// Longest ink name kept (bytes).
pub const MAX_NAME: usize = 255;
/// Most ink amounts the plates of one image hold together (512 MB).
pub const MAX_SAMPLES: u64 = 1 << 29;
/// How a plate's ImageDescription starts.
const MARK: &str = "VectorCraft plate ";
/// How the composite's ImageDescription starts when plates were read from files of their own.
const FILES_MARK: &str = "VectorCraft plate files";
/// A plate file of its own that a separated image was read from, as it was then: a change to it
/// changes the image as a change to the DCS file does.
#[derive(Clone, Debug, PartialEq)]
pub struct PlateFile {
/// Its file name, in the DCS file's folder.
pub name: String,
/// Its size (bytes).
pub size: u64,
/// Its modification time (ms since the Unix epoch; 0 when the file system keeps none).
pub modified: u64,
}
/// `text` in hexadecimal (TIFF text is ASCII).
fn hex(text: &str) -> String {
text.bytes().map(|b| format!("{b:02x}")).collect()
}
/// The text [`hex`] wrote, of at most `max` bytes.
fn unhex(hex: &str, max: usize) -> Option<String> {
if !hex.len().is_multiple_of(2) || hex.len() > max * 2 {
return None;
}
let bytes: Option<Vec<u8>> = (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()).collect();
String::from_utf8(bytes?).ok()
}
/// One ink's plate: its ink amounts, one byte a pixel (0 is none, 255 is solid), row by row from
/// the top.
#[derive(Clone, Debug, PartialEq)]
pub struct InkPlate {
/// "Cyan", "Magenta", "Yellow", "Black", or a spot ink's name.
pub name: String,
/// The process inks the ink stands for (C, M, Y, K, 0..1): one process ink at 100% for a
/// process plate.
pub cmyk: [f32; 4],
pub data: Vec<u8>,
}
impl InkPlate {
/// Is it a spot ink's plate (not one of the four process inks)?
pub fn spot(&self) -> bool {
!PROCESS_PLATES.contains(&self.name.as_str())
}
}
/// The plates of a separated image, all of one size.
#[derive(Clone, Debug, PartialEq)]
pub struct Separated {
pub width: u32,
pub height: u32,
pub plates: Vec<InkPlate>,
}
impl Separated {
/// `plates` as a `width` × `height` image; `None` when there are none or more than
/// [`MAX_PLATES`], two have the same name, a name is empty or longer than [`MAX_NAME`], a plate
/// doesn't hold that many pixels, an equivalent isn't within 0..1, or the image is empty, has
/// more than [`MAX_CMYK_PIXELS`], or its plates together hold more than [`MAX_SAMPLES`]
/// ([`Self::fits`]). A process plate's equivalent is its own ink.
pub fn new(width: u32, height: u32, mut plates: Vec<InkPlate>) -> Option<Self> {
let n = u64::from(width) * u64::from(height);
if !Self::fits(width, height, plates.len()) {
return None;
}
for p in plates.iter_mut() {
if p.name.is_empty() || p.name.len() > MAX_NAME || p.data.len() as u64 != n {
return None;
}
if let Some(j) = PROCESS_PLATES.iter().position(|q| *q == p.name) {
p.cmyk = [0.0; 4];
p.cmyk[j] = 1.0;
}
if !p.cmyk.iter().all(|v| (0.0..=1.0).contains(v)) {
return None;
}
}
let mut names = std::collections::HashSet::new();
if !plates.iter().all(|p| names.insert(p.name.as_str())) {
return None;
}
Some(Self { width, height, plates })
}
/// May an image of `width` × `height` pixels hold `plates` plates? Not when it is empty, has
/// more than [`MAX_CMYK_PIXELS`], has no plate or more than [`MAX_PLATES`], or the plates
/// together would hold more than [`MAX_SAMPLES`].
pub fn fits(width: u32, height: u32, plates: usize) -> bool {
let n = u64::from(width) * u64::from(height);
n > 0 && n <= MAX_CMYK_PIXELS && (1..=MAX_PLATES).contains(&plates) && n * plates as u64 <= MAX_SAMPLES
}
/// The plates of spot inks.
pub fn spots(&self) -> impl Iterator<Item = &InkPlate> {
self.plates.iter().filter(|p| p.spot())
}
/// The plates mixed into process inks: each plate's ink amount times its equivalent, the
/// plates over one another as inks print (`1 − (1 − a)(1 − b)` for each process ink).
pub fn composite(&self) -> Inks {
let n = self.data_len();
let mut data = vec![0u8; n * 4];
for (i, px) in data.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let mut clear = [1.0f32; 4];
for p in &self.plates {
let t = f32::from(p.data[i]) / 255.0;
for (c, e) in clear.iter_mut().zip(p.cmyk) {
*c *= 1.0 - t * e;
}
}
for (o, c) in px.iter_mut().zip(clear) {
*o = ((1.0 - c) * 255.0).round() as u8;
}
}
Inks { width: self.width, height: self.height, data }
}
/// The ink amounts that print on plate `plate` (a process ink's name or a spot ink's), one byte
/// a pixel. A spot plate for which `own` is true prints on its own plate only; every other spot
/// plate (the job prints no plate of that name) prints as its process equivalent, mixed as
/// [`Self::composite`] mixes it. A plate name the image has no plate for, and a spot plate that
/// prints as process, print nothing.
pub fn coverage(&self, plate: &str, own: &dyn Fn(&str) -> bool) -> Vec<u8> {
self.coverage_as(plate, own, &|p| p.cmyk)
}
/// [`Self::coverage`], a spot plate that prints as process printing as the process inks
/// `equivalent` gives it (the document's swatch of its ink, when there is one).
pub fn coverage_as(&self, plate: &str, own: &dyn Fn(&str) -> bool, equivalent: &dyn Fn(&InkPlate) -> [f32; 4]) -> Vec<u8> {
let n = self.data_len();
if let Some(j) = PROCESS_PLATES.iter().position(|q| *q == plate) {
let mixed: Vec<(&InkPlate, f32)> = self
.plates
.iter()
.filter(|p| !p.spot() || !own(&p.name))
.map(|p| (p, if p.spot() { equivalent(p)[j].clamp(0.0, 1.0) } else { p.cmyk[j] }))
.filter(|(_, e)| *e > 0.0)
.collect();
return (0..n)
.map(|i| {
let clear = mixed.iter().fold(1.0f32, |c, (p, e)| c * (1.0 - f32::from(p.data[i]) / 255.0 * e));
((1.0 - clear) * 255.0).round() as u8
})
.collect();
}
match self.plates.iter().find(|p| p.name == plate && p.spot() && own(&p.name)) {
Some(p) => p.data.clone(),
None => vec![0; n],
}
}
/// [`Self::coverage_as`] as a grayscale PNG, black for solid ink and white for none, as a plate
/// prints; `None` when it can't be written.
pub fn plate_png(&self, plate: &str, own: &dyn Fn(&str) -> bool, equivalent: &dyn Fn(&InkPlate) -> [f32; 4]) -> Option<ImageBlob> {
let gray: Vec<u8> = self.coverage_as(plate, own, equivalent).into_iter().map(|v| 255 - v).collect();
let img = image::GrayImage::from_raw(self.width, self.height, gray)?;
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(ImageBlob::png(png))
}
fn data_len(&self) -> usize {
self.width as usize * self.height as usize
}
}
/// A plate's ImageDescription: the mark, its equivalent and its name in hexadecimal (TIFF text is
/// ASCII).
fn description(p: &InkPlate) -> String {
let [c, m, y, k] = p.cmyk;
format!("{MARK}{c} {m} {y} {k} {}", hex(&p.name))
}
/// A plate's name and equivalent from its ImageDescription; `None` when it isn't one.
fn parse_description(s: &str) -> Option<(String, [f32; 4])> {
let rest = s.strip_prefix(MARK)?;
let mut words = rest.split(' ');
let mut cmyk = [0.0f32; 4];
for v in &mut cmyk {
*v = words.next()?.parse().ok().filter(|v: &f32| v.is_finite())?;
}
let name = unhex(words.next()?, MAX_NAME)?;
words.next().is_none().then_some((name, cmyk))
}
/// Longest plate file name kept (bytes).
const MAX_FILE_NAME: usize = 4096;
/// The composite's ImageDescription line for `files`: the mark, then each file's name in
/// hexadecimal, its size and its modification time.
fn files_description(files: &[PlateFile]) -> String {
let mut out = String::from(FILES_MARK);
for f in files.iter().take(MAX_PLATES) {
out.push_str(&format!(" {} {} {}", hex(&f.name), f.size, f.modified));
}
out
}
/// How the composite's ImageDescription line holding the clipping path starts.
const CLIP_MARK: &str = "VectorCraft clip ";
/// Longest clipping path kept (bytes of its JSON).
const MAX_CLIP: usize = 16 << 20;
/// The composite's ImageDescription line for clipping path `clip`: the mark, then the path's JSON
/// in hexadecimal; `None` when it is longer than [`MAX_CLIP`].
fn clip_description(clip: &PathData) -> Option<String> {
let json = serde_json::to_string(clip).ok().filter(|j| j.len() <= MAX_CLIP)?;
Some(format!("{CLIP_MARK}{}", hex(&json)))
}
/// The plate files of a composite's ImageDescription; none when it isn't one.
fn parse_files(s: &str) -> Vec<PlateFile> {
let Some(rest) = s.lines().find_map(|l| l.strip_prefix(FILES_MARK)) else { return vec![] };
let words: Vec<&str> = rest.split(' ').filter(|w| !w.is_empty()).collect();
let file =
|w: &[&str]| Some(PlateFile { name: unhex(w.first()?, MAX_FILE_NAME)?, size: w.get(1)?.parse().ok()?, modified: w.get(2)?.parse().ok()? });
words.chunks(3).take(MAX_PLATES).filter_map(file).collect()
}
impl ImageBlob {
/// A TIFF of separated image `s`: its composite in CMYK, then its plates (Deflate-compressed),
/// declaring the resolution `ppi` when it is given and both are finite and above 0, and noting
/// `files`, the plate files of their own the plates were read from ([`Self::plate_files`]);
/// `None` when it can't be written.
pub fn separated_tiff(s: &Separated, ppi: Option<(f64, f64)>, files: &[PlateFile]) -> Option<Self> {
Self::separated_tiff_clipped(s, ppi, files, None)
}
/// [`Self::separated_tiff`], noting `clip` too, the clipping path its file draws it through, in
/// its pixel space ([`Self::separated_clip`]).
pub fn separated_tiff_clipped(s: &Separated, ppi: Option<(f64, f64)>, files: &[PlateFile], clip: Option<&PathData>) -> Option<Self> {
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype, compression::DeflateLevel};
use tiff::tags::{ResolutionUnit, Tag};
let ppi = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0));
// Ten-thousandths of a pixel per inch.
let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 };
let mut out = Cursor::new(Vec::new());
let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced));
let mut img = enc.new_image::<colortype::CMYK8>(s.width, s.height).ok()?;
if let Some((x, y)) = ppi {
img.encoder().write_tag(Tag::XResolution, r(x)).ok()?;
img.encoder().write_tag(Tag::YResolution, r(y)).ok()?;
img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?;
}
// One line for the plate files, one for the clipping path.
let lines: Vec<String> =
[(!files.is_empty()).then(|| files_description(files)), clip.and_then(clip_description)].into_iter().flatten().collect();
if !lines.is_empty() {
img.encoder().write_tag(Tag::ImageDescription, lines.join("\n").as_str()).ok()?;
}
img.write_data(&s.composite().data).ok()?;
for p in &s.plates {
// White is no ink, so a viewer that shows a plate shows it as it prints.
let mut img = enc.new_image::<colortype::Gray8>(s.width, s.height).ok()?;
img.encoder().write_tag(Tag::ImageDescription, description(p).as_str()).ok()?;
let inverted: Vec<u8> = p.data.iter().map(|v| 255 - v).collect();
img.write_data(&inverted).ok()?;
}
Some(Self::new("image/tiff", out.into_inner()))
}
/// Does the image hold plates ([`ImageBlob::separated_tiff`]), at most [`MAX_PLATES`] of them?
/// One with more, or with more directories than its composite and [`MAX_PLATES`] plates, is the
/// CMYK image of its composite only, as [`Self::separated`] refuses it. Reads only the TIFF's
/// directories, at most [`MAX_DIRECTORIES`].
pub fn is_separated(&self) -> bool {
let Some((_, mut d)) = crate::cmyk::tiff(&self.bytes) else { return false };
let (mut plates, mut directories) = (0usize, 1usize);
while d.more_images() {
directories += 1;
if directories > MAX_DIRECTORIES || d.next_image().is_err() {
return false;
}
if d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().is_some_and(|s| s.starts_with(MARK)) {
plates += 1;
if plates > MAX_PLATES {
return false;
}
}
}
plates > 0
}
/// The plate files of their own a separated image was read from, as they were then; none for
/// other images. Reads only the TIFF's first directory.
pub fn plate_files(&self) -> Vec<PlateFile> {
let Some((_, mut d)) = crate::cmyk::tiff(&self.bytes) else { return vec![] };
d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().as_deref().map(parse_files).unwrap_or_default()
}
/// The clipping path a separated image's file draws it through, in its pixel space, as
/// [`Self::separated_tiff_clipped`] noted it; `None` for other images. Reads only the TIFF's
/// first directory.
pub fn separated_clip(&self) -> Option<PathData> {
let (_, mut d) = crate::cmyk::tiff(&self.bytes)?;
let text = d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok()?;
let json = unhex(text.lines().find_map(|l| l.strip_prefix(CLIP_MARK))?, MAX_CLIP)?;
serde_json::from_str(&json).ok()
}
/// The spot inks a separated image has plates for, each with its CMYK equivalent; none for
/// other images. Reads only the TIFF's directories.
pub fn spot_plates(&self) -> Vec<(String, [f32; 4])> {
if !self.is_separated() {
return vec![];
}
let Some((_, mut d)) = crate::cmyk::tiff(&self.bytes) else { return vec![] };
let mut inks = vec![];
while d.more_images() && inks.len() < MAX_PLATES {
if d.next_image().is_err() {
break;
}
let plate = d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().as_deref().and_then(parse_description);
inks.extend(plate.filter(|(name, _)| !PROCESS_PLATES.contains(&name.as_str())));
}
inks
}
/// The plates of a separated image; `None` for other images, and ones whose plates can't be
/// read. The plates of the last image read are kept for the next call (print separates one
/// image plate after plate).
pub fn separated(&self) -> Option<Arc<Separated>> {
thread_local! {
static LAST: std::cell::RefCell<Option<(String, Arc<Separated>)>> = const { std::cell::RefCell::new(None) };
}
if !self.is_separated() {
return None;
}
let key = self.content_key();
LAST.with_borrow_mut(|last| match last {
Some((k, s)) if *k == key => Some(s.clone()),
_ => {
let s = Arc::new(read_plates(&self.bytes)?);
*last = Some((key, s.clone()));
Some(s)
}
})
}
}
/// The plates of TIFF `bytes`, each the size of its first image.
fn read_plates(bytes: &[u8]) -> Option<Separated> {
let (_, mut d) = crate::cmyk::tiff(bytes)?;
let (w, h) = d.dimensions().ok()?;
let n = usize::try_from(u64::from(w) * u64::from(h)).ok()?;
let mut plates = vec![];
// Every plate is read: a file with more than [`MAX_PLATES`] is refused ([`Separated::fits`]),
// not cut short, and so is one with more than [`MAX_DIRECTORIES`] directories.
let mut directories = 1usize;
while d.more_images() {
directories += 1;
if directories > MAX_DIRECTORIES {
return None;
}
d.next_image().ok()?;
let Some((name, cmyk)) = d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().as_deref().and_then(parse_description) else {
continue;
};
// Checked before the plate is decoded.
if d.dimensions().ok()? != (w, h) || !matches!(d.colortype().ok()?, tiff::ColorType::Gray(8)) || !Separated::fits(w, h, plates.len() + 1) {
return None;
}
let data = crate::cmyk::read_samples(&mut d, n, 1)?.into_iter().map(|v| 255 - v).collect();
plates.push(InkPlate { name, cmyk, data });
}
Separated::new(w, h, plates)
}
#[cfg(test)]
mod tests {
use super::*;
fn plate(name: &str, cmyk: [f32; 4], data: &[u8]) -> InkPlate {
InkPlate { name: name.into(), cmyk, data: data.to_vec() }
}
/// Two pixels: cyan 100% and a spot ink at 50% on the first, nothing on the second.
fn two() -> Separated {
let plates = vec![plate("Cyan", [0.0; 4], &[255, 0]), plate("Spot Orange", [0.0, 0.6, 1.0, 0.0], &[128, 0])];
Separated::new(2, 1, plates).unwrap()
}
#[test]
fn plates_mix_into_the_composite_as_inks_print() {
let s = two();
assert_eq!(s.plates[0].cmyk, [1.0, 0.0, 0.0, 0.0], "a process plate stands for its own ink");
let c = s.composite();
// Magenta: 0.6 × 128/255; yellow: 128/255.
assert_eq!(&c.data[..4], &[255, 77, 128, 0]);
assert_eq!(&c.data[4..], &[0, 0, 0, 0]);
}
#[test]
fn a_spot_plate_prints_on_its_own_plate_or_as_process() {
let s = two();
let own = |_: &str| true;
assert_eq!(s.coverage("Spot Orange", &own), [128, 0]);
assert_eq!(s.coverage("Magenta", &own), [0, 0], "nothing of the spot on a process plate");
assert_eq!(s.coverage("Cyan", &own), [255, 0]);
assert_eq!(s.coverage("Black", &own), [0, 0]);
let process = |_: &str| false;
assert_eq!(s.coverage("Spot Orange", &process), [0, 0]);
assert_eq!(s.coverage("Magenta", &process), [77, 0]);
assert_eq!(s.coverage("Yellow", &process), [128, 0]);
assert_eq!(s.coverage("Other", &own), [0, 0]);
}
#[test]
fn the_tiff_keeps_every_plate_and_shows_the_composite() {
let s = two();
let files =
[PlateFile { name: "pic (1).C".into(), size: 1234, modified: 1_700_000_000_000 }, PlateFile { name: "ü.K".into(), size: 0, modified: 0 }];
let b = ImageBlob::separated_tiff(&s, Some((300.0, 300.0)), &files).unwrap();
assert_eq!(b.plate_files(), files);
assert!(ImageBlob::separated_tiff(&s, None, &[]).unwrap().plate_files().is_empty());
assert!(b.is_separated());
assert!(b.is_cmyk(), "readers of a CMYK TIFF see the composite");
assert_eq!(b.cmyk(), Some(s.composite()));
assert_eq!(*b.separated().unwrap(), s);
assert_eq!(b.spot_plates(), [("Spot Orange".to_string(), [0.0, 0.6, 1.0, 0.0])]);
assert!(ImageBlob::cmyk_tiff(&s.composite()).unwrap().spot_plates().is_empty());
assert!(!ImageBlob::cmyk_tiff(&s.composite()).unwrap().is_separated());
}
#[test]
fn the_tiff_keeps_the_clipping_path_beside_the_plate_files() {
let s = two();
let clip = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 1.5, 1.0));
let files = [PlateFile { name: "a.C".into(), size: 1, modified: 2 }];
let b = ImageBlob::separated_tiff_clipped(&s, None, &files, Some(&clip)).unwrap();
assert_eq!(b.separated_clip(), Some(clip.clone()));
assert_eq!(b.plate_files(), files);
let alone = ImageBlob::separated_tiff_clipped(&s, None, &[], Some(&clip)).unwrap();
assert_eq!(alone.separated_clip(), Some(clip));
assert!(alone.plate_files().is_empty());
assert_eq!(ImageBlob::separated_tiff(&s, None, &files).unwrap().separated_clip(), None);
}
#[test]
fn a_tiff_with_more_plates_than_are_read_is_refused() {
use tiff::encoder::{TiffEncoder, colortype};
let tiff = |plates: usize| {
let mut out = Cursor::new(Vec::new());
let mut enc = TiffEncoder::new(&mut out).unwrap();
enc.write_image::<colortype::CMYK8>(1, 1, &[0, 0, 0, 0]).unwrap();
for i in 0..plates {
let mut img = enc.new_image::<colortype::Gray8>(1, 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ImageDescription, description(&plate(&format!("S{i}"), [0.0; 4], &[0])).as_str()).unwrap();
img.write_data(&[255]).unwrap();
}
ImageBlob::new("image/tiff", out.into_inner())
};
assert_eq!(tiff(MAX_PLATES).separated().map(|s| s.plates.len()), Some(MAX_PLATES));
let over = tiff(MAX_PLATES + 1);
assert!(over.separated().is_none(), "not cut short to the first {MAX_PLATES}");
assert!(!over.is_separated() && over.spot_plates().is_empty(), "the CMYK image of its composite only");
assert!(tiff(MAX_PLATES).is_separated());
}
#[test]
fn a_tiff_with_more_directories_than_a_composite_and_its_plates_is_refused() {
use tiff::encoder::{TiffEncoder, colortype};
let tiff = |others: usize| {
let mut out = Cursor::new(Vec::new());
let mut enc = TiffEncoder::new(&mut out).unwrap();
enc.write_image::<colortype::CMYK8>(1, 1, &[0, 0, 0, 0]).unwrap();
let mut img = enc.new_image::<colortype::Gray8>(1, 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ImageDescription, description(&plate("S", [0.0; 4], &[0])).as_str()).unwrap();
img.write_data(&[255]).unwrap();
// Directories that are not plates.
for _ in 0..others {
enc.write_image::<colortype::Gray8>(1, 1, &[255]).unwrap();
}
ImageBlob::new("image/tiff", out.into_inner())
};
let most = tiff(MAX_PLATES - 1);
assert!(most.is_separated() && most.separated().is_some());
let over = tiff(MAX_PLATES);
assert!(!over.is_separated() && over.separated().is_none() && over.spot_plates().is_empty());
assert!(read_plates(&over.bytes).is_none(), "read_plates stops at the limit too");
}
#[test]
fn names_that_are_not_ascii_are_kept() {
let s = Separated::new(1, 1, vec![plate("Grün 100 % (A)", [0.5, 0.0, 1.0, 0.0], &[9])]).unwrap();
let b = ImageBlob::separated_tiff(&s, None, &[]).unwrap();
assert_eq!(b.separated().unwrap().plates[0].name, "Grün 100 % (A)");
}
#[test]
fn bad_plates_are_refused() {
assert!(Separated::new(2, 1, vec![]).is_none(), "no plates");
assert!(Separated::new(2, 1, vec![plate("Cyan", [0.0; 4], &[1])]).is_none(), "too few pixels");
assert!(Separated::new(1, 1, vec![plate("A", [0.0; 4], &[1]), plate("A", [0.0; 4], &[1])]).is_none(), "the same name twice");
assert!(Separated::new(1, 1, vec![plate("", [0.0; 4], &[1])]).is_none(), "no name");
assert!(Separated::new(1, 1, vec![plate("A", [2.0, 0.0, 0.0, 0.0], &[1])]).is_none(), "equivalent past 100%");
assert!(Separated::new(0, 1, vec![plate("A", [0.0; 4], &[])]).is_none(), "empty");
let many: Vec<InkPlate> = (0..=MAX_PLATES).map(|i| plate(&format!("S{i}"), [0.0; 4], &[1])).collect();
assert!(Separated::new(1, 1, many).is_none(), "too many plates");
assert!(Separated::fits(1 << 13, 1 << 13, 8), "512 MB of ink amounts");
assert!(!Separated::fits(1 << 13, 1 << 13, 9), "more than 512 MB together");
assert!(!Separated::fits(1 << 14, 1 << 13, 1), "more pixels than a CMYK image is decoded with");
}
}