diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 19ed7014..49614fc5 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -456,6 +456,19 @@ fn color_mode(s: &mut Session, p: &Value) -> Result { if s.doc()?.doc.color_mode == mode { return ok(); } + s.edit("Document Color Mode", |d, _| { + set_color_mode(d, mode, convert); + Ok(()) + })?; + ok() +} + +/// Put `d` in colour `mode`; with `convert`, every colour of its art and swatches too. +pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool) { + d.color_mode = mode; + if !convert { + return; + } let f = move |c: vectorcraft_color::Color| match mode { ColorMode::Cmyk => to_cmyk(c), ColorMode::Rgb => { @@ -463,20 +476,13 @@ fn color_mode(s: &mut Session, p: &Value) -> Result { vectorcraft_color::Color::rgb(r, g, b) } }; - s.edit("Document Color Mode", |d, _| { - d.color_mode = mode; - if convert { - let layers: Vec = d.layers.iter().map(|l| l.id).collect(); - Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers); - for sw in d.swatches_iter_mut() { - if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint { - *color = f(*color); - } - } + let layers: Vec = d.layers.iter().map(|l| l.id).collect(); + Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers); + for sw in d.swatches_iter_mut() { + if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint { + *color = f(*color); } - Ok(()) - })?; - ok() + } } fn file_info(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 24acb1b5..f993bb4a 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -115,6 +115,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result _ if format.raster => raster_doc(&title, bytes)?, _ => return Err(err(format!("{} files can't be opened yet", format.label))), }; + if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { + super::super::docmenu::set_color_mode(&mut doc, mode, true); + } // Imports are named after the file; a native document keeps its own title (the tab shows the // file name once it has a path). if format.id != "vectorcraft" || doc.title.is_empty() { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 6a39d564..dc8d490c 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo) → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document", always, load::open ), @@ -468,6 +468,8 @@ mod tests; #[cfg(test)] mod tests_pdf; #[cfg(test)] +mod tests_pdfcolor; +#[cfg(test)] mod tests_pdfimport; #[cfg(test)] mod tests_svg; diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 9bec318e..3023bd87 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -3,13 +3,14 @@ //! boxes, a thumbnail) for the Import PDF dialog. use serde_json::{Value, json}; -use vectorcraft_doc::Document; +use vectorcraft_doc::{ColorMode, Document}; use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError}; use super::super::*; use super::{err, source}; -/// The `document.open` options besides the file. PDF only for now; other formats ignore them. +/// The `document.open` options besides the file. The PDF ones (pages, box, password) are ignored +/// by other formats. #[derive(Clone, Debug, Default, PartialEq)] pub struct LoadOptions { /// 1-based pages to import, such as `"2-3, 5"`; `None` = every page. @@ -18,11 +19,14 @@ pub struct LoadOptions { pub crop: CropTo, /// The password of an encrypted PDF. pub password: Option, + /// The colour mode the document opens in, its colours converted as Document Color Mode does + /// (default: the file's; a PDF painted mostly in CMYK opens in CMYK). + pub color_mode: Option, } impl LoadOptions { /// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when - /// `pages` isn't given), `cropTo` (or `crop`) and `password`. + /// `pages` isn't given), `cropTo` (or `crop`), `password` and `colorMode` (`rgb` | `cmyk`). pub fn from_params(cmd: &str, p: &Value) -> Result { let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) { None => None, @@ -41,7 +45,13 @@ impl LoadOptions { .ok_or_else(|| bad(cmd, format!("cropTo must be one of {}", CropTo::IDS.join(", "))))?, }; let password = str_param(p, "password").filter(|s| !s.is_empty()).map(str::to_string); - Ok(Self { pages, crop, password }) + let color_mode = match str_param(p, "colorMode").map(str::to_ascii_lowercase).as_deref() { + None => None, + Some("rgb") => Some(ColorMode::Rgb), + Some("cmyk") => Some(ColorMode::Cmyk), + Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))), + }; + Ok(Self { pages, crop, password, color_mode }) } /// The PDF import options: `pages` resolved against the file's page count. diff --git a/crates/engine/src/cmd/fileio/tests_pdfcolor.rs b/crates/engine/src/cmd/fileio/tests_pdfcolor.rs new file mode 100644 index 00000000..b5b3c651 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdfcolor.rs @@ -0,0 +1,64 @@ +//! `document.open` keeps a PDF's CMYK, Gray and spot colours, and `colorMode` picks the mode. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::ColorMode; + +use super::*; + +/// A CMYK document with a CMYK fill, a Gray fill and a 40 % tint of spot ink "Gold", as PDF. +fn cmyk_pdf() -> String { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50, "colorMode": "cmyk"})).unwrap(); + s.execute("swatch.new", &json!({"name": "Gold", "color": {"model": "cmyk", "c": 0.0, "m": 0.2, "y": 0.8, "k": 0.1}, "spot": true})).unwrap(); + for (x, fill) in [ + (0, json!({"color": {"model": "cmyk", "c": 0.6, "m": 0.1, "y": 0.0, "k": 0.2}})), + (20, json!({"color": {"model": "gray", "k": 0.3}})), + (40, json!({"swatch": "Gold", "tint": 40})), + ] { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + } + let v = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); + v["dataBase64"].as_str().unwrap().to_string() +} + +fn fills(s: &Session) -> Vec { + let d = &s.active().unwrap().doc; + d.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.fill().map(|f| f.paint.clone())).collect() +} + +#[test] +fn a_cmyk_pdf_opens_as_a_cmyk_document_with_its_spot_swatch() { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf()})).unwrap(); + assert_eq!(s.execute("file.info", &json!({})).unwrap()["colorMode"], "cmyk"); + let f = fills(&s); + assert!(matches!(f[0].color(), Some(Color::Cmyk { c, .. }) if (c - 0.6).abs() < 0.004), "{:?}", f[0]); + assert!(matches!(f[1].color(), Some(Color::Gray { k }) if (k - 0.3).abs() < 0.004), "{:?}", f[1]); + assert!(matches!(&f[2], Paint::Solid { swatch: Some(n), tint, .. } if n == "Gold" && (tint - 0.4).abs() < 0.004), "{:?}", f[2]); + let d = &s.active().unwrap().doc; + let gold = d.swatch("Gold").unwrap(); + assert!(gold.spot && gold.global); + assert!(matches!(gold.paint.color(), Some(Color::Cmyk { m, y, .. }) if (m - 0.2).abs() < 0.004 && (y - 0.8).abs() < 0.004)); +} + +#[test] +fn color_mode_opens_the_file_in_the_mode_asked_for() { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf(), "colorMode": "RGB"})).unwrap(); + assert_eq!(s.active().unwrap().doc.color_mode, ColorMode::Rgb); + let f = fills(&s); + assert!(f.iter().all(|p| matches!(p.color(), Some(Color::Rgb { .. }))), "converted as Document Color Mode does: {f:?}"); + // Any format opens in the mode asked for. + let mut svg = Session::new(); + svg.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); + svg.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let text = svg.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string(); + s.execute("document.open", &json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes()), "colorMode": "cmyk"})) + .unwrap(); + assert_eq!(s.active().unwrap().doc.color_mode, ColorMode::Cmyk); + assert!(fills(&s).iter().all(|p| !matches!(p.color(), Some(Color::Rgb { .. })))); + let e = s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf(), "colorMode": "lab"})).unwrap_err().to_string(); + assert!(e.contains("rgb or cmyk"), "{e}"); +} diff --git a/crates/engine/src/cmd/fileio/tests_pdfimport.rs b/crates/engine/src/cmd/fileio/tests_pdfimport.rs index 4d10b31e..c8853c27 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfimport.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfimport.rs @@ -140,7 +140,7 @@ fn file_place_crops_a_pdf_page_to_any_box_and_takes_its_password() { #[test] fn load_options_read_pages_crop_and_password() { let o = LoadOptions::from_params("x", &json!({"page": 3, "crop": "trim", "password": "p"})).unwrap(); - assert_eq!(o, LoadOptions { pages: Some("3".into()), crop: vectorcraft_pdf::CropTo::Trim, password: Some("p".into()) }); + assert_eq!(o, LoadOptions { pages: Some("3".into()), crop: vectorcraft_pdf::CropTo::Trim, password: Some("p".into()), ..Default::default() }); // `pages` wins over `page`; an empty password is none. let o = LoadOptions::from_params("x", &json!({"pages": "1-2", "page": 3, "password": ""})).unwrap(); assert_eq!((o.pages.as_deref(), o.password), (Some("1-2"), None)); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4ba505bb..7096923b 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -273,6 +273,8 @@ fn arb_pdf_op() -> impl Strategy { Just("/Im0".to_string()), Just("/GS0".to_string()), Just("/DeviceRGB".to_string()), + Just("/CS0".to_string()), + Just("/DeviceCMYK".to_string()), Just("/Pattern".to_string()), Just("(Hi)".to_string()), Just("<00ff>".to_string()), @@ -290,6 +292,12 @@ fn arb_resources() -> impl Strategy { "<< /Shading << /Sh0 << /ShadingType 3 /ColorSpace /DeviceRGB /Coords [0 0 0 0 0 -5] /Function << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [] /N -1 >> >> >> >>".to_string(), "<< /XObject << /Im0 << /Type /XObject /Subtype /Image /Width 4294967295 /Height 0 /BitsPerComponent 8 /ColorSpace /DeviceRGB /Length 3 >> >> >>".to_string(), "<< /Pattern << /P0 << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0 0] /XStep 0 /YStep -0 /Resources << >> >> >> >>".to_string(), + // Ink colour spaces with junk names, alternates and tint transforms (spot swatch import). + "<< /ColorSpace << /CS0 [/Separation /All /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [1 1 1 1] /N 1 >>] >> >>".to_string(), + "<< /ColorSpace << /CS0 [/Separation /#00 /Lab << /FunctionType 2 /Domain [0 1] /C0 [1e308] /C1 [NaN 5] /N -1 >>] >> >>".to_string(), + "<< /ColorSpace << /CS0 [/DeviceN [] /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>] >> >>".to_string(), + "<< /ColorSpace << /CS0 [/DeviceN [/A /None /Cyan /All] [/ICCBased << /N 4 >>] << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>] >> >>".to_string(), + "<< /Shading << /Sh0 << /ShadingType 2 /ColorSpace [/Separation /Ink /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>] /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> >>".to_string(), ]) } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 88f0198d..fcd5947d 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -14,11 +14,12 @@ use hayro_syntax::object::Name; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Artboard, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, - StrokeLayer, + Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, + NodeKind, StrokeLayer, }; use vectorcraft_geom::{FillRule, PathData}; +use crate::import_color::{Colors, Native}; use crate::{CropTo, ImportOptions, ImportReport, PdfError}; /// Import a PDF (or PDF-compatible `.ai`) with default options. @@ -54,7 +55,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result Result { + colors: Colors<'p>, next: u64, stack: Vec, blend: BlendMode, @@ -203,9 +215,10 @@ fn round3(v: f32) -> f32 { (v * 1000.0).round() / 1000.0 } -impl Builder { - fn new(next: u64) -> Self { +impl<'p> Builder<'p> { + fn new(next: u64, colors: Colors<'p>) -> Self { Self { + colors, next, stack: vec![], blend: BlendMode::Normal, @@ -331,10 +344,11 @@ impl Builder { match p { hayro_interpret::Paint::Color(c) => { let [r, g, b, a] = c.to_rgba().components(); - (Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), a) + let paint = self.colors.solid(c).map_or_else(|| Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), Native::paint); + (paint, a) } hayro_interpret::Paint::Pattern(pat) => match pat.as_ref() { - Pattern::Shading(sp) => match shading_gradient(sp) { + Pattern::Shading(sp) => match shading_gradient(sp, &mut self.colors) { Some(g) => (Paint::Gradient(Box::new(g)), sp.opacity), None => { self.warn("mesh/function shadings are imported as a flat colour"); @@ -368,17 +382,19 @@ impl Builder { } } -/// Axial/radial shading → gradient paint (stops sampled from the shading function). -fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option { +/// Axial/radial shading → gradient paint (stops sampled from the shading function, in the +/// shading's own colour model). +fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern, colors: &mut Colors<'_>) -> Option { let ShadingType::RadialAxial { coords, domain, function, axial, .. } = sp.shading.shading_type.as_ref() else { return None; }; let m = sp.matrix; let cs = &sp.shading.color_space; + let at = |t: f32| function.eval(&smallvec::smallvec![domain[0] + (domain[1] - domain[0]) * t]); + let space = colors.shading_space(cs, at(0.0)?.len()); + // RGB to decide which samples are stops; the stops keep the shading's model. let sample = |t: f32| -> Option<(Color, f32)> { - let x = domain[0] + (domain[1] - domain[0]) * t; - let v = function.eval(&smallvec::smallvec![x])?; - let [r, g, b, a] = cs.to_rgba(&v, 1.0, false).components(); + let [r, g, b, a] = cs.to_rgba(&at(t)?, 1.0, false).components(); Some((Color::rgb(round3(r), round3(g), round3(b)), a)) }; // Sample densely, then drop samples that linear interpolation reproduces. @@ -400,9 +416,13 @@ fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option GradientStop { - let (color, _) = sample(t).unwrap_or((Color::BLACK, 1.0)); - GradientStop::new(offset, color) + let mut stop_at = |t: f32, offset: f32| -> GradientStop { + let (rgb, _) = sample(t).unwrap_or((Color::BLACK, 1.0)); + match at(t).and_then(|v| colors.native(space, &v)) { + Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) }, + Some(Native { color, link: None }) => GradientStop::new(offset, color), + None => GradientStop::new(offset, rgb), + } }; let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64); let (kind, geom, stops) = if *axial { @@ -443,7 +463,7 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec { } } -impl<'a> Device<'a> for Builder { +impl<'a> Device<'a> for Builder<'_> { fn set_soft_mask(&mut self, mask: Option>) { if mask.is_some() { self.warn("soft masks are not imported (content drawn unmasked)"); diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs new file mode 100644 index 00000000..c4966fbe --- /dev/null +++ b/crates/pdf/src/import_color.rs @@ -0,0 +1,419 @@ +//! PDF colours in their own models: DeviceCMYK and CMYK ICC colours stay CMYK, DeviceGray is +//! Gray, and Separation and DeviceN inks become spot swatches at a tint (for solid paints and +//! shading stops alike). Everything else is RGB, as the interpreter converts it. +//! +//! hayro-interpret keeps a colour's space and components private: their `Debug` form is the only +//! public view, so [`Colors`] reads that (`tests_import_color.rs` pins the format). Ink names +//! aren't in it at all: they come from the file's Separation and DeviceN arrays, found by their +//! tint transform. + +use std::fmt::{self, Debug, Write as _}; + +use hayro_interpret::Function; +use hayro_interpret::color::ColorSpace; +use hayro_syntax::Pdf; +use hayro_syntax::object::{Array, MaybeRef, Name, Object, Stream}; +use smallvec::SmallVec; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_color::{Color, Paint, Swatch}; + +/// The longest `Debug` text read: a colour whose space is longer (a big sampled tint transform) +/// imports as RGB. +const DEBUG_LIMIT: usize = 64 * 1024; +/// The deepest nesting searched for colour spaces, and the most ink spaces kept. +const MAX_DEPTH: u32 = 32; +const MAX_SPACES: usize = 4096; + +/// DeviceN (and Separation) colorants that are process plates, in CMYK channel order. +const PROCESS: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"]; + +fn round3(v: f32) -> f32 { + (v * 1000.0).round() / 1000.0 +} + +/// A colour in its own model; an ink links to its spot swatch at a tint. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Native { + pub color: Color, + pub link: Option<(String, f32)>, +} + +impl Native { + fn plain(color: Color) -> Self { + Self { color, link: None } + } + + pub fn paint(self) -> Paint { + match self.link { + Some((name, tint)) => Paint::Solid { color: self.color, swatch: Some(name), tint }, + None => Paint::solid(self.color), + } + } +} + +/// What a Separation or DeviceN colorant is. +#[derive(Clone, Copy, Debug)] +enum Colorant { + /// A spot ink (index into `Colors::inks`). + Ink(usize), + /// A process plate (CMYK channel). + Process(usize), + /// Every plate (Registration). + All, + /// Paints nothing. + None, + /// An ink whose colour can't be told (its alternate space isn't one we model). + Unknown, +} + +/// A Separation or DeviceN colour space of the file. +struct InkSpace { + /// `Debug` text of its tint transform: how a colour's space is matched to it. + key: String, + device_n: bool, + names: Vec, + colorants: Vec, +} + +struct Ink { + /// The colorant name in the file. + source: String, + /// The swatch name (the source name, made unique against the document's swatches). + name: String, + /// The colour at 100 %. + color: Color, + used: bool, +} + +/// How a colour space's components map. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Space { + Gray, + Rgb, + Cmyk, + /// A Separation or DeviceN space (index into `Colors::spaces`). + Inks(usize), + /// Anything else: RGB through the interpreter. + Other, +} + +/// The alternate space of an ink, for its colour at 100 %. +#[derive(Clone, Copy)] +enum Alt { + Gray, + Rgb, + Cmyk, + Lab, +} + +/// The import's colour mapping, with the inks found and the colour models used. +pub(crate) struct Colors<'p> { + pdf: &'p Pdf, + /// Swatch names the document already has. + taken: Vec, + scanned: bool, + spaces: Vec, + inks: Vec, + /// Process colours painted in CMYK and in RGB (or anything converted to it). + cmyk: usize, + rgb: usize, + /// A DeviceN colour mixed several inks (imported as RGB). + pub mixed: bool, +} + +/// `v`'s `Debug` text, or `None` past [`DEBUG_LIMIT`] (formatting stops there). +fn debug_text(v: &impl Debug) -> Option { + struct Capped(String); + impl fmt::Write for Capped { + fn write_str(&mut self, s: &str) -> fmt::Result { + if self.0.len() + s.len() > DEBUG_LIMIT { + return Err(fmt::Error); + } + self.0.push_str(s); + Ok(()) + } + } + let mut w = Capped(String::new()); + write!(w, "{v:?}").ok()?; + Some(w.0) +} + +/// A colour's `Debug` text (`Color { color_space: …, components: [..], opacity: .. }`) split into +/// its space's text and its components. +pub(crate) fn split_color(t: &str) -> Option<(&str, Vec)> { + let start = t.find("color_space: ")? + "color_space: ".len(); + let at = t.rfind(", components: [")?; + let space = t.get(start..at)?; + let rest = t.get(at + ", components: [".len()..)?; + let list = rest.get(..rest.find(']')?)?; + let comps = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::parse::).collect::, _>>().ok()?; + comps.iter().all(|c| c.is_finite()).then_some((space, comps)) +} + +impl<'p> Colors<'p> { + pub fn new(pdf: &'p Pdf, taken: Vec) -> Self { + Self { pdf, taken, scanned: false, spaces: vec![], inks: vec![], cmyk: 0, rgb: 0, mixed: false } + } + + /// A solid colour in its own model; `None`: use its RGB. + pub fn solid(&mut self, c: &hayro_interpret::color::Color) -> Option { + let mapped = debug_text(c).and_then(|t| { + let (space, comps) = split_color(&t)?; + let space = self.space_of(space, comps.len()); + Some((space, comps)) + }); + let (space, comps) = mapped.unwrap_or((Space::Other, vec![])); + self.count(space); + self.native(space, &comps) + } + + /// The space shading colours are in, for [`Colors::native`]; `n`: its component count. + pub fn shading_space(&mut self, cs: &ColorSpace, n: usize) -> Space { + let space = debug_text(cs).map_or(Space::Other, |t| self.space_of(&t, n)); + self.count(space); + space + } + + /// Note a colour painted in `space` (the document's colour mode follows the majority). + fn count(&mut self, space: Space) { + match space { + Space::Cmyk => self.cmyk += 1, + Space::Rgb | Space::Other => self.rgb += 1, + Space::Gray | Space::Inks(_) => {} + } + } + + /// The document opens in CMYK when more process colours were CMYK than RGB. + pub fn cmyk_document(&self) -> bool { + self.cmyk > self.rgb + } + + /// Spot swatches for the inks used. + pub fn swatches(&self) -> impl Iterator + '_ { + self.inks.iter().filter(|i| i.used).map(|i| Swatch { name: i.name.clone(), paint: Paint::solid(i.color), global: true, spot: true }) + } + + /// The space a `ColorSpace(…)` `Debug` text names, with `n` components. + pub(crate) fn space_of(&mut self, text: &str, n: usize) -> Space { + let inner = text.strip_prefix("ColorSpace(").unwrap_or(text); + let kind = inner.split(|c: char| !c.is_ascii_alphanumeric()).next().unwrap_or_default(); + match (kind, n) { + ("DeviceGray" | "ICCBased", 1) => Space::Gray, + ("DeviceRgb", 3) => Space::Rgb, + ("DeviceCmyk" | "ICCBased", 4) => Space::Cmyk, + ("Separation", 1) | ("DeviceN", _) => { + self.scan(); + let device_n = kind == "DeviceN"; + let mut found = + self.spaces.iter().enumerate().filter(|(_, s)| s.device_n == device_n && s.colorants.len() == n && inner.contains(&s.key)); + match found.next() { + // Spaces of other inks with the same alternate and tint transform look the + // same here: which inks this colour uses can't be told. + Some((i, first)) if found.all(|(_, s)| s.names == first.names) => Space::Inks(i), + _ => Space::Other, + } + } + _ => Space::Other, + } + } + + /// Components `comps` of `space` in their own model; `None`: use their RGB. + pub fn native(&mut self, space: Space, comps: &[f32]) -> Option { + let c = |i: usize| round3(comps.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0)); + match space { + Space::Gray => Some(Native::plain(Color::gray(round3(1.0 - c(0))))), + Space::Cmyk => Some(Native::plain(Color::cmyk(c(0), c(1), c(2), c(3)))), + Space::Rgb | Space::Other => None, + Space::Inks(i) => self.inks_color(i, comps), + } + } + + /// A Separation or DeviceN colour: process plates make a CMYK colour; one ink (or every + /// plate) at a tint links to its swatch; several inks together can't, and import as RGB. + fn inks_color(&mut self, space: usize, comps: &[f32]) -> Option { + let colorants = &self.spaces.get(space)?.colorants; + let mut cmyk = [0.0f32; 4]; + let mut ink: Option<(Colorant, f32)> = None; + let mut first_ink = None; + let mut several = false; + for (c, t) in colorants.iter().zip(comps) { + let t = round3(t.clamp(0.0, 1.0)); + match *c { + Colorant::Unknown => return None, + Colorant::None => {} + Colorant::Process(ch) => { + if let Some(v) = cmyk.get_mut(ch) { + *v = v.max(t); + } + } + Colorant::Ink(_) | Colorant::All => { + first_ink.get_or_insert(*c); + if t > 0.0 { + several |= ink.is_some(); + ink = Some((*c, t)); + } + } + } + } + let process = cmyk.iter().any(|v| *v > 0.0); + let (c, t) = match (ink, first_ink) { + (Some(_), _) if several || process => { + self.mixed = true; + return None; + } + (Some(it), _) => it, + // No ink is painting: 0 % of the first one (a tint of paper), unless the plates are + // all process ones. + (None, Some(c)) if !process => (c, 0.0), + _ => return Some(Native::plain(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]))), + }; + match c { + Colorant::Ink(k) => { + let ink = self.inks.get_mut(k)?; + ink.used = true; + Some(Native { color: ink.color.tinted(t), link: Some((ink.name.clone(), t)) }) + } + _ => Some(Native { color: Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(t), link: Some((REGISTRATION.to_string(), t)) }), + } + } + + /// Find the file's Separation and DeviceN colour spaces (once, when a colour first needs them). + fn scan(&mut self) { + if self.scanned { + return; + } + self.scanned = true; + let pdf = self.pdf; + for o in pdf.objects() { + self.visit(o, 0); + } + } + + fn visit(&mut self, o: Object<'_>, depth: u32) { + if depth > MAX_DEPTH || self.spaces.len() >= MAX_SPACES { + return; + } + match o { + Object::Array(a) => { + self.ink_space(&a); + for item in a.raw_iter().filter_map(direct) { + self.visit(item, depth + 1); + } + } + Object::Dict(d) => { + for (_, v) in d.entries() { + if let Some(v) = direct(v) { + self.visit(v, depth + 1); + } + } + } + Object::Stream(s) => self.visit(Object::Dict(s.dict().clone()), depth + 1), + _ => {} + } + } + + /// Record `a` when it is a `[/Separation name alt tint]` or `[/DeviceN [names] alt tint …]` space. + fn ink_space(&mut self, a: &Array<'_>) -> Option<()> { + let mut it = a.flex_iter(); + let device_n = match it.next::>()?.as_str() { + "Separation" => false, + "DeviceN" => true, + _ => return None, + }; + let names: Vec = if device_n { + it.next::>()?.iter::>().map(|n| n.as_str().to_string()).collect() + } else { + vec![it.next::>()?.as_str().to_string()] + }; + let alt = alt_model(&it.next::>()?); + let tint = Function::new(&it.next::>()?)?; + let key = debug_text(&tint)?; + if names.is_empty() || self.spaces.iter().any(|s| s.key == key && s.device_n == device_n && s.names == names) { + return None; + } + let colorants = names + .iter() + .enumerate() + .map(|(i, name)| match name.as_str() { + "None" => Colorant::None, + "All" => Colorant::All, + n => match PROCESS.iter().position(|p| *p == n) { + Some(ch) => Colorant::Process(ch), + None => { + // The ink alone at 100 %, through the tint transform. + let input: SmallVec<[f32; 4]> = (0..names.len()).map(|j| if i == j { 1.0 } else { 0.0 }).collect(); + match alt.zip(tint.eval(input)).and_then(|(alt, v)| alt_color(alt, &v)) { + Some(color) => Colorant::Ink(self.ink(n, color)), + None => Colorant::Unknown, + } + } + }, + }) + .collect(); + self.spaces.push(InkSpace { key, device_n, names, colorants }); + Some(()) + } + + /// The ink named `source` (the first definition wins), its swatch name unique in the document. + fn ink(&mut self, source: &str, color: Color) -> usize { + if let Some(i) = self.inks.iter().position(|k| k.source == source) { + return i; + } + let mut name = source.to_string(); + let mut n = 2; + while self.taken.contains(&name) { + name = format!("{source} {n}"); + n += 1; + } + self.inks.push(Ink { source: source.to_string(), name, color, used: false }); + self.inks.len() - 1 + } +} + +/// An object written in place (references are visited as objects of their own). +fn direct(m: MaybeRef>) -> Option> { + match m { + MaybeRef::NotRef(o) => Some(o), + MaybeRef::Ref(_) => None, + } +} + +fn alt_model(o: &Object<'_>) -> Option { + match o { + Object::Name(n) => match n.as_str() { + "DeviceGray" | "G" => Some(Alt::Gray), + "DeviceRGB" | "RGB" => Some(Alt::Rgb), + "DeviceCMYK" | "CMYK" => Some(Alt::Cmyk), + _ => None, + }, + Object::Array(a) => { + let mut it = a.flex_iter(); + match it.next::>()?.as_str() { + "ICCBased" => match it.next::>()?.dict().get::(b"N")? { + 1 => Some(Alt::Gray), + 3 => Some(Alt::Rgb), + 4 => Some(Alt::Cmyk), + _ => None, + }, + "Lab" => Some(Alt::Lab), + "CalRGB" => Some(Alt::Rgb), + "CalGray" => Some(Alt::Gray), + "CalCMYK" => Some(Alt::Cmyk), + _ => None, + } + } + _ => None, + } +} + +/// An alternate-space colour (the tint transform's output) as a VectorCraft colour. +fn alt_color(alt: Alt, v: &[f32]) -> Option { + let c = |i: usize| v.get(i).copied().filter(|x| x.is_finite()); + let u = |i: usize| c(i).map(|x| round3(x.clamp(0.0, 1.0))); + Some(match alt { + Alt::Gray => Color::gray(round3(1.0 - u(0)?)), + Alt::Rgb => Color::rgb(u(0)?, u(1)?, u(2)?), + Alt::Cmyk => Color::cmyk(u(0)?, u(1)?, u(2)?, u(3)?), + Alt::Lab => Color::lab(round3(c(0)?.clamp(0.0, 100.0)), round3(c(1)?.clamp(-128.0, 127.0)), round3(c(2)?.clamp(-128.0, 127.0))), + }) +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index f87e9a2f..b7a33a24 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -11,12 +11,15 @@ //! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a //! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups, //! transparency groups, axial/radial shadings → gradients, images (JPEG passthrough, others -//! re-encoded as PNG) and text as glyph outlines. [`ImportOptions`] pick the pages, the box each +//! re-encoded as PNG) and text as glyph outlines. Colours keep their model: CMYK, Gray, and spot +//! inks (Separation, DeviceN) as spot swatches at a tint; a file painted mostly in CMYK opens as a +//! CMYK document. [`ImportOptions`] pick the pages, the box each //! artboard gets ([`CropTo`]) and the password; [`info`] lists the pages and their boxes. #![forbid(unsafe_code)] mod export; mod import; +mod import_color; mod lab_spot; mod pages; mod settings; @@ -138,6 +141,8 @@ mod tests_focal; #[cfg(test)] mod tests_fx; #[cfg(test)] +mod tests_import_color; +#[cfg(test)] mod tests_import_options; #[cfg(test)] mod tests_settings; diff --git a/crates/pdf/src/tests_import_color.rs b/crates/pdf/src/tests_import_color.rs new file mode 100644 index 00000000..4a0acc4d --- /dev/null +++ b/crates/pdf/src/tests_import_color.rs @@ -0,0 +1,217 @@ +//! Imported colours keep their model: CMYK, Gray, spot inks (Separation, DeviceN) at a tint, in +//! solid paints and shading stops; a CMYK file opens as a CMYK document. + +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch}; +use vectorcraft_doc::{Appearance, AppearanceItem, ColorMode, Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with}; + +use crate::import_color::{Colors, Space}; +use crate::*; + +const Q: f32 = 1.0 / 255.0 + 1e-4; + +fn add(d: &mut Document, p: Paint) { + let x = d.node_count() as f64 * 12.0; + let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(p, Paint::None, 0.0)); + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), usize::MAX, n).unwrap(); +} + +/// The fills of the imported page, in paint order. +fn fills(d: &Document) -> Vec { + let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!() }; + children + .iter() + .filter_map(|n| n.appearance.items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f.paint.clone()) } else { None })) + .collect() +} + +fn near(a: Color, b: Color) -> bool { + match (a, b) { + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => [c - c2, m - m2, y - y2, k - k2].iter().all(|v| v.abs() <= Q), + (Color::Gray { k }, Color::Gray { k: k2 }) => (k - k2).abs() <= Q, + (Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => [l - l2, a - a2, b - b2].iter().all(|v| v.abs() <= 0.01), + (Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => [r - r2, g - g2, b - b2].iter().all(|v| v.abs() <= Q), + _ => false, + } +} + +fn solid(p: &Paint) -> (Color, Option<&str>, f32) { + match p { + Paint::Solid { color, swatch, tint } => (*color, swatch.as_deref(), *tint), + other => panic!("{other:?}"), + } +} + +fn spot(name: &str, c: Color) -> Swatch { + Swatch { name: name.into(), paint: Paint::solid(c), global: true, spot: true } +} + +#[test] +fn a_cmyk_document_with_spot_colours_round_trips() { + let mut d = Document::new_with_mode(200.0, 50.0, ColorMode::Cmyk); + d.swatches.push(spot("Ink A", Color::cmyk(0.1, 0.9, 0.2, 0.0))); + d.swatches.push(spot("Lab Ink", Color::lab(55.0, 60.0, 40.0))); + add(&mut d, Paint::solid(Color::cmyk(0.1, 0.2, 0.3, 0.4))); + add(&mut d, Paint::solid(Color::gray(0.25))); + for (name, tint) in [("Ink A", 0.4), ("Lab Ink", 0.6), (REGISTRATION, 0.5)] { + let p = d.tint_paint(name, tint).unwrap(); + add(&mut d, p); + } + let bytes = export(&d, &PdfOptions::default()).unwrap(); + let r = import_with_report(&bytes, &ImportOptions::default()).unwrap(); + let back = r.document; + assert_eq!(back.color_mode, ColorMode::Cmyk, "a CMYK file opens in CMYK"); + assert!(back.swatches.iter().filter_map(|s| s.paint.color()).any(|c| matches!(c, Color::Cmyk { .. })), "CMYK default swatches"); + let f = fills(&back); + assert_eq!(f.len(), 5); + assert!(near(solid(&f[0]).0, Color::cmyk(0.1, 0.2, 0.3, 0.4)), "{:?}", f[0]); + assert!(near(solid(&f[1]).0, Color::gray(0.25)), "{:?}", f[1]); + // Spot inks link to new spot swatches at their tint. + for (p, name, tint) in [(&f[2], "Ink A", 0.4), (&f[3], "Lab Ink", 0.6)] { + let (color, link, t) = solid(p); + assert_eq!(link, Some(name)); + assert!((t - tint).abs() <= Q, "{name}: tint {t}"); + let sw = back.swatch(name).unwrap(); + assert!(sw.spot && sw.global, "{name} is a spot swatch"); + assert!(near(color, sw.paint.color().unwrap().tinted(t)), "{name}: {color:?}"); + } + assert!(near(back.swatch("Ink A").unwrap().paint.color().unwrap(), Color::cmyk(0.1, 0.9, 0.2, 0.0))); + assert!(near(back.swatch("Lab Ink").unwrap().paint.color().unwrap(), Color::lab(55.0, 60.0, 40.0)), "a Lab alternate keeps Lab"); + // Registration (every plate) links to the built-in swatch, which isn't added. + let (_, link, t) = solid(&f[4]); + assert_eq!((link, (t * 100.0).round()), (Some(REGISTRATION), 50.0)); + assert_eq!(back.swatches.iter().filter(|s| s.spot).count(), 2); + assert!(r.warnings.iter().all(|w| !w.contains("inks")), "{:?}", r.warnings); +} + +#[test] +fn gray_imports_as_gray_and_rgb_documents_stay_rgb() { + let mut d = Document::new(100.0, 50.0); + add(&mut d, Paint::solid(Color::gray(0.8))); + add(&mut d, Paint::solid(Color::rgb(0.2, 0.4, 0.6))); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + assert_eq!(back.color_mode, ColorMode::Rgb); + let f = fills(&back); + assert!(near(solid(&f[0]).0, Color::gray(0.8)), "{:?}", f[0]); + assert!(near(solid(&f[1]).0, Color::rgb(0.2, 0.4, 0.6)), "{:?}", f[1]); + assert!(back.swatches.iter().all(|s| !s.spot)); +} + +#[test] +fn shading_stops_keep_cmyk_and_spot_tints() { + let mut d = Document::new_with_mode(100.0, 50.0, ColorMode::Cmyk); + d.swatches.push(spot("Ink A", Color::cmyk(0.0, 0.5, 1.0, 0.0))); + let grad = |stops: Vec| Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + add(&mut d, grad(vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 1.0, 0.2))])); + let ink = d.global_color("Ink A").unwrap(); + let linked = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink A".into()), tint, ..GradientStop::new(offset, ink.tinted(tint)) }; + add(&mut d, grad(vec![linked(0.0, 0.0), linked(1.0, 1.0)])); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let f = fills(&back); + let stops = |p: &Paint| match p { + Paint::Gradient(g) => g.gradient.stops.clone(), + other => panic!("{other:?}"), + }; + let s = stops(&f[0]); + assert!(near(s[0].color, Color::cmyk(1.0, 0.0, 0.0, 0.0)) && near(s.last().unwrap().color, Color::cmyk(0.0, 0.0, 1.0, 0.2)), "{s:?}"); + assert!(s.iter().all(|s| matches!(s.color, Color::Cmyk { .. }) && s.swatch.is_none())); + let s = stops(&f[1]); + assert!(s.iter().all(|s| s.swatch.as_deref() == Some("Ink A")), "{s:?}"); + assert!(s[0].tint.abs() <= Q && (s.last().unwrap().tint - 1.0).abs() <= Q, "{s:?}"); + assert!(near(s.last().unwrap().color, Color::cmyk(0.0, 0.5, 1.0, 0.0))); + assert!(back.swatch("Ink A").is_some_and(|w| w.spot)); +} + +/// A page painting with colour spaces `CS0`…: `fills` are `(space, components)`. +fn page_with(spaces: &str, fills: &[(&str, &str)]) -> PdfPage { + let content: String = fills.iter().enumerate().map(|(i, (cs, c))| format!("/{cs} cs {c} scn {} 0 10 10 re f\n", i * 12)).collect(); + PdfPage { resources: format!("/ColorSpace << {spaces} >>"), ..PdfPage::new(200.0, 50.0, &content) } +} + +#[test] +fn device_n_inks_become_spot_swatches_and_process_plates_cmyk() { + // Gold and Silver → CMYK (0, 0, gold, silver), through a calculator function. + let f = first_extra(1); + let func = "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 16 >>\nstream\n{ 0 0 4 2 roll }\nendstream"; + // Cyan and Magenta → CMYK (cyan, magenta, 0, 0). + let process = "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 9 >>\nstream\n{ 0 0 }\nendstream"; + let spaces = format!( + "/CS0 [/DeviceN [/Gold /Silver] /DeviceCMYK {f} 0 R] /CS1 [/DeviceN [/Cyan /Magenta] /DeviceCMYK {} 0 R] /CS2 [/Separation /Black /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>]", + f + 1 + ); + let page = page_with(&spaces, &[("CS0", "0.3 0"), ("CS0", "0 1"), ("CS0", "0.5 0.5"), ("CS1", "0.2 0.7"), ("CS2", "0.4"), ("CS0", "0 0")]); + let r = import_with_report(&pdf_with(&[page], &[func, process], None), &ImportOptions::default()).unwrap(); + let d = &r.document; + let f = fills(d); + assert_eq!(f.len(), 6); + assert_eq!(solid(&f[0]).1, Some("Gold")); + assert!((solid(&f[0]).2 - 0.3).abs() < 1e-6); + assert_eq!((solid(&f[1]).1, solid(&f[1]).2), (Some("Silver"), 1.0)); + assert!(near(d.swatch("Gold").unwrap().paint.color().unwrap(), Color::cmyk(0.0, 0.0, 1.0, 0.0))); + assert!(near(d.swatch("Silver").unwrap().paint.color().unwrap(), Color::cmyk(0.0, 0.0, 0.0, 1.0))); + // Two inks at once can't link to one swatch: RGB, with a warning. + assert!(matches!(solid(&f[2]), (Color::Rgb { .. }, None, _))); + assert!(r.warnings.iter().any(|w| w.contains("several inks")), "{:?}", r.warnings); + // Process plates are CMYK, also as a Separation. + assert!(near(solid(&f[3]).0, Color::cmyk(0.2, 0.7, 0.0, 0.0)), "{:?}", f[3]); + assert!(near(solid(&f[4]).0, Color::cmyk(0.0, 0.0, 0.0, 0.4)), "{:?}", f[4]); + // No ink painting: 0 % of the first. + assert_eq!((solid(&f[5]).1, solid(&f[5]).2), (Some("Gold"), 0.0)); + assert_eq!(d.swatches.iter().filter(|s| s.spot).count(), 2); +} + +#[test] +fn inks_that_cannot_be_told_apart_import_as_rgb() { + // Two spaces of other inks with the same alternate and tint transform. + let tint = "/DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>"; + let spaces = format!("/CS0 [/Separation /Tealish {tint}] /CS1 [/Separation /Rubyish {tint}] /CS2 [/Separation /Tealish {tint}]"); + let d = import(&pdf(&[page_with(&spaces, &[("CS0", "1")])], None)).unwrap(); + assert!(matches!(solid(&fills(&d)[0]), (Color::Rgb { .. }, None, _))); + // The same ink defined twice is still that ink. + let spaces = format!("/CS0 [/Separation /Tealish {tint}] /CS2 [/Separation /Tealish {tint}]"); + let d = import(&pdf(&[page_with(&spaces, &[("CS2", "0.5")])], None)).unwrap(); + assert_eq!(solid(&fills(&d)[0]).1, Some("Tealish")); +} + +#[test] +fn ink_names_never_clash_with_the_document_swatches() { + let taken = Document::new(1.0, 1.0).swatches.iter().find(|s| !s.paint.is_none() && s.name != REGISTRATION).unwrap().name.clone(); + let spaces = format!( + "/CS0 [/Separation /{} /DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>]", + taken.replace(' ', "#20").replace('=', "#3D") + ); + let d = import(&pdf(&[page_with(&spaces, &[("CS0", "1")])], None)).unwrap(); + let f = fills(&d); + let (color, link, _) = solid(&f[0]); + let renamed = format!("{taken} 2"); + assert_eq!(link, Some(renamed.as_str())); + assert!(near(color, Color::rgb(1.0, 0.0, 0.0))); + assert!(d.swatch(&renamed).unwrap().spot); +} + +#[test] +fn colour_spaces_are_read_from_the_interpreters_debug_form() { + let pdf = hayro_syntax::Pdf::new(pdf(&[PdfPage::new(10.0, 10.0, "")], None)).unwrap(); + let mut c = Colors::new(&pdf, vec![]); + for (text, n, want) in [ + ("ColorSpace(DeviceCmyk)", 4, Space::Cmyk), + ("ColorSpace(DeviceGray)", 1, Space::Gray), + ("ColorSpace(DeviceRgb)", 3, Space::Rgb), + ("ColorSpace(ICCBased(ICCColor {..}))", 4, Space::Cmyk), + ("ColorSpace(ICCBased(ICCColor {..}))", 1, Space::Gray), + ("ColorSpace(ICCBased(ICCColor {..}))", 3, Space::Other), + ("ColorSpace(Lab(Lab { range: [] }))", 3, Space::Other), + ("ColorSpace(Separation(Separation { tint_transform: Function(Type2(..)) }))", 1, Space::Other), + ("ColorSpace(DeviceCmyk)", 3, Space::Other), + ] { + assert_eq!(c.space_of(text, n), want, "{text}"); + } + let t = "Color { color_space: ColorSpace(DeviceCmyk), components: [0.101960786, 0.2, 0.3, 0.4], opacity: 1.0 }"; + let (space, comps) = crate::import_color::split_color(t).unwrap(); + assert_eq!((space, comps), ("ColorSpace(DeviceCmyk)", vec![0.101960786, 0.2, 0.3, 0.4])); + assert_eq!(crate::import_color::split_color("Color { color_space: X, components: [NaN], opacity: 1.0 }"), None); +} diff --git a/crates/testkit/src/pdf.rs b/crates/testkit/src/pdf.rs index 4c0d8d06..e4139823 100644 --- a/crates/testkit/src/pdf.rs +++ b/crates/testkit/src/pdf.rs @@ -52,6 +52,17 @@ fn rect(r: [f64; 4]) -> String { /// A PDF of `pages`; with a `password` it is encrypted and opens only with it (its owner password /// is the same followed by `-owner`). pub fn pdf(pages: &[PdfPage], password: Option<&str>) -> Vec { + pdf_with(pages, &[], password) +} + +/// The object number of the first `extra` object of [`pdf_with`] for a file of `pages` pages. +pub fn first_extra(pages: usize) -> usize { + 3 + 2 * pages +} + +/// [`pdf`] with `extra` objects (written as given, not encrypted: streams such as functions and +/// ICC profiles that resources refer to by number, from [`first_extra`] on). +pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Vec { let id = md5(b"vectorcraft test file"); // (O, U, file key) of the standard security handler, revision 2, permissions -4. let crypt = password.map(|pw| { @@ -96,6 +107,7 @@ pub fn pdf(pages: &[PdfPage], password: Option<&str>) -> Vec { s.extend_from_slice(b"\nendstream"); objects.push(s); } + objects.extend(extra.iter().map(|o| o.as_bytes().to_vec())); if let Some((o, u, _)) = &crypt { objects.push(format!("<< /Filter /Standard /V 1 /R 2 /O <{}> /U <{}> /P -4 >>", hex(o), hex(u)).into_bytes()); } diff --git a/docs/mcp.md b/docs/mcp.md index 08ccdf32..738ce472 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -188,7 +188,10 @@ options accepted but not applied yet, and features approximated or left out. PDF Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based), `cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets) and `password` for an encrypted file. `document.pdfInfo` reads a file without opening it: the page count, each page's -size and boxes, `needsPassword`, and with `thumbnail: n` a PNG of page n: +size and boxes, `needsPassword`, and with `thumbnail: n` a PNG of page n. Imported colours keep their model: +DeviceCMYK and CMYK ICC colours stay CMYK (a file painted mostly in CMYK opens as a CMYK document), DeviceGray is Gray, +and Separation and DeviceN inks become spot swatches the art links to at its tint (gradient stops too). +`colorMode: "rgb" | "cmyk"` opens any file in that mode instead, its colours converted as `file.documentColorMode` does: ```json {"name":"run_command","arguments":{"command":"document.pdfInfo","params":{"path":"/tmp/brochure.pdf","thumbnail":2,"cropTo":"trim"}}}