M4.46: PDF import keeps CMYK, Gray and spot colours
DeviceCMYK and CMYK ICC colours import as CMYK and a file painted mostly in CMYK opens as a CMYK document (with CMYK default swatches); DeviceGray imports as Gray; Separation and DeviceN inks become global spot swatches (their 100 % colour from the alternate space: CMYK, RGB, Gray or Lab) that the art links to at its tint, /All links to Registration and process colorants make CMYK. Shading stops keep their model and ink links the same way. Inks mixed in one DeviceN colour, or spaces that can't be told apart, import as RGB (with a warning for the mix). document.open takes colorMode (rgb | cmyk) to open any file in that mode, converted as file.documentColorMode does (its conversion is now shared). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e204341996
commit
8765d335ec
|
|
@ -456,6 +456,19 @@ fn color_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Value> {
|
|||
vectorcraft_color::Color::rgb(r, g, b)
|
||||
}
|
||||
};
|
||||
s.edit("Document Color Mode", |d, _| {
|
||||
d.color_mode = mode;
|
||||
if convert {
|
||||
let layers: Vec<NodeId> = 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<NodeId> = 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<Value> {
|
||||
|
|
|
|||
|
|
@ -115,6 +115,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
_ 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() {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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;
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<ColorMode>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<Paint> {
|
||||
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}");
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -273,6 +273,8 @@ fn arb_pdf_op() -> impl Strategy<Value = String> {
|
|||
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<Value = String> {
|
|||
"<< /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(),
|
||||
])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ImportRe
|
|||
doc.title = "Imported PDF".into();
|
||||
doc.artboards.clear();
|
||||
doc.layers.clear();
|
||||
let mut b = Builder::new(doc.peek_next_id());
|
||||
let taken = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect();
|
||||
let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken));
|
||||
let cache = InterpreterCache::new();
|
||||
let mut x = 0.0;
|
||||
for (i, &number) in picked.iter().enumerate() {
|
||||
|
|
@ -92,6 +94,15 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
for (k, blob) in b.images.drain() {
|
||||
doc.images.insert(k, blob);
|
||||
}
|
||||
// A file painted mostly in CMYK opens as a CMYK document (with CMYK default swatches).
|
||||
if b.colors.cmyk_document() {
|
||||
doc.color_mode = ColorMode::Cmyk;
|
||||
(doc.swatches, doc.swatch_groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
|
||||
}
|
||||
doc.swatches.extend(b.colors.swatches());
|
||||
if b.colors.mixed {
|
||||
b.warn("colours mixing several inks (DeviceN) were imported as RGB");
|
||||
}
|
||||
doc.fix_next_id();
|
||||
|
||||
let mut warnings = b.warnings;
|
||||
|
|
@ -135,7 +146,8 @@ struct GlyphRun {
|
|||
scale: f64,
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
struct Builder<'p> {
|
||||
colors: Colors<'p>,
|
||||
next: u64,
|
||||
stack: Vec<Frame>,
|
||||
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<GradientPaint> {
|
||||
/// 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<GradientPaint> {
|
||||
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<Gra
|
|||
}
|
||||
}
|
||||
keep.push(N);
|
||||
let stop_at = |t: f32, offset: f32| -> 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<u8> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<'a> Device<'a> for Builder {
|
||||
impl<'a> Device<'a> for Builder<'_> {
|
||||
fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
|
||||
if mask.is_some() {
|
||||
self.warn("soft masks are not imported (content drawn unmasked)");
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
colorants: Vec<Colorant>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
scanned: bool,
|
||||
spaces: Vec<InkSpace>,
|
||||
inks: Vec<Ink>,
|
||||
/// 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<String> {
|
||||
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<f32>)> {
|
||||
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::<f32>).collect::<Result<Vec<_>, _>>().ok()?;
|
||||
comps.iter().all(|c| c.is_finite()).then_some((space, comps))
|
||||
}
|
||||
|
||||
impl<'p> Colors<'p> {
|
||||
pub fn new(pdf: &'p Pdf, taken: Vec<String>) -> 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<Native> {
|
||||
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<Item = Swatch> + '_ {
|
||||
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<Native> {
|
||||
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<Native> {
|
||||
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::<Name<'_>>()?.as_str() {
|
||||
"Separation" => false,
|
||||
"DeviceN" => true,
|
||||
_ => return None,
|
||||
};
|
||||
let names: Vec<String> = if device_n {
|
||||
it.next::<Array<'_>>()?.iter::<Name<'_>>().map(|n| n.as_str().to_string()).collect()
|
||||
} else {
|
||||
vec![it.next::<Name<'_>>()?.as_str().to_string()]
|
||||
};
|
||||
let alt = alt_model(&it.next::<Object<'_>>()?);
|
||||
let tint = Function::new(&it.next::<Object<'_>>()?)?;
|
||||
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<Object<'_>>) -> Option<Object<'_>> {
|
||||
match m {
|
||||
MaybeRef::NotRef(o) => Some(o),
|
||||
MaybeRef::Ref(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn alt_model(o: &Object<'_>) -> Option<Alt> {
|
||||
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::<Name<'_>>()?.as_str() {
|
||||
"ICCBased" => match it.next::<Stream<'_>>()?.dict().get::<u8>(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<Color> {
|
||||
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))),
|
||||
})
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Paint> {
|
||||
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<GradientStop>| 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);
|
||||
}
|
||||
|
|
@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
|||
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue