M4.81: Print advanced options: overprints, flattener, printer profile, bitmap

The pdf crate's PrintSettings gains advanced {printAsBitmap,
overprints: preserve|discard|simulate, flattenerPreset} and
color.profile, all defaulting so saved print_setup values still load.
Composite output discards overprints (no /OP) or simulates them as
Overprint Preview draws them; separations keep them. A printer profile
converts composite colours with the chosen intent (a CMYK one writes
device CMYK) and separations separate with a CMYK one. The engine
flattens transparency with the preset, or renders each printed region
as one image for Print as Bitmap, before PDF or PostScript layout.
The Print dialog's Advanced and Color Management sections show them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 17:21:14 +07:00
parent 1b9b37454b
commit 1f922fcbbc
17 changed files with 680 additions and 38 deletions

View File

@ -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 |

View File

@ -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<Node>) {
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::*;

View File

@ -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;

View File

@ -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<CommandSpec> {
"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<Value> {
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<bool> {
fn print(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
}
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<PrintPage> = plan
.sheets

View File

@ -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<Option<FlattenOptions>> {
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<Option<Document>> {
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<Arc<Node>>) {
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<Document> {
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)
}

View File

@ -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;

View File

@ -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<u8> {
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<f64> = 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());
}

View File

@ -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;

View File

@ -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<Arc<Node>>, 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<Vec<(Option<usize>, 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<Self, PdfError> {
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<PrintPlan<'a>,
pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
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<Exporter> =
@ -356,6 +409,8 @@ pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfErro
for ex in &mut exporters {
ex.non_printing = true;
ex.intent = opts.settings.color.intent;
// The writer's colour output: the printer profile's conversion.
ex.out = w.out.clone();
}
for &i in &plan.order {
let Some(s) = plan.sheets.get(i) else { continue };

View File

@ -4,7 +4,7 @@
use std::sync::Arc;
use vectorcraft_color::Color;
use vectorcraft_color::cms::{Cms, Model};
use vectorcraft_color::cms::{self, Cms, Model, ProfileKind};
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors};
use vectorcraft_doc::overprint::multiply_overprints;
@ -42,6 +42,19 @@ pub fn print_inks(doc: &Document, set: &PrintSettings) -> (Vec<PrintInk>, Vec<St
(list, warnings)
}
/// The colour settings separations convert with: the active ones, with the printer profile as
/// their CMYK profile when it is a CMYK one.
fn separation_cms(set: &PrintSettings) -> Arc<Cms> {
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.

View File

@ -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")?;

View File

@ -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<f32>)> {
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::<f32>() {
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<f32> {
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);
}

View File

@ -378,3 +378,6 @@ mod tests_print;
#[cfg(test)]
mod tests_print_presets;
#[cfg(test)]
mod tests_print_advanced;

View File

@ -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::<Intent>(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::<OutputMode>(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::<PrintOverprints>(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::<Emulsion>(id),
"output.image" => label::<PrintImage>(id),
"graphics.fonts" => label::<FontDownload>(id),
"advanced.overprints" => label::<PrintOverprints>(id),
"color.intent" => serde_json::from_value::<Intent>(c["value"].clone()).ok().map(|i| i.label()),
_ => None,
};

View File

@ -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| {

View File

@ -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:?}");
}
}

View File

@ -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"}}}}}
```