diff --git a/ROADMAP.md b/ROADMAP.md index b9075e37..5018b128 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -187,7 +187,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 | | File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X and presets are done | 6–10 | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | -| Print, colour management, separations, flattener | 3 | 70% | Print Tiling tool and Show Print Tiling, flattener presets in print/PDF, print preview fidelity (Print dialog, presets, PostScript, marks and separations are done) | 5–8 | +| Print, colour management, separations, flattener | 3 | 78% | flattener presets in PDF export, print preview fidelity, `/OP` in composite print (Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 2–4 | | Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 | | UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 | | Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 | diff --git a/crates/doc/src/overprint.rs b/crates/doc/src/overprint.rs index 4f65e883..0097436a 100644 --- a/crates/doc/src/overprint.rs +++ b/crates/doc/src/overprint.rs @@ -149,6 +149,33 @@ impl Document { } } +/// Whether any fill or stroke of `n`'s subtree overprints, characters' too. +fn overprints_anywhere(n: &Node) -> bool { + n.appearance.items.iter().any(|i| i.overprint()) + || matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke)) + || n.children().is_some_and(|c| c.iter().any(|c| overprints_anywhere(c))) +} + +/// Stop every fill and stroke of `a`'s subtree overprinting (characters' too), copying only the +/// nodes on the way to them. +pub fn clear_overprints(a: &mut Arc) { + if !overprints_anywhere(a) { + return; + } + let n = Arc::make_mut(a); + for it in &mut n.appearance.items { + *it.overprint_mut() = false; + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + (r.style.overprint_fill, r.style.overprint_stroke) = (false, false); + } + } + for c in n.children_mut().into_iter().flatten() { + clear_overprints(c); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a9802602..875cd295 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -47,6 +47,7 @@ pub mod pdfcmds; mod place; pub mod prefscmds; pub mod print; +pub(crate) mod printadvanced; pub mod printpresets; pub mod printtiling; pub mod rasterfx; diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 9fd0150e..e9474aaa 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -10,7 +10,6 @@ use vectorcraft_pdf::{PrintOptions, PrintSettings}; use super::fileio::pdf::{merge, pdf_error}; use super::fileio::{extension, write_or_return}; -use super::flatten::{FlattenOptions, flatten_document}; use super::*; const SETUP: &str = "print.setup"; @@ -22,7 +21,7 @@ pub fn specs() -> Vec { "Print Setup", [], None, - "{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values in separations)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)", + "{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)", has_doc, setup ), @@ -90,6 +89,7 @@ fn preview(s: &mut Session, p: &Value) -> Result { const C: &str = "print.preview"; let doc = &s.doc()?.doc; let set = settings(C, doc, p)?; + super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?; let pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?; let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?; v["settings"] = to_json(&set)?; @@ -110,25 +110,25 @@ fn to_postscript(p: &Value) -> Result { fn print(s: &mut Session, p: &Value) -> Result { let postscript = to_postscript(p)?; - let flattener = if postscript { - let preset = str_param(p, "flattenerPreset").unwrap_or("medium"); - Some( - FlattenOptions::find_preset(preset, &s.prefs.flattener_presets) - .ok_or_else(|| bad(PRINT, format!("flattenerPreset `{preset}`: high, medium, low or a saved one (see flattener.presets.list)")))?, - ) - } else { - None - }; let doc = &s.doc()?.doc; - let set = settings(PRINT, doc, p)?; + let mut set = settings(PRINT, doc, p)?; + if postscript { + // PostScript has no transparency: `flattenerPreset`, else the Advanced preset, else medium. + let given = str_param(p, "flattenerPreset").filter(|n| !n.trim().is_empty()); + let preset = given.or(Some(set.advanced.flattener_preset.as_str()).filter(|n| !n.trim().is_empty())).unwrap_or("medium"); + set.advanced.flattener_preset = preset.to_string(); + } // Raster effects print as images at the document's raster effects resolution. let flat = super::rasterfx::flatten_raster_effects(doc); let doc = flat.as_ref().unwrap_or(doc); + // Print as Bitmap and the flattener preset (Advanced) render and flatten it first. + let prepared = super::printadvanced::prepare(PRINT, doc, &set, &s.prefs.flattener_presets)?; let path = str_param(p, "path"); - let Some(flattener) = flattener else { - let r = vectorcraft_pdf::print(doc, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?; + if !postscript { + let r = vectorcraft_pdf::print(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, ..Default::default() }) + .map_err(|e| pdf_error(PRINT, e))?; return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings })); - }; + } let level = match p.get("level").filter(|v| !v.is_null()) { Some(v) => { let t = super::fileio::dxf::text(v); @@ -136,14 +136,16 @@ fn print(s: &mut Session, p: &Value) -> Result { } None => Level::default(), }; - // PostScript has no transparency. - let flat = flatten_document(doc, &flattener)?; let mut warnings = vec![]; - if flat.is_some() && doc.layers.iter().any(|l| l.shows_transparency()) { + let bitmap = set.advanced.print_as_bitmap && set.output.mode == vectorcraft_pdf::OutputMode::Composite; + if !bitmap && doc.layers.iter().any(|l| l.shows_transparency()) { warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string()); } + if !set.color.profile.trim().is_empty() && set.output.mode == vectorcraft_pdf::OutputMode::Composite { + warnings.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string()); + } let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness); - let plan = vectorcraft_pdf::plan(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() }) + let plan = vectorcraft_pdf::plan(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() }) .map_err(|e| pdf_error(PRINT, e))?; let pages: Vec = plan .sheets diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs new file mode 100644 index 00000000..ef684b00 --- /dev/null +++ b/crates/engine/src/cmd/printadvanced.rs @@ -0,0 +1,107 @@ +//! File → Print › Advanced, what needs the renderer and the flattener (the PDF writer does the +//! rest): Print as Bitmap renders each printed region as one image, and a flattener preset +//! flattens transparency, both on the document as it prints (only the layers that print, +//! overprints discarded or simulated in composite output) before it is laid out on paper. + +use std::sync::Arc; + +use vectorcraft_doc::marks::outset; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, LayerColor, Node, NodeKind}; +use vectorcraft_geom::{Affine, Rect}; +use vectorcraft_pdf::{OutputMode, PrintOverprints, PrintSettings}; +use vectorcraft_render::{AntiAlias, RenderOptions}; + +use super::fileio::pdf::pdf_error; +use super::flatten::{FlattenOptions, FlattenerPreset, flatten_document}; +use super::menucmds::{MAX_PIXELS, unique_key}; +use super::*; + +/// The flattener options of `set`'s preset (`None`: transparency prints live); an error naming +/// `cmd` for a preset that is neither built in nor in `saved`. +pub(crate) fn flattener(cmd: &str, set: &PrintSettings, saved: &[FlattenerPreset]) -> Result> { + let name = set.advanced.flattener_preset.trim(); + if name.is_empty() { + return Ok(None); + } + let o = FlattenOptions::find_preset(name, saved) + .ok_or_else(|| bad(cmd, format!("flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list)")))?; + Ok(Some(o)) +} + +/// `doc` as Print as Bitmap and the flattener preset of `set` print it (from `saved` presets), or +/// `None` when neither applies. Print as Bitmap is for composite output. +pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[FlattenerPreset]) -> Result> { + let composite = set.output.mode == OutputMode::Composite; + let bitmap = set.advanced.print_as_bitmap && composite; + let flattener = flattener(cmd, set, saved)?; + if !bitmap && flattener.is_none() { + return Ok(None); + } + let printed = vectorcraft_pdf::printed_document(doc, set); + if bitmap { + return bitmap_document(cmd, &printed, set).map(Some); + } + let Some(mut o) = flattener else { return Ok(None) }; + // Composite overprints that were discarded or simulated aren't preserved either. + o.preserve_overprints &= !composite || set.advanced.overprints == PrintOverprints::Preserve; + Ok(Some(flatten_document(&printed, &o)?.unwrap_or_else(|| printed.into_owned()))) +} + +/// Leave guide paths out of `nodes` (the renderer draws them; they never print). +fn drop_guides(nodes: &mut Vec>) { + fn has_guides(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: true, .. }) || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c))) + } + nodes.retain(|n| !matches!(n.kind, NodeKind::Path { guide: true, .. })); + for n in nodes.iter_mut().filter(|n| has_guides(n)) { + if let Some(children) = Arc::make_mut(n).children_mut() { + drop_guides(children); + } + } +} + +/// `doc` (as it prints) with its art replaced by one image of each printed region (an artboard +/// with its bleed, or all the art), at the document's raster effects resolution; regions where +/// nothing is drawn get none. +fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings) -> Result { + let regions = vectorcraft_pdf::print_regions(doc, set).map_err(|e| pdf_error(cmd, e))?; + let bleed = set.bleed.of(doc); + let mut art = doc.clone(); + drop_guides(&mut art.layers); + let anti_alias = if doc.raster_effects.anti_alias { AntiAlias::Art } else { AntiAlias::None }; + let opts = RenderOptions { skip_templates: true, anti_alias, ..Default::default() }; + let mut out = doc.clone(); + let mut renderer = vectorcraft_render::Renderer::new(); + let mut images = vec![]; + for (_, r) in regions { + let area = outset(r, bleed); + // At most MAX_PIXELS, and 65535 pixels a side. + let side = area.width().max(area.height()).max(1.0); + let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(65_535.0 / side); + if !(scale.is_finite() && scale > 0.0) { + continue; + } + // Whole pixels. + let region = Rect::new( + area.x0, + area.y0, + area.x0 + (area.width() * scale).ceil().max(1.0) / scale, + area.y0 + (area.height() * scale).ceil().max(1.0) / scale, + ); + let img = renderer.render_region_with(&art, region, scale, &opts); + if img.pixels.as_chunks::<4>().0.iter().all(|px| px[3] == 0) { + continue; + } + let key = unique_key(&out, "print-bitmap"); + out.images.insert(key.clone(), ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?)); + let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); + let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }; + images.push(Arc::new(Node::new(out.alloc_id(), NodeKind::Image(image)))); + } + let mut layer = Node::layer(out.alloc_id(), "Bitmap", LayerColor::Preset(0)); + if let Some(children) = layer.children_mut() { + *children = images; + } + out.layers = vec![Arc::new(layer)]; + Ok(out) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1a84cf2d..9d95e3e5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1218,6 +1218,8 @@ mod tests_previewbounds; #[cfg(test)] mod tests_print; #[cfg(test)] +mod tests_printadvanced; +#[cfg(test)] mod tests_printpresets; #[cfg(test)] mod tests_printpreview; diff --git a/crates/engine/src/tests_printadvanced.rs b/crates/engine/src/tests_printadvanced.rs new file mode 100644 index 00000000..2030dbda --- /dev/null +++ b/crates/engine/src/tests_printadvanced.rs @@ -0,0 +1,131 @@ +//! File → Print › Advanced: simulated overprints print what Overprint Preview shows, Print as +//! Bitmap prints images of the art, and a flattener preset flattens transparency first. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, NodeKind}; +use vectorcraft_geom::Rect; +use vectorcraft_pdf::PrintSettings; +use vectorcraft_render::{RenderOptions, Renderer}; + +use super::*; + +/// A 200 × 100 CMYK document: a cyan square under a yellow one whose fill overprints, and a +/// half-transparent magenta one; their ids. +fn session() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "colorMode": "cmyk"})).unwrap(); + let mut ids = [0; 3]; + for (i, (x, color)) in [(10, "#00ffff"), (40, "#ffff00"), (120, "#ff00ff")].into_iter().enumerate() { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap(); + ids[i] = r["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [ids[i]], "color": color})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [ids[i]], "none": true})).unwrap(); + } + s.execute("object.setOverprint", &json!({"ids": [ids[1]], "fill": true})).unwrap(); + s.execute("transparency.set", &json!({"ids": [ids[2]], "opacity": 50, "item": null})).unwrap(); + (s, ids) +} + +fn settings(v: Value) -> PrintSettings { + serde_json::from_value(v).unwrap() +} + +fn doc(s: &Session) -> &Document { + &s.doc().unwrap().doc +} + +/// `doc` rendered over its artboard at 2 px/pt on white. +fn pixels(doc: &Document, opts: RenderOptions) -> Vec { + let opts = RenderOptions { background: Some([255; 4]), skip_templates: true, ..opts }; + Renderer::new().render_region_with(doc, Rect::new(0.0, 0.0, 200.0, 100.0), 2.0, &opts).pixels +} + +/// The pixel of the page `pdf` prints at document point `(x, y)` of the first sheet. +fn printed_pixel(s: &mut Session, settings: Value, (x, y): (f64, f64)) -> [u8; 4] { + let v = s.execute("file.print", &json!({ "settings": settings })).unwrap(); + let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + let pv = s.execute("print.preview", &json!({ "settings": settings })).unwrap(); + let t: Vec = pv["sheets"][0]["transform"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + let back = vectorcraft_pdf::import(&bytes).unwrap(); + let page = back.artboards[0].rect; + let (px, py) = (page.x0 + t[0] * x + t[2] * y + t[4], page.y0 + t[1] * x + t[3] * y + t[5]); + let img = Renderer::new().render_region(&back, Rect::new(px - 0.5, py - 0.5, px + 0.5, py + 0.5), 1.0, true); + img.pixels[..4].try_into().unwrap() +} + +fn near(a: [u8; 4], b: [u8; 4]) -> bool { + a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 4) +} + +#[test] +fn simulated_overprints_print_what_overprint_preview_shows() { + let (mut s, _) = session(); + let preview = pixels(doc(&s), RenderOptions { overprint_preview: true, ..Default::default() }); + let simulated = vectorcraft_pdf::printed_document(doc(&s), &settings(json!({"advanced": {"overprints": "simulate"}}))); + assert_eq!(pixels(&simulated, RenderOptions::default()), preview, "the same pixels as Overprint Preview"); + assert_ne!(pixels(doc(&s), RenderOptions::default()), preview, "overprinting shows"); + // On the printed page too: where yellow overprints cyan it prints green; discarded, yellow. + let at = (55.0, 30.0); + let i = ((30.0 * 2.0) as usize * 400 + (55.0 * 2.0) as usize) * 4; + let shown: [u8; 4] = preview[i..i + 4].try_into().unwrap(); + let sim = printed_pixel(&mut s, json!({"advanced": {"overprints": "simulate"}}), at); + assert!(near(sim, shown), "{sim:?} vs {shown:?}"); + let discarded = printed_pixel(&mut s, json!({"advanced": {"overprints": "discard"}}), at); + assert!(!near(discarded, shown) && discarded[2] < 64 && discarded[0] > 192, "yellow knocks out: {discarded:?}"); +} + +#[test] +fn a_flattener_preset_flattens_transparency_before_printing() { + let (mut s, _) = session(); + let saved = s.prefs.flattener_presets.clone(); + let transparent = |d: &Document| { + let mut any = false; + d.walk(|n| any |= n.opacity < 1.0); + any + }; + assert!(transparent(doc(&s))); + let none = super::cmd::printadvanced::prepare("file.print", doc(&s), &settings(json!({})), &saved).unwrap(); + assert!(none.is_none(), "transparency prints live without a preset"); + for name in ["High Resolution", "low"] { + let set = settings(json!({"advanced": {"flattenerPreset": name}})); + let flat = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().unwrap(); + assert!(!transparent(&flat), "{name}: flattened"); + } + // A saved preset by name; an unknown one is refused by preview and print alike. + s.execute("flattener.presets.save", &json!({"name": "Proofs", "preset": "low"})).unwrap(); + let ok = json!({"settings": {"advanced": {"flattenerPreset": "Proofs"}}}); + assert!(s.execute("print.preview", &ok).is_ok() && s.execute("file.print", &ok).is_ok()); + let bad = json!({"settings": {"advanced": {"flattenerPreset": "Nope"}}}); + assert!(s.execute("print.preview", &bad).is_err() && s.execute("file.print", &bad).is_err()); +} + +#[test] +fn print_as_bitmap_prints_an_image_of_each_artboard() { + let (mut s, _) = session(); + s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 100})).unwrap(); + let saved = s.prefs.flattener_presets.clone(); + let set = settings(json!({"advanced": {"printAsBitmap": true}})); + let bitmap = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().unwrap(); + let mut kinds = vec![]; + bitmap.walk(|n| kinds.push(matches!(n.kind, NodeKind::Image(_)))); + assert_eq!(kinds.iter().filter(|k| **k).count(), 1, "one image: the empty artboard gets none"); + // The image looks like the art, at the raster effects resolution. + assert_eq!(pixels(&bitmap, RenderOptions::default()).len(), pixels(doc(&s), RenderOptions::default()).len()); + let v = s.execute("file.print", &json!({"settings": {"advanced": {"printAsBitmap": true}, "skipBlank": true}})).unwrap(); + assert_eq!(v["pages"], 1, "the blank artboard is skipped"); + let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + let back = vectorcraft_pdf::import(&bytes).unwrap(); + let (mut images, mut paths) = (0, 0); + back.walk(|n| match n.kind { + NodeKind::Image(_) => images += 1, + NodeKind::Path { .. } => paths += 1, + _ => {} + }); + assert_eq!((images, paths), (1, 0), "no vector art is left"); + // Separations print the art as it is. + let seps = json!({"settings": {"advanced": {"printAsBitmap": true}, "output": {"mode": "separations"}}}); + let pv = s.execute("print.preview", &seps).unwrap(); + assert!(pv["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("Print as Bitmap"))); + let set = settings(seps["settings"].clone()); + assert!(super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().is_none()); +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 63e9ca09..cc985dd4 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -227,6 +227,8 @@ mod tests_presets; #[cfg(test)] mod tests_print; #[cfg(test)] +mod tests_printadvanced; +#[cfg(test)] mod tests_realtext; #[cfg(test)] mod tests_settings; diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index 3ffa3e48..9e325c68 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -12,8 +12,12 @@ //! - Output: composite, or separations, one page per ink that prints ([`print_inks`]) in the //! grey of its coverage (overprints honoured, spot colours as process if asked), emulsion down //! (mirrored), negative (inverted). -//! - Colour management: the rendering intent colours are separated with, and whether CMYK -//! colours keep their numbers. +//! - Colour management: the printer profile composite colours are converted to (and +//! separations separate with), the rendering intent, and whether CMYK colours keep their +//! numbers. +//! - Advanced: overprints in composite output preserved, discarded or simulated (as Overprint +//! Preview shows them). Print as Bitmap and the flattener preset need the renderer and the +//! flattener: the app applies them to the document before printing it. //! //! Halftone screens and flatness are left to the output device (warnings say so). [`preview`] //! lists the pages, tiles and inks without writing the file; [`plan`] lays the job out for other @@ -30,7 +34,9 @@ use std::sync::Arc; use kurbo::{Affine, Rect}; use serde::Serialize; +use vectorcraft_color::cms::{self, ProfileKind}; use vectorcraft_doc::marks::PrinterMarks; +use vectorcraft_doc::overprint::{clear_overprints, multiply_overprints}; use vectorcraft_doc::{ColorMode, Document, Node, NodeKind}; pub use layout::{MAX_TILES, TileGrid}; @@ -40,7 +46,7 @@ pub use tiling::{TilingPage, tiling}; use crate::export::{Exporter, Sheet, Writer}; use crate::marks::PageBoxes; -use crate::{CompressionSettings, PdfError, PdfSettings}; +use crate::{AdvancedSettings, ColorConversion, CompressionSettings, OutputSettings, Overprint, PdfError, PdfSettings}; use layout::Layout; use plates::Separator; @@ -150,24 +156,60 @@ pub fn keep_layers(nodes: &mut Vec>, which: PrintLayers) { } /// `doc` as `set` prints it: live effects applied (raster effects are rendered by the app first), -/// only the layers that print. -fn printed_doc<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> { +/// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`] +/// says. +pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> { let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned); - keep_layers(&mut doc.to_mut().layers, set.print_layers); + let d = doc.to_mut(); + keep_layers(&mut d.layers, set.print_layers); + composite_overprints(d, set); doc } +/// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated +/// (drawn with Multiply, as Overprint Preview shows them), after which nothing overprints any +/// more; or preserved. Separations always honour them. +fn composite_overprints(doc: &mut Document, set: &PrintSettings) { + let how = set.advanced.overprints; + if set.output.mode == OutputMode::Separations || how == PrintOverprints::Preserve { + return; + } + let discard_white = doc.setup.discard_white_overprint; + let symbols = doc.symbols.iter_mut().map(|s| &mut s.art); + let nodes = doc.layers.iter_mut().chain(symbols).chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())); + for n in nodes { + if how == PrintOverprints::Simulate { + multiply_overprints(n, discard_white); + } + clear_overprints(n); + } +} + +/// How composite output writes colours: converted to the printer profile (CMYK colours keep +/// their numbers with Preserve CMYK Numbers), overprints kept unless discarded or simulated. +fn composite_pdf(set: &PrintSettings) -> (OutputSettings, AdvancedSettings) { + let profile = set.color.profile.trim(); + let output = if profile.is_empty() { + OutputSettings::default() + } else { + let conversion = if set.color.preserve_numbers { ColorConversion::PreserveNumbers } else { ColorConversion::Destination }; + OutputSettings { conversion, destination: profile.to_string(), ..Default::default() } + }; + let overprint = if set.advanced.overprints == PrintOverprints::Preserve { Overprint::Preserve } else { Overprint::Discard }; + (output, AdvancedSettings { overprint, ..Default::default() }) +} + /// What of `doc` prints on pages of its own: each artboard printed (its rect), or all the art /// (`None`) with artboards ignored. pub fn print_regions(doc: &Document, set: &PrintSettings) -> Result, kurbo::Rect)>, PdfError> { set.check()?; - layout::regions(&printed_doc(doc, set), set) + layout::regions(&printed_document(doc, set), set) } impl<'a> Job<'a> { fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> Result { set.check()?; - let doc = printed_doc(doc, set); + let doc = printed_document(doc, set); let mut warnings = vec![]; let (layouts, tiles) = layout::layout(&doc, set, &mut warnings)?; let separations = set.output.mode == OutputMode::Separations; @@ -189,7 +231,15 @@ impl<'a> Job<'a> { warnings.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into()); } if !separations && doc.layers.iter().any(|l| l.has_overprint()) { - warnings.push("overprinting fills and strokes print as knockouts in composite output (separations honour them)".into()); + warnings.push( + "overprinting fills and strokes print as knockouts in composite output (separations honour them; Simulate Overprints prints them as Overprint Preview shows them)".into(), + ); + } + if separations && set.advanced.print_as_bitmap { + warnings.push("Print as Bitmap is for composite output: separations print the art as it is".into()); + } + if separations && cms::profile(set.color.profile.trim()).is_some_and(|p| p.kind != ProfileKind::Cmyk) { + warnings.push("separations separate with a CMYK profile: the RGB printer profile is left out".into()); } if !postscript && !set.graphics.auto_flatness { warnings.push("a fixed flatness is not written yet: the output device flattens curves".into()); @@ -347,8 +397,11 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, pub fn print(doc: &Document, opts: &PrintOptions) -> Result { let plan = plan(doc, opts)?; let doc = &*plan.doc; - let pdf = PdfSettings { compression: CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() }, ..Default::default() }; let separations = opts.settings.output.mode == OutputMode::Separations; + // Plates are greys already: only composite colours go to the printer profile. + let (output, advanced) = if separations { Default::default() } else { composite_pdf(&opts.settings) }; + let compression = CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() }; + let pdf = PdfSettings { compression, output, advanced, ..Default::default() }; let mut w = Writer::new(doc, &pdf, &plan.title, plan.created, !separations && doc.color_mode == ColorMode::Cmyk)?; // One exporter per ink (one for composite), each drawing its own document. let mut exporters: Vec = @@ -356,6 +409,8 @@ pub fn print(doc: &Document, opts: &PrintOptions) -> Result (Vec, Vec Arc { + let active = cms::active(); + match cms::profile(set.color.profile.trim()) { + Some(p) if p.kind == ProfileKind::Cmyk && cms::canonical_name(&active.settings().cmyk) != p.name => { + let settings = cms::ColorSettings { cmyk: p.name, ..active.settings().clone() }; + Cms::new(&settings).map_or(active, Arc::new) + } + _ => active, + } +} + /// Separates colours into inks as the job's colour options say. pub(crate) struct Separator<'a> { pub doc: &'a Document, @@ -51,7 +64,7 @@ pub(crate) struct Separator<'a> { impl<'a> Separator<'a> { pub fn new(doc: &'a Document, set: &'a PrintSettings) -> Self { - Self { doc, cms: vectorcraft_color::cms::active(), set } + Self { doc, cms: separation_cms(set), set } } /// How much of ink `plate` colour `c` (with swatch link `link`) prints, 0..1. diff --git a/crates/pdf/src/print/settings.rs b/crates/pdf/src/print/settings.rs index b50c2341..822ab8ba 100644 --- a/crates/pdf/src/print/settings.rs +++ b/crates/pdf/src/print/settings.rs @@ -3,7 +3,7 @@ //! separation ([`PrintInk`]). use serde::{Deserialize, Serialize}; -use vectorcraft_color::cms::Intent; +use vectorcraft_color::cms::{self, Intent, ProfileKind}; use crate::settings::{choice, within}; use crate::{BleedSettings, MarkSettings, PdfError}; @@ -216,6 +216,8 @@ pub struct PrintSettings { pub color: PrintColor, /// Where the Print Tiling tool put the pages. pub tile_origin: TileOrigin, + // Advanced. + pub advanced: PrintAdvanced, } impl Default for PrintSettings { @@ -247,6 +249,7 @@ impl Default for PrintSettings { graphics: PrintGraphics::default(), color: PrintColor::default(), tile_origin: TileOrigin::default(), + advanced: PrintAdvanced::default(), } } } @@ -362,14 +365,43 @@ pub struct PrintColor { pub intent: Intent, /// CMYK colours print with their own values; off, they go through the colour settings too. pub preserve_numbers: bool, + /// The printer profile (an RGB or CMYK profile by name): composite output converts colours to + /// it with the intent (CMYK colours keep their numbers with `preserve_numbers`), and + /// separations separate with it when it is a CMYK one. Empty: colours print as they are. + pub profile: String, } impl Default for PrintColor { fn default() -> Self { - Self { intent: Intent::RelativeColorimetric, preserve_numbers: true } + Self { intent: Intent::RelativeColorimetric, preserve_numbers: true, profile: String::new() } } } +choice! { + /// What composite output does with overprinting fills and strokes (separations always + /// honour them). + PrintOverprints { + Preserve = "preserve", "Preserve"; + /// They knock out. + Discard = "discard", "Discard"; + /// Composited as Overprint Preview shows them. + Simulate = "simulate", "Simulate"; + } default Preserve +} + +/// The Advanced section (with [`PrintSettings::margin`]). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintAdvanced { + /// Composite pages print as one image each of the art, at the document's raster effects + /// resolution (the app renders them before printing). + pub print_as_bitmap: bool, + pub overprints: PrintOverprints, + /// The transparency flattener preset transparency is flattened with (a built-in or saved + /// one, by name; the app flattens before printing). Empty: transparency prints live. + pub flattener_preset: String, +} + /// Where the Print Tiling tool put the pages (View → Show Print Tiling). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -407,6 +439,10 @@ impl PrintSettings { self.marks.check()?; self.bleed.check()?; within("graphics.flatness", self.graphics.flatness, 0.2, 100.0, "")?; + let profile = self.color.profile.trim(); + if !profile.is_empty() && cms::profile(profile).is_none_or(|p| p.kind == ProfileKind::Gray) { + return Err(PdfError::BadSetting(format!("color.profile: no RGB or CMYK profile is called “{profile}”"))); + } for ink in &self.output.inks { if let Some(f) = ink.frequency { within(&format!("output.inks[{}].frequency", ink.name), f, 1.0, 1000.0, " lpi")?; diff --git a/crates/pdf/src/tests_printadvanced.rs b/crates/pdf/src/tests_printadvanced.rs new file mode 100644 index 00000000..0ed20035 --- /dev/null +++ b/crates/pdf/src/tests_printadvanced.rs @@ -0,0 +1,144 @@ +//! File → Print › Advanced and Color Management: overprints preserved, discarded or simulated in +//! composite output, and colours converted to the printer profile with the rendering intent. + +use std::sync::Arc; + +use hayro_syntax::Pdf; +use serde_json::{Value, json}; +use vectorcraft_color::cms::{self, Cms, ColorSettings, DEVICE_CMYK, GENERIC_CMYK, Intent}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::*; + +/// A 200 × 100 document with a rectangle filled with each `(colour, overprints)`, overlapping +/// one another left to right, in paint order. +fn doc(fills: &[(Color, bool)]) -> Document { + let mut d = Document::new(200.0, 100.0); + let l = d.layers[0].id; + for (i, (c, over)) in fills.iter().enumerate() { + let x = 10.0 + 30.0 * i as f64; + let mut a = Appearance::basic(Paint::solid(*c), Paint::None, 0.0); + for it in &mut a.items { + *it.overprint_mut() = *over; + } + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 50.0, 60.0)), a); + d.insert(Some(l), i, n).unwrap(); + } + d +} + +/// Cyan under a yellow that overprints. +fn overprinted() -> Document { + doc(&[(Color::cmyk(1.0, 0.0, 0.0, 0.0), false), (Color::cmyk(0.0, 0.0, 1.0, 0.0), true)]) +} + +fn settings(v: Value) -> PrintSettings { + serde_json::from_value(v).unwrap() +} + +/// The job's bytes, content streams left readable. +fn job(d: &Document, v: Value) -> PrintReport { + let opts = PrintOptions { settings: settings(v), created: Some(1_791_200_000), uncompressed: true, ..Default::default() }; + print(d, &opts).unwrap() +} + +/// The fill colours page `page` sets: (operator, operands). +fn colours(pdf: &[u8], page: usize) -> Vec<(String, Vec)> { + let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap(); + let pages = file.pages(); + let content = String::from_utf8_lossy(pages.get(page).unwrap().page_stream().unwrap()).into_owned(); + let (mut nums, mut out) = (vec![], vec![]); + for t in content.split_whitespace() { + if let Ok(v) = t.parse::() { + nums.push(v); + continue; + } + if ["rg", "k", "g", "scn"].contains(&t) { + out.push((t.to_string(), nums.clone())); + } + nums.clear(); + } + out +} + +/// `v` as the writer stores it (8 bits a component). +fn q8(v: &[f32]) -> Vec { + v.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect() +} + +fn close(a: &[f32], b: &[f32]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3) +} + +/// Does any fill or stroke of `d` overprint? +fn overprints(d: &Document) -> bool { + d.layers.iter().any(|l| l.has_overprint()) +} + +#[test] +fn settings_saved_before_the_advanced_options_load_with_their_defaults() { + let s = settings(json!({"copies": 2, "color": {"intent": "perceptual"}})); + assert_eq!((s.copies, s.color.intent, s.color.preserve_numbers), (2, Intent::Perceptual, true)); + assert_eq!(s.advanced, PrintAdvanced::default()); + assert_eq!((s.advanced.print_as_bitmap, s.advanced.overprints, s.advanced.flattener_preset.as_str()), (false, PrintOverprints::Preserve, "")); + assert_eq!(s.color.profile, ""); + let v = serde_json::to_value(&s).unwrap(); + assert_eq!(v["advanced"], json!({"printAsBitmap": false, "overprints": "preserve", "flattenerPreset": ""})); + // A printer profile must be one there is. + assert!(settings(json!({"color": {"profile": GENERIC_CMYK}})).check().is_ok()); + assert!(matches!(settings(json!({"color": {"profile": "No Such Profile"}})).check(), Err(PdfError::BadSetting(_)))); +} + +#[test] +fn discarded_and_simulated_overprints_write_no_overprint() { + let d = overprinted(); + let preserve = job(&d, json!({})); + assert!(preserve.warnings.iter().any(|w| w.contains("knockouts")), "{:?}", preserve.warnings); + assert!(overprints(&printed_document(&d, &settings(json!({})))), "preserved"); + for (how, multiplies) in [("discard", false), ("simulate", true)] { + let set = json!({"advanced": {"overprints": how}}); + assert!(!overprints(&printed_document(&d, &settings(set.clone()))), "{how}: nothing overprints"); + let r = job(&d, set); + let text = String::from_utf8_lossy(&r.bytes); + assert!(!text.contains("/OP"), "{how}: no overprint in the file"); + assert_eq!(text.contains("/Multiply"), multiplies, "{how}"); + assert!(!r.warnings.iter().any(|w| w.contains("knockouts")), "{how}: {:?}", r.warnings); + } + // Separations honour overprints whatever the option says. + let seps = settings(json!({"output": {"mode": "separations"}, "advanced": {"overprints": "discard"}})); + assert!(overprints(&printed_document(&d, &seps))); +} + +#[test] +fn a_cmyk_printer_profile_writes_device_cmyk_with_the_intent() { + let d = doc(&[(Color::rgb(1.0, 0.0, 0.0), false)]); + assert_eq!(colours(&job(&d, json!({})).bytes, 0), [("rg".to_string(), vec![1.0, 0.0, 0.0])], "as it is without a profile"); + let to_cmyk = Cms::new(&ColorSettings { cmyk: GENERIC_CMYK.into(), ..cms::active_settings() }).unwrap(); + for intent in [Intent::Perceptual, Intent::Saturation] { + let c = colours(&job(&d, json!({"color": {"profile": GENERIC_CMYK, "intent": intent}})).bytes, 0); + let ink = q8(&to_cmyk.srgb_to_cmyk([1.0, 0.0, 0.0], intent)); + assert!(c.len() == 1 && c[0].0 == "k" && close(&c[0].1, &ink), "{intent:?}: {c:?} vs {ink:?}"); + } + // CMYK colours keep their numbers with Preserve CMYK Numbers. + let d = doc(&[(Color::cmyk(0.1, 0.2, 0.3, 0.4), false)]); + let c = colours(&job(&d, json!({"color": {"profile": DEVICE_CMYK, "preserveNumbers": true}})).bytes, 0); + assert!(c[0].0 == "k" && close(&c[0].1, &q8(&[0.1, 0.2, 0.3, 0.4])), "{c:?}"); +} + +#[test] +fn separations_separate_with_a_cmyk_printer_profile() { + // A mid grey: naive device CMYK puts it all on the black plate; a press profile doesn't. + let d = doc(&[(Color::rgb(0.5, 0.5, 0.5), false)]); + let cyan = |profile: &str| { + let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": profile}})); + colours(&r.bytes, 0) + }; + let device = cyan(DEVICE_CMYK); + let press = cyan(GENERIC_CMYK); + assert_ne!(device, press, "the profile separates"); + // An RGB printer profile doesn't separate. + let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": cms::SRGB}})); + assert!(r.warnings.iter().any(|w| w.contains("RGB printer profile")), "{:?}", r.warnings); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index dca6c0e4..a775978a 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -378,3 +378,6 @@ mod tests_print; #[cfg(test)] mod tests_print_presets; + +#[cfg(test)] +mod tests_print_advanced; diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 68020cfb..58ad7297 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -22,7 +22,8 @@ use serde_json::{Value, json}; use vectorcraft_color::cms::Intent; use vectorcraft_geom::Rect as DRect; use vectorcraft_pdf::{ - Choice, Emulsion, FontDownload, Media, Orientation, Origin, OutputMode, PrintArtboards, PrintImage, PrintLayers, PrintScaling, PrintSettings, + Choice, Emulsion, FontDownload, Media, Orientation, Origin, OutputMode, PrintArtboards, PrintImage, PrintLayers, PrintOverprints, PrintScaling, + PrintSettings, }; use super::save_pdf::{TOP_LABEL_WIDTH, choice, current_section, flag, get, heading, length, note, number, pick, row, row_with, set, text}; @@ -730,6 +731,10 @@ fn graphics(ui: &mut egui::Ui, d: &mut Dialog) { } fn color(ui: &mut egui::Ui, d: &mut Dialog) { + let profiles = vectorcraft_color::cms::profiles(); + let names: Vec<&str> = std::iter::once(SAME_AS_SOURCE).chain(profiles.iter().map(|p| p.name.as_str())).collect(); + row(ui, "Printer profile:", |ui| super::save_pdf::profile_pick(ui, d, "color.profile", &names, true)); + note(ui, "Composite colours are converted to it; separations separate with it when it is a CMYK profile."); row(ui, "Rendering intent:", |ui| { let current = serde_json::from_value::(get(d, "color.intent").clone()).unwrap_or_default(); let labels: Vec<&str> = Intent::ALL.iter().map(Intent::label).collect(); @@ -746,8 +751,27 @@ fn advanced(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { heading(ui, "Printer"); row(ui, "Unprintable margin:", |ui| length(ui, d, "margin", app.session.general_unit(), true)); note(ui, "The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed."); + let composite = choice::(d, "output.mode") != Some(OutputMode::Separations); + flag(ui, d, "advanced.printAsBitmap", "Print as Bitmap", composite); + note(ui, "Each page prints as one image, at the document's raster effects resolution."); + heading(ui, "Overprint and Transparency Flattener Options"); + row(ui, "Overprints:", |ui| { + ui.add_enabled_ui(composite, |ui| pick::(ui, d, "advanced.overprints", 160.0, |_| true)); + }); + let presets = app.session.flattener_presets(); + let names: Vec<&str> = std::iter::once(KEEP_TRANSPARENCY).chain(presets.iter().map(|p| p.name.as_str())).collect(); + row(ui, "Preset:", |ui| super::save_pdf::profile_pick(ui, d, "advanced.flattenerPreset", &names, true)); + note( + ui, + "Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it).", + ); } +/// The Printer Profile entry for no conversion. +const SAME_AS_SOURCE: &str = "Same As Source"; +/// The flattener Preset entry for transparency printed as it is. +const KEEP_TRANSPARENCY: &str = "None (keep transparency)"; + /// The section an option is set in, and whether its first key is the section's own object /// (`output.mode` → Output › Mode). pub(super) fn section_of(option: &str) -> (usize, bool) { @@ -757,6 +781,7 @@ pub(super) fn section_of(option: &str) -> (usize, bool) { "graphics" => (3, true), "color" => (4, true), "margin" => (5, false), + "advanced" => (5, true), _ => (0, false), } } @@ -788,6 +813,7 @@ pub(super) fn labelled(changed: &mut [Value]) { "output.emulsion" => label::(id), "output.image" => label::(id), "graphics.fonts" => label::(id), + "advanced.overprints" => label::(id), "color.intent" => serde_json::from_value::(c["value"].clone()).ok().map(|i| i.label()), _ => None, }; diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 617e3031..e4780456 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -552,9 +552,9 @@ pub(super) fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut }); } -/// A dropdown of the colour profiles (`names`, the first one standing for "" at `path`), showing -/// the profile at `path` (a name that isn't one of them too). -fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], enabled: bool) { +/// A dropdown of names such as colour profiles or flattener presets (`names`, the first one +/// standing for "" at `path`), showing the name at `path` (one that isn't among them too). +pub(super) fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], enabled: bool) { let blank = names.first().copied().unwrap_or_default(); let current = get(d, path).as_str().filter(|s| !s.is_empty()).unwrap_or(blank).to_string(); ui.add_enabled_ui(enabled, |ui| { diff --git a/crates/ui-egui/src/dialogs/tests_print_advanced.rs b/crates/ui-egui/src/dialogs/tests_print_advanced.rs new file mode 100644 index 00000000..0725fadd --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_print_advanced.rs @@ -0,0 +1,75 @@ +//! The Print dialog's Advanced and Color Management options and the Print Tiling tool's placement +//! in it: they draw, label, save with the document and move with the preview. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use super::print::KIND; +use crate::state::Dialog; +use crate::{VectorcraftApp, theme}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 120, "height": 90})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + app +} + +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn set(app: &mut VectorcraftApp, field: &str, value: Value) { + app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(field.into(), value); +} + +#[test] +fn advanced_options_draw_label_and_save_with_the_document() { + let mut app = app(); + app.run("print.tiling.set", json!({"origin": [-20, -10]})).unwrap(); + app.run("file.print", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["tileOrigin"]["placed"], true, "the tool's placement comes in"); + for section in ["General", "Color Management", "Advanced", "Summary"] { + for mode in ["composite", "separations"] { + set(&mut app, "__section", json!(section)); + set(&mut app, "output", json!({"mode": mode})); + set(&mut app, "advanced", json!({"printAsBitmap": true, "overprints": "simulate", "flattenerPreset": "High Resolution"})); + set(&mut app, "color", json!({"profile": vectorcraft_color::cms::GENERIC_CMYK})); + frame(&mut app); + assert!(app.ui.dialog.is_some(), "{section} ({mode}) closed the dialog"); + } + } + let mut changed = vec![json!({"option": "advanced.overprints", "value": "simulate"})]; + super::print::labelled(&mut changed); + assert_eq!(changed[0]["value"], "Simulate"); + assert_eq!(super::print::option_label("advanced.printAsBitmap"), "Advanced › Print As Bitmap"); + // Done keeps them with the document. + set(&mut app, "output", json!({"mode": "composite"})); + set(&mut app, "discard", json!(true)); + super::confirm(&mut app).unwrap(); + let saved = app.session.execute("print.setup", &json!({})).unwrap()["settings"].clone(); + assert_eq!(saved["advanced"], json!({"printAsBitmap": true, "overprints": "simulate", "flattenerPreset": "High Resolution"})); + assert_eq!(saved["color"]["profile"], vectorcraft_color::cms::GENERIC_CMYK); + assert_eq!(saved["tileOrigin"]["placed"], true); +} + +#[test] +fn dragging_the_preview_moves_the_print_tiling_tools_pages() { + let app = &mut app(); + for settings in [json!({}), json!({"transverse": true, "scaling": "custom", "scale": {"width": 50, "height": 200}})] { + let mut settings = settings; + settings["tileOrigin"] = json!({"placed": true, "x": -20, "y": -10}); + let before = app.session.execute("print.preview", &json!({ "settings": settings })).unwrap(); + let mut d = Dialog::new(KIND, settings.clone()); + super::print::move_placement(&mut d, &before["sheets"][0], (10.0, 5.0)); + let moved = super::print::settings(&d); + assert_eq!(moved["placement"], Value::Null, "the placement stays"); + let after = app.session.execute("print.preview", &json!({ "settings": moved })).unwrap(); + let (t0, t1) = (&before["sheets"][0]["trim"], &after["sheets"][0]["trim"]); + let delta = (t1[0].as_f64().unwrap() - t0[0].as_f64().unwrap(), t1[1].as_f64().unwrap() - t0[1].as_f64().unwrap()); + assert!((delta.0 - 10.0).abs() < 0.05 && (delta.1 - 5.0).abs() < 0.05, "{settings}: moved {delta:?}"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 45622692..eba00620 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1606,3 +1606,21 @@ the placement then shows as placed by the tool, with a Reset button; dragging th {"name":"run_command","arguments":{"command":"print.tiling.set","params":{"origin":[-36,-36]}}} {"name":"run_command","arguments":{"command":"print.tiling","params":{"settings":{"scaling":"tileImageable","scale":{"width":300,"height":300}}}}} ``` + +## Print advanced options + +The Print dialog's Advanced section and the printer profile are print settings too (`print.setup`, `print.preview`, +`file.print`): `advanced: {printAsBitmap, overprints: preserve|discard|simulate, flattenerPreset}` and `color: +{profile}`. In composite output, `discard` makes overprinting fills and strokes knock out and `simulate` prints them +as Overprint Preview shows them (multiplied); either way the file has no overprint left. Separations always keep +overprints. `flattenerPreset` (High Resolution, Medium Resolution, Low Resolution or a saved preset, +`flattener.presets.list`) flattens transparency before printing, as Object → Flatten Transparency does; empty, a +PDF keeps it live (PostScript, which has none, uses `file.print`'s `flattenerPreset`, else this one, else medium). +`printAsBitmap` prints each composite page as one image of the art at the document's raster effects resolution. +`color.profile` names an RGB or CMYK profile: composite PDF colours are converted to it with `color.intent` (CMYK +colours keep their numbers with `preserveNumbers`), so a CMYK profile writes device CMYK; separations separate with a +CMYK one. Settings saved before these options load with their defaults. + +```json +{"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/proof.pdf","settings":{"advanced":{"overprints":"simulate","flattenerPreset":"High Resolution"},"color":{"profile":"VectorCraft Generic CMYK (SWOP-like)","intent":"perceptual"}}}}} +```