Part of #394: Appearance of Black › Printing / Exporting draws pure CMYK black as RGB black

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Fabio William Conceição 2026-10-13 15:06:58 +01:00 committed by bflatastic
parent b5bc8f0d19
commit b6d14b1fd0
12 changed files with 116 additions and 31 deletions

View File

@ -175,6 +175,8 @@ struct RasterOptions {
layers: Option<bool>,
max_editability: Option<bool>,
hidden_layers: Option<bool>,
/// Appearance of Black › Printing / Exporting (`prefs.blackOutput`); agents can set it here too.
rich_black: Option<bool>,
}
impl RasterOptions {
@ -239,6 +241,7 @@ impl RasterOptions {
hidden_layers: self.hidden_layers.unwrap_or(false),
embed_icc,
},
rich_black: self.rich_black.unwrap_or(false),
})
}
}

View File

@ -11,14 +11,17 @@ use crate::DocState;
use super::super::*;
use super::encode::single_artboard;
use super::{ARTBOARD_PARAMS, Format, default_name, encode, encode_all, encode_with_warnings, merge, writable, write_encoded, write_or_return};
use super::{
ARTBOARD_PARAMS, Format, default_name, encode, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded,
write_or_return,
};
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
let expanded = expand_presets(s, "document.serialize", p)?;
let p = &*expanded;
let doc = &*source(s, f, p, "document.serialize")?;
let enc = encode_all(doc, f.id, p)?;
let p = with_black_output(s, &expanded);
let doc = &*source(s, f, &p, "document.serialize")?;
let enc = encode_all(doc, f.id, &p)?;
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
let (main, linked) = files.split_at(enc.files.len());
// Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes.
@ -50,9 +53,9 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
let f = writable("document.export", str_param(p, "format"), path)?;
let expanded = expand_presets(s, "document.export", p)?;
let p = &*expanded;
let doc = &*source(s, f, p, "document.export")?;
let enc = encode_all(doc, f.id, p)?;
let p = with_black_output(s, &expanded);
let doc = &*source(s, f, &p, "document.export")?;
let enc = encode_all(doc, f.id, &p)?;
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
}
@ -185,11 +188,11 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path");
let f = writable(C, str_param(p, "format"), path)?;
let expanded = expand_presets(s, C, p)?;
let p = &*expanded;
let p = with_black_output(s, &expanded);
let (d, bounds) = selection(s, C)?;
// The encoder's warnings (options accepted but not applied, features approximated or left
// out), as document.export reports them.
let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(p))?;
let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings, "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
}
@ -211,7 +214,8 @@ pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("no artboard {artboard} (0-based; the document has {})", doc.artboards.len())));
}
let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" };
let bytes = encode(doc, "png", &json!({ "ppi": ppi, "background": background, "artboard": artboard }))?;
let opts = with_black_output(s, &json!({ "ppi": ppi, "background": background, "artboard": artboard }));
let bytes = encode(doc, "png", &opts)?;
// The pixel size, from the PNG header.
let side = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map_or(0, u32::from_be_bytes);
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20) }))

View File

@ -1052,6 +1052,12 @@ pub(crate) fn merge(mut a: Value, b: Value) -> Value {
a
}
/// Appearance of Black › Printing / Exporting (#394): Output All Blacks as Rich Black makes RGB
/// raster exports draw pure CMYK black as RGB black (`richBlack` on the encoder params).
pub(crate) fn with_black_output(s: &Session, p: &Value) -> Value {
if s.prefs.black_output == "rich" { merge(p.clone(), json!({ "richBlack": true })) } else { p.clone() }
}
/// A file name for bytes handed back without a path: the document's title with `ext`.
pub(crate) fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String {
let stem = std::path::Path::new(&doc.title).file_stem().map(|s| s.to_string_lossy().into_owned()).filter(|s| !s.is_empty());

View File

@ -15,7 +15,9 @@ use vectorcraft_geom::Rect;
use super::super::*;
use super::encode::single_artboard;
use super::export::{isolated, without_artboards};
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, writable, write_file};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
};
const C: &str = "document.exportForScreens";
@ -363,6 +365,8 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
if subfolders { format!("{}/{file}", r.folder) } else { file }
};
let mut out = Output::default();
// Appearance of Black › Printing / Exporting for RGB raster rows.
let write = |doc: &Document, format: &str, options: &Value| encode(doc, format, &with_black_output(s, options));
if o.full_document {
let stem = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
// The bounds of all visible art, as the one artboard of the files that aren't PDFs.
@ -376,7 +380,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
// One page per artboard.
let mut options = r.options.clone();
options["range"] = json!("all");
encode(&doc, "pdf", &options)?
write(&doc, "pdf", &options)?
} else {
if whole.is_none() {
let bounds =
@ -385,7 +389,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
let Some(d) = whole.as_ref() else { continue };
let region = d.artboards.first().map_or(Rect::ZERO, |a| a.rect);
encode(d, r.format.id, &r.options_for(region, Some(0))?)?
write(d, r.format.id, &r.options_for(region, Some(0))?)?
};
out.put(folder, o.zip, name, bytes)?;
}
@ -403,7 +407,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
if !out.claim(&name) {
continue;
}
let bytes = encode(&d, r.format.id, &r.options_for(bounds, Some(0))?)?;
let bytes = write(&d, r.format.id, &r.options_for(bounds, Some(0))?)?;
out.put(folder, o.zip, name, bytes)?;
}
}
@ -420,7 +424,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
if !out.claim(&name) {
continue; // the same file from two identical rows
}
let bytes = encode(&doc, r.format.id, &r.options_for(region, Some(b))?)?;
let bytes = write(&doc, r.format.id, &r.options_for(region, Some(b))?)?;
out.put(folder, o.zip, name, bytes)?;
}
}

View File

@ -106,6 +106,36 @@ fn screens_and_rasterize_take_anti_aliasing() {
assert!(s.execute("object.rasterize", &json!({"antiAlias": "fuzzy"})).is_err());
}
/// Appearance of Black › Printing / Exporting (#394): Output All Blacks as Rich Black makes an
/// RGB PNG of a CMYK document draw pure black ink as RGB black; Accurately (the default) shows it
/// through the CMYK profile. `blackOnScreen` does not change the export; a rich black is the same
/// either way.
#[test]
fn black_output_rich_draws_pure_cmyk_black_as_rgb_black_in_png() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 20, "height": 20, "colorMode": "cmyk"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
let centre = |s: &mut Session| {
let png = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "background": "white"})).unwrap()));
png.get_pixel(10, 10).0
};
let accurate = centre(&mut s);
let shown = vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgba8(1.0);
assert!(accurate.iter().zip(shown).all(|(a, b)| a.abs_diff(b) <= 1), "accurate: {accurate:?}");
assert!(accurate[0] > 10, "accurate: dark grey, not RGB black");
s.execute("prefs.set", &json!({"key": "blackOnScreen", "value": "rich"})).unwrap();
assert_eq!(centre(&mut s), accurate, "blackOnScreen does not change exports");
s.execute("prefs.set", &json!({"key": "blackOutput", "value": "rich"})).unwrap();
assert_eq!(centre(&mut s), [0, 0, 0, 255], "rich: RGB black");
s.execute("select.all", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 0.6, "m": 0.4, "y": 0.4, "k": 1}})).unwrap();
let rich = centre(&mut s);
s.execute("prefs.set", &json!({"key": "blackOutput", "value": "accurate"})).unwrap();
assert_eq!(centre(&mut s), rich, "a rich black shows the same either way");
}
#[test]
fn formats_list_the_raster_options() {
let mut s = Session::new();

View File

@ -128,7 +128,7 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
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 prepared = super::printadvanced::prepare(PRINT, doc, &set, &s.prefs.flattener_presets, s.prefs.black_output == "rich")?;
let path = str_param(p, "path");
if !postscript {
let r = vectorcraft_pdf::print(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, ..Default::default() })

View File

@ -30,7 +30,8 @@ pub(crate) fn flattener(cmd: &str, set: &PrintSettings, saved: &[FlattenerPreset
/// `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>> {
/// `rich_black`: Appearance of Black › Printing / Exporting › Output All Blacks as Rich Black.
pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[FlattenerPreset], rich_black: bool) -> Result<Option<Document>> {
let composite = set.output.mode == OutputMode::Composite;
let bitmap = set.advanced.print_as_bitmap && composite;
let flattener = flattener(cmd, set, saved)?;
@ -39,7 +40,7 @@ pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[F
}
let printed = vectorcraft_pdf::printed_document(doc, set);
if bitmap {
return bitmap_document(cmd, &printed, set).map(Some);
return bitmap_document(cmd, &printed, set, rich_black).map(Some);
}
let Some(mut o) = flattener else { return Ok(None) };
// Composite overprints that were discarded or simulated aren't preserved either.
@ -63,13 +64,13 @@ fn drop_guides(nodes: &mut Vec<Arc<Node>>) {
/// `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> {
fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings, rich_black: bool) -> 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 opts = RenderOptions { skip_templates: true, anti_alias, rich_black, ..Default::default() };
let mut out = doc.clone();
let mut renderer = vectorcraft_render::Renderer::new();
let mut images = vec![];

View File

@ -84,11 +84,11 @@ fn a_flattener_preset_flattens_transparency_before_printing() {
any
};
assert!(transparent(doc(&s)));
let none = super::cmd::printadvanced::prepare("file.print", doc(&s), &settings(json!({})), &saved).unwrap();
let none = super::cmd::printadvanced::prepare("file.print", doc(&s), &settings(json!({})), &saved, false).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();
let flat = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap();
assert!(!transparent(&flat), "{name}: flattened");
}
// A saved preset by name; an unknown one is refused by preview and print alike.
@ -105,7 +105,7 @@ fn print_as_bitmap_prints_an_image_of_each_artboard() {
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 bitmap = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).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");
@ -127,5 +127,33 @@ fn print_as_bitmap_prints_an_image_of_each_artboard() {
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());
assert!(super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().is_none());
}
/// Appearance of Black › Printing / Exporting (#394): Print as Bitmap draws pure CMYK black as RGB
/// black when `rich_black` is on.
#[test]
fn print_as_bitmap_honours_rich_black() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 20, "height": 20, "colorMode": "cmyk"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
let set = settings(json!({"advanced": {"printAsBitmap": true}}));
let saved = s.prefs.flattener_presets.clone();
let centre = |prepared: &Document| {
let mut key = None;
prepared.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
key = Some(im.key.clone());
}
});
let bytes = &prepared.images[key.as_ref().expect("bitmap image")].bytes;
let img = image::load_from_memory(bytes).unwrap().to_rgba8();
img.get_pixel(img.width() / 2, img.height() / 2).0
};
let accurate = centre(&super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap());
assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}");
let rich = centre(&super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, true).unwrap().unwrap());
assert_eq!(rich, [0, 0, 0, 255], "rich: RGB black");
}

View File

@ -60,6 +60,9 @@ pub struct RasterExportOptions {
pub tga: tga::TgaOptions,
/// PSD colour model, layers and profile.
pub psd: psd::PsdOptions,
/// Appearance of Black › Printing / Exporting › Output All Blacks as Rich Black: pure CMYK
/// black (K alone at 100%) becomes RGB black in RGB raster exports (document values unchanged).
pub rich_black: bool,
}
impl Default for RasterExportOptions {
@ -76,6 +79,7 @@ impl Default for RasterExportOptions {
bmp: bmp::BmpOptions::default(),
tga: tga::TgaOptions::default(),
psd: psd::PsdOptions::default(),
rich_black: false,
}
}
}
@ -99,6 +103,7 @@ impl RasterExportOptions {
precise: true,
// Shapes meeting edge to edge leave no seam (#983).
supersample: true,
rich_black: self.rich_black,
..Default::default()
}
}

File diff suppressed because one or more lines are too long

View File

@ -1802,7 +1802,11 @@ default: View › Actual Size / `view.actualSize` is one document point per scre
96 screen points, an inch at the system's reference density whatever the display scaling). `blackOnScreen`
(Appearance of Black › On Screen, `accurate` by default) sets how the canvas shows pure CMYK black, black ink alone
at 100%: `accurate` shows it through the CMYK profile, a dark grey, and `rich` shows it as RGB black. Rich blacks,
black tints and images show the same either way, and exports are unchanged. `antiAliasedBitmaps` (off by default;
black tints and images show the same either way, and exports are unchanged. `blackOutput` (Appearance of Black ›
Printing / Exporting, `accurate` by default) does the same for RGB raster exports (`document.export`,
`document.serialize`, `document.exportSelection`, `document.exportForScreens`, `document.exportForOffice`) and for
Print as Bitmap: `rich` draws pure CMYK black as RGB black in the pixels; CMYK ink exports and the document's
colours stay as they are. Agents can also pass `richBlack: true` on those raster options. `antiAliasedBitmaps` (off by default;
desktop app): in View › Pixel Preview, on softens placed and embedded bitmaps; off, their pixels stay hard-edged.
`disableAutoAddDelete` (off by default): on, the Pen no longer adds or deletes an anchor when a click lands on a
segment or an anchor of a selected path.

File diff suppressed because one or more lines are too long