From dd32c874bf6ff23febbae9d4b96e2eb7c8e599ad Mon Sep 17 00:00:00 2001 From: SEOHAN JUNG Date: Tue, 22 Sep 2026 22:11:12 +0900 Subject: [PATCH 1/3] feat: --transparent keeps unpainted page areas transparent in PNG output Rendering already starts from a transparent backdrop and composites onto white paper as its last step. With --transparent (or SkiaDevice::set_transparent_background / render_to_rgba_with_background) that step converts premultiplied pixels to straight alpha instead, so placed artwork keeps its unpainted areas clear. The full-page path starts from a transparent pixmap in that mode, matching the banded path. render_to_rgba and render_to_rgba_with_layers keep their signatures and white paper; the viewport audit path is unchanged. --- README.md | 1 + crates/stet-cli/README.md | 2 + crates/stet-cli/src/main.rs | 30 +++++- crates/stet-render/src/lib.rs | 1 + crates/stet-render/src/skia_device.rs | 140 ++++++++++++++++++++++++-- 5 files changed, 165 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6fd959c..da27b50 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ zoom presets, minimap navigation, and drag-and-drop. | `--threads ` | Worker-thread count (default: 75 % of cores in viewer mode, 8 otherwise) | | `--no-icc` | Disable ICC color management entirely | | `--no-aa` | Disable anti-aliasing | +| `--transparent` | Leave unpainted areas transparent instead of white paper (`--device png` only; straight-alpha RGBA) | | `--output-profile ` | Generic ICC output profile (also used as source CMYK when `--cmyk-profile` is absent) | | `--cmyk-profile ` | Pin the source CMYK ICC profile for CMYK→sRGB conversion | | `--use-output-intent` | Honour the PDF's embedded OutputIntent as the source CMYK profile (default) | diff --git a/crates/stet-cli/README.md b/crates/stet-cli/README.md index 01047b8..9b3bc76 100644 --- a/crates/stet-cli/README.md +++ b/crates/stet-cli/README.md @@ -135,6 +135,8 @@ Common options: viewer mode, 8 otherwise) --password Password for encrypted PDF input --no-aa Disable anti-aliasing + --transparent Leave unpainted areas transparent instead of + white paper (--device png only) Resource limits (for untrusted input): --timeout Abort a job running longer than this. No limit diff --git a/crates/stet-cli/src/main.rs b/crates/stet-cli/src/main.rs index 8404695..30c367a 100644 --- a/crates/stet-cli/src/main.rs +++ b/crates/stet-cli/src/main.rs @@ -105,6 +105,7 @@ fn main() { let mut device_name: Option = None; let mut no_icc = false; let mut no_aa = false; + let mut transparent = false; let mut output_profile_path: Option = None; let mut cmyk_profile_path: Option = None; let mut bpc_mode = BpcMode::Auto; @@ -206,6 +207,11 @@ fn main() { i += 1; continue; } + "--transparent" => { + transparent = true; + i += 1; + continue; + } "--output-profile" => { if i + 1 < args.len() { output_profile_path = Some(args[i + 1].clone()); @@ -496,6 +502,14 @@ writes all pages to one file", std::process::exit(1); } + if transparent && device != "png" { + eprintln!( + "Error: --transparent is only supported for --device png (got '{}')", + device + ); + std::process::exit(1); + } + match device.as_str() { "png" => { run_png_mode( @@ -503,6 +517,7 @@ writes all pages to one file", file_args, &icc_cfg, no_aa, + transparent, page_filter, false, password.as_deref(), @@ -523,6 +538,7 @@ writes all pages to one file", file_args, &icc_cfg, no_aa, + false, page_filter, true, password.as_deref(), @@ -618,6 +634,7 @@ fn run_png_mode( file_args: Vec, icc_cfg: &IccCliConfig, no_aa: bool, + transparent: bool, page_filter: Option>, use_viewport: bool, password: Option<&str>, @@ -636,6 +653,7 @@ fn run_png_mode( &file_args, &page_filter, no_aa, + transparent, use_viewport, icc_cfg, password, @@ -665,6 +683,7 @@ fn run_png_mode( dev.set_system_cmyk_bytes(bytes.clone()); } dev.set_no_aa(no_aa); + dev.set_transparent_background(transparent); dev.set_use_viewport_path(use_viewport); Box::new(dev) })); @@ -1218,6 +1237,9 @@ Common options: 75% of cores in viewer mode and 8 otherwise, where sequential PNG writing limits the benefit of more. --no-aa Disable anti-aliasing. + --transparent Leave unpainted areas transparent instead of + white paper (--device png only). Pixels are + written as straight-alpha RGBA. --password Password for encrypted PDF input. Colour management: @@ -2276,11 +2298,13 @@ fn compute_fit_dims( (out_w, out_h, dpi) } +#[expect(clippy::too_many_arguments)] fn render_pdf_page_to_rgba( doc: &PdfDocument, page: usize, dpi: f64, no_aa: bool, + transparent: bool, use_viewport: bool, target_width: Option, target_height: Option, @@ -2307,13 +2331,15 @@ fn render_pdf_page_to_rgba( no_aa, ) } else { - stet_render::render_to_rgba( + stet_render::render_to_rgba_with_background( &display_list, pixel_w, pixel_h, effective_dpi, Some(doc.icc_cache()), no_aa, + &stet_graphics::layer_set::LayerSet::new(), + transparent, ) }; Ok((rgba, pixel_w, pixel_h)) @@ -2325,6 +2351,7 @@ fn run_pdf_input_png( file_args: &[String], page_filter: &Option>, no_aa: bool, + transparent: bool, use_viewport: bool, icc_cfg: &IccCliConfig, password: Option<&str>, @@ -2421,6 +2448,7 @@ were selected from '{}'", page, dpi, no_aa, + transparent, use_viewport, target_width, target_height, diff --git a/crates/stet-render/src/lib.rs b/crates/stet-render/src/lib.rs index ae5bc16..c59edf1 100644 --- a/crates/stet-render/src/lib.rs +++ b/crates/stet-render/src/lib.rs @@ -50,5 +50,6 @@ pub use skia_device::render_region_prepared_parallel_with_progress; pub use skia_device::render_region_single_band; pub use skia_device::render_to_rgba; pub use skia_device::render_to_rgba_viewport; +pub use skia_device::render_to_rgba_with_background; pub use skia_device::render_to_rgba_with_layers; pub use skia_device::viewport_band_count; diff --git a/crates/stet-render/src/skia_device.rs b/crates/stet-render/src/skia_device.rs index 69b195b..2251307 100644 --- a/crates/stet-render/src/skia_device.rs +++ b/crates/stet-render/src/skia_device.rs @@ -123,6 +123,9 @@ pub struct SkiaDevice { /// its `default_visible`); a consumer building a layer panel can /// install an explicit set via `set_layer_set`. layer_set: LayerSet, + /// Leave unpainted areas transparent instead of compositing the page onto + /// white paper. Output pixels are then straight (non-premultiplied) RGBA. + transparent_background: bool, } #[cfg(feature = "ps-device")] @@ -183,6 +186,7 @@ impl SkiaDevice { no_aa: false, use_viewport_path: false, layer_set: LayerSet::new(), + transparent_background: false, } } @@ -228,7 +232,11 @@ impl SkiaDevice { return; }; self.pixmap = pixmap; - self.pixmap.fill(Color::WHITE); + self.pixmap.fill(if self.transparent_background { + Color::TRANSPARENT + } else { + Color::WHITE + }); } } @@ -246,6 +254,13 @@ impl SkiaDevice { pub fn set_no_aa(&mut self, no_aa: bool) { self.no_aa = no_aa; } + + /// Leave unpainted areas transparent instead of white paper, emitting + /// straight-alpha RGBA. Applies to the banded and full-page paths; the + /// viewport audit path (`set_use_viewport_path`) always composites. + pub fn set_transparent_background(&mut self, on: bool) { + self.transparent_background = on; + } } /// Convert a PostScript `Matrix` to tiny-skia `Transform` (f32). @@ -1247,6 +1262,33 @@ fn composite_onto_white(data: &mut [u8]) { } } +/// Convert premultiplied-alpha RGBA pixels to straight alpha, the form PNG +/// and most RGBA consumers expect. Fully transparent pixels become (0,0,0,0). +fn unpremultiply(data: &mut [u8]) { + for pixel in data.as_chunks_mut::<4>().0 { + let a = pixel[3] as u32; + match a { + 255 => {} + 0 => pixel[..3].fill(0), + _ => { + for channel in &mut pixel[..3] { + *channel = ((*channel as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + } +} + +/// Last step before rendered pixels leave the renderer: composite onto white +/// paper, or keep the page transparent and convert to straight alpha. +fn finish_page_pixels(data: &mut [u8], transparent_background: bool) { + if transparent_background { + unpremultiply(data); + } else { + composite_onto_white(data); + } +} + /// Extract the contribution of a non-isolated transparency group and composite /// it onto the parent using the group's blend mode and alpha. /// @@ -6620,8 +6662,8 @@ impl OutputDevice for SkiaDevice { fn show_page(&mut self, output_path: &str) -> Result<(), String> { let w = self.pixmap.width(); let h = self.pixmap.height(); - // Composite onto white background before output - composite_onto_white(self.pixmap.data_mut()); + // Composite onto white background (or keep it transparent) before output + finish_page_pixels(self.pixmap.data_mut(), self.transparent_background); let mut sink = self.sink_factory.create_sink(output_path)?; sink.begin_page(w, h)?; sink.write_rows(self.pixmap.data(), h)?; @@ -6900,10 +6942,20 @@ impl OutputDevice for SkiaDevice { // interpretation of the next page. Using rayon::spawn avoids OS thread // creation overhead and keeps work on the warmed-up pool. let no_aa = self.no_aa; + let transparent_background = self.transparent_background; let (tx, rx) = std::sync::mpsc::sync_channel(1); rayon::spawn(move || { let result = render_banded_to_sink( - page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, no_aa, &layer_set, + page_w, + page_h, + band_h, + dpi, + &list, + &mut *sink, + &icc_cache, + no_aa, + transparent_background, + &layer_set, ); let _ = tx.send(result); }); @@ -6912,7 +6964,16 @@ impl OutputDevice for SkiaDevice { #[cfg(not(feature = "parallel"))] { render_banded_to_sink( - page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, self.no_aa, &layer_set, + page_w, + page_h, + band_h, + dpi, + &list, + &mut *sink, + &icc_cache, + self.no_aa, + self.transparent_background, + &layer_set, )?; } @@ -9261,6 +9322,7 @@ fn render_banded_to_sink( sink: &mut dyn stet_graphics::device::PageSink, icc_cache: &IccCache, no_aa: bool, + transparent_background: bool, layer_set: &LayerSet, ) -> Result<(), String> { // Precompute Y bounding boxes for culling @@ -9387,8 +9449,9 @@ fn render_banded_to_sink( } } - // Composite content onto white background (premultiplied alpha) - composite_onto_white(band_pixmap.data_mut()); + // Composite content onto white background (premultiplied alpha), or + // keep it transparent + finish_page_pixels(band_pixmap.data_mut(), transparent_background); // Extract only the actual band rows (skip overlap) let start_byte = band_offset as usize * row_bytes; @@ -10899,6 +10962,25 @@ pub fn render_to_rgba_with_layers( icc: Option<&IccCache>, no_aa: bool, layer_set: &LayerSet, +) -> Vec { + render_to_rgba_with_background(list, pixel_w, pixel_h, dpi, icc, no_aa, layer_set, false) +} + +/// Like [`render_to_rgba_with_layers`] but can leave the page transparent. +/// +/// With `transparent_background` set, unpainted areas stay at alpha 0 instead +/// of being composited onto white paper, and the returned pixels are straight +/// (non-premultiplied) RGBA — for artwork that is placed over other content. +#[expect(clippy::too_many_arguments)] +pub fn render_to_rgba_with_background( + list: &DisplayList, + pixel_w: u32, + pixel_h: u32, + dpi: f64, + icc: Option<&IccCache>, + no_aa: bool, + layer_set: &LayerSet, + transparent_background: bool, ) -> Vec { if pixel_w == 0 || pixel_h == 0 { return vec![0xFF; pixel_w as usize * pixel_h as usize * 4]; @@ -10919,7 +11001,16 @@ pub fn render_to_rgba_with_layers( let band_h = select_band_height(pixel_w, pixel_h); if let Err(e) = render_banded_to_sink( - pixel_w, pixel_h, band_h, dpi, list, &mut sink, &icc_cache, no_aa, layer_set, + pixel_w, + pixel_h, + band_h, + dpi, + list, + &mut sink, + &icc_cache, + no_aa, + transparent_background, + layer_set, ) { eprintln!("render_to_rgba: banded render failed: {e}"); return vec![0xFF; pixel_w as usize * pixel_h as usize * 4]; @@ -13668,6 +13759,39 @@ mod tests { } } + #[test] + fn test_render_to_rgba_transparent_background_keeps_unpainted_area_clear() { + let mut list = DisplayList::new(); + list.push(make_test_fill_at(0.0, 0.0, 10.0, 20.0)); // left half of a 20×20 page + let pixel = |data: &[u8], x: usize, y: usize| { + let i = (y * 20 + x) * 4; + [data[i], data[i + 1], data[i + 2], data[i + 3]] + }; + + let paper = render_to_rgba(&list, 20, 20, 72.0, None, false); + assert_eq!(pixel(&paper, 15, 10), [255, 255, 255, 255]); + + let clear = render_to_rgba_with_background( + &list, + 20, + 20, + 72.0, + None, + false, + &LayerSet::new(), + true, + ); + assert_eq!(pixel(&clear, 15, 10), [0, 0, 0, 0]); + assert_eq!(pixel(&clear, 5, 10), [0, 0, 0, 255]); + } + + #[test] + fn test_unpremultiply_restores_straight_alpha() { + let mut data = [100u8, 50, 0, 128, 10, 20, 30, 0, 1, 2, 3, 255]; + unpremultiply(&mut data); + assert_eq!(data, [199, 100, 0, 128, 0, 0, 0, 0, 1, 2, 3, 255]); + } + #[test] fn test_compute_paint_bounds_two_fills() { let mut list = DisplayList::new(); From dadf71fadce7ba077b57373af9c3c2ac29f5eddd Mon Sep 17 00:00:00 2001 From: SEOHAN JUNG Date: Tue, 22 Sep 2026 22:11:40 +0900 Subject: [PATCH 2/3] docs: changelog entry for --transparent --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a60e02..f339562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`--transparent` for `--device png`, and `render_to_rgba_with_background` + in `stet-render`.** Pages are rendered onto a transparent backdrop and + composited onto white paper only as the last step; the option skips that + step and writes straight-alpha RGBA instead, so artwork (EPS, AI, PDF) can + be placed over other content with its unpainted areas clear. Both the + PostScript and PDF input paths honour it. `render_to_rgba` and + `render_to_rgba_with_layers` keep their signatures and white paper. + ### Removed - **`stet-wasm`: the `set_page_callback()` / `clear_page_callback()` JS From 660a0cf675f3d7040ce1473356a5ad831dca72ca Mon Sep 17 00:00:00 2001 From: SEOHAN JUNG Date: Wed, 23 Sep 2026 09:57:27 +0900 Subject: [PATCH 3/3] feat: --cmyk-intent selects the source profile's perceptual A2B table The CLUT bake samples A2B1, matching lcms2's RelCol. For a print profile that is markedly lighter in the blacks than A2B0, which is what lcms2, Ghostscript and ImageMagick use by default: with Japan Color 2001 Coated, K100 comes out (51,45,43) instead of (35,25,22), and placed artwork ends up lighter than the same black drawn as text by another tool. --cmyk-intent perceptual (IccCacheOptions::cmyk_source_table) samples A2B0 instead. Checked against ImageMagick + lcms2 with the same profile: K100 and K50 match exactly, and a 60-patch CMYK sweep agrees to within 1 RGB level (mean 0.3). The default stays relative, and a profile with no perceptual table falls back to the colorimetric one. --- CHANGELOG.md | 9 +++++ README.md | 1 + crates/stet-cli/README.md | 3 ++ crates/stet-cli/src/main.rs | 35 +++++++++++++++- crates/stet-graphics/examples/icc_probe.rs | 1 + crates/stet-graphics/src/icc.rs | 46 +++++++++++++++++++++- crates/stet-graphics/src/icc/perceptual.rs | 26 +++++++++++- 7 files changed, 117 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f339562..fda897e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 be placed over other content with its unpainted areas clear. Both the PostScript and PDF input paths honour it. `render_to_rgba` and `render_to_rgba_with_layers` keep their signatures and white paper. +- **`--cmyk-intent perceptual|relative`, and `IccCacheOptions::cmyk_source_table`.** + The CLUT bake samples the source CMYK profile's `A2B1` (relative) table, + which for a print profile is a good deal lighter in the blacks than the + `A2B0` (perceptual) table that lcms2, Ghostscript and ImageMagick use by + default: Japan Color 2001 Coated renders K100 as (51,45,43) rather than + (35,25,22). The option selects the perceptual table, which reproduces + lcms2's default to within 1 RGB level over a 60-patch CMYK sweep. The + default is unchanged (relative), and a profile without a perceptual table + falls back to the colorimetric one. ### Removed diff --git a/README.md b/README.md index da27b50..56b4bd9 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ zoom presets, minimap navigation, and drag-and-drop. | `--use-output-intent` | Honour the PDF's embedded OutputIntent as the source CMYK profile (default) | | `--no-output-intent` | Ignore the PDF's embedded OutputIntent and use the system CMYK profile | | `--bpc ` | Black-point compensation (default: `auto`, currently equivalent to `on`) | +| `--cmyk-intent ` | Which table of the source CMYK profile drives CMYK conversion (default: `relative`). A print profile's perceptual table carries a darker black — what lcms2, Ghostscript and ImageMagick use by default | | `--password ` | Password for encrypted PDF input | | `--timeout ` | Abort a job running longer than this. No limit by default — PostScript is Turing-complete and legitimate jobs run for minutes. Set one for untrusted input | | `--max-vm ` | Ceiling on PostScript VM — strings, arrays, dictionaries (default 8192). Exceeding it raises `VMerror` instead of aborting. Separate from the renderer's image and band buffers, so it does **not** cap rendering resolution | diff --git a/crates/stet-cli/README.md b/crates/stet-cli/README.md index 9b3bc76..532437d 100644 --- a/crates/stet-cli/README.md +++ b/crates/stet-cli/README.md @@ -160,6 +160,9 @@ Colour management: --no-output-intent Ignore it and use the system CMYK profile --bpc Black-point compensation (default: auto, currently equivalent to on) + --cmyk-intent + Which table of the source CMYK profile drives + CMYK conversion (default: relative) ``` `stet --help` prints the same list; `scripts/check-cli-docs.sh` in the diff --git a/crates/stet-cli/src/main.rs b/crates/stet-cli/src/main.rs index 30c367a..ba2a0f3 100644 --- a/crates/stet-cli/src/main.rs +++ b/crates/stet-cli/src/main.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use stet_core::context::Context; use stet_core::eps::{content_is_epsf, read_eps_bounding_box, strip_dos_eps_header}; use stet_engine::eval::{parse_and_exec, parse_and_exec_file}; -use stet_graphics::icc::{BpcMode, IccCacheOptions}; +use stet_graphics::icc::{BpcMode, CmykSourceTable, IccCacheOptions}; use stet_ops::build_system_dict; use stet_pdf::PdfDevice; use stet_pdf_reader::PdfDocument; @@ -25,6 +25,7 @@ struct IccCliConfig { output_profile_path: Option, cmyk_profile_path: Option, bpc_mode: BpcMode, + cmyk_source_table: CmykSourceTable, /// When true, prefer the PDF's embedded `/OutputIntents[].DestOutputProfile` /// over the system-default CMYK profile (unless `--cmyk-profile` is also /// set, which always wins). Off by default because it changes the sRGB @@ -110,6 +111,7 @@ fn main() { let mut cmyk_profile_path: Option = None; let mut bpc_mode = BpcMode::Auto; let mut bpc_explicit = false; + let mut cmyk_source_table = CmykSourceTable::default(); // Default: honour the PDF's declared OutputIntent as the CMYK→sRGB // source profile. Matches Acrobat's behaviour for PDF/X files and // eliminates profile-approximation artefacts on GWG swatches (e.g. @@ -232,6 +234,26 @@ fn main() { std::process::exit(1); } } + "--cmyk-intent" => { + if i + 1 < args.len() { + cmyk_source_table = match args[i + 1].as_str() { + "perceptual" => CmykSourceTable::Perceptual, + "relative" => CmykSourceTable::Colorimetric, + other => { + eprintln!( + "Error: --cmyk-intent must be one of: perceptual, relative (got '{}')", + other + ); + std::process::exit(1); + } + }; + i += 2; + continue; + } else { + eprintln!("Error: --cmyk-intent requires a value (perceptual|relative)"); + std::process::exit(1); + } + } "--bpc" => { if i + 1 < args.len() { bpc_mode = match args[i + 1].as_str() { @@ -408,6 +430,7 @@ run stet once per file", output_profile_path, cmyk_profile_path, bpc_mode, + cmyk_source_table, use_output_intent, }; @@ -1254,6 +1277,12 @@ Colour management: --no-output-intent Ignore the PDF's OutputIntent and fall back to the system CMYK profile. --bpc Black-point compensation mode (default auto). + --cmyk-intent + Which table of the source CMYK profile drives + CMYK conversion (default relative). A print + profile's perceptual table carries a darker + black, and is what lcms2, Ghostscript and + ImageMagick use by default. Subcommands: inspect Print a structural summary of a PDF @@ -1339,6 +1368,7 @@ fn build_icc_cache(icc_cfg: &IccCliConfig) -> stet_graphics::icc::IccCache { if icc_cfg.no_icc { return IccCache::new_with_options(IccCacheOptions { bpc_mode: BpcMode::Off, + cmyk_source_table: icc_cfg.cmyk_source_table, source_cmyk_profile: None, }); } @@ -1352,6 +1382,7 @@ fn build_icc_cache(icc_cfg: &IccCliConfig) -> stet_graphics::icc::IccCache { eprintln!("[ICC] Loaded source CMYK profile: {}", path); return IccCache::new_with_options(IccCacheOptions { bpc_mode: icc_cfg.bpc_mode, + cmyk_source_table: icc_cfg.cmyk_source_table, source_cmyk_profile: Some(bytes), }); } @@ -1368,12 +1399,14 @@ fn build_icc_cache(icc_cfg: &IccCliConfig) -> stet_graphics::icc::IccCache { eprintln!("[ICC] Loaded output profile: {}", path); return IccCache::new_with_options(IccCacheOptions { bpc_mode: icc_cfg.bpc_mode, + cmyk_source_table: icc_cfg.cmyk_source_table, source_cmyk_profile: Some(bytes), }); } let mut cache = IccCache::new_with_options(IccCacheOptions { bpc_mode: icc_cfg.bpc_mode, + cmyk_source_table: icc_cfg.cmyk_source_table, source_cmyk_profile: None, }); cache.search_system_cmyk_profile(); diff --git a/crates/stet-graphics/examples/icc_probe.rs b/crates/stet-graphics/examples/icc_probe.rs index 044de23..4aa422a 100644 --- a/crates/stet-graphics/examples/icc_probe.rs +++ b/crates/stet-graphics/examples/icc_probe.rs @@ -88,6 +88,7 @@ fn main() { let opts = IccCacheOptions { bpc_mode: mode, source_cmyk_profile: Some(bytes.clone()), + ..Default::default() }; let cache = IccCache::new_with_options(opts); for &(c, m, y, k) in cases { diff --git a/crates/stet-graphics/src/icc.rs b/crates/stet-graphics/src/icc.rs index ddcc60d..629de4c 100644 --- a/crates/stet-graphics/src/icc.rs +++ b/crates/stet-graphics/src/icc.rs @@ -11,6 +11,8 @@ pub mod bpc; mod perceptual; +pub use perceptual::CmykSourceTable; + use bpc::{ BpcParams, apply_bpc_f64, apply_bpc_rgb_u8, compute_bpc_params, detect_source_black_point, }; @@ -92,6 +94,8 @@ pub struct IccCacheOptions { /// for invoking [`IccCache::search_system_cmyk_profile`] (or providing /// bytes some other way). pub source_cmyk_profile: Option>, + /// Which A2B table of the source CMYK profile drives CMYK→sRGB. + pub cmyk_source_table: CmykSourceTable, } /// Identity Gray→RGB transform: maps each gray value to equal R=G=B. @@ -296,6 +300,8 @@ pub struct IccCache { /// construction time via [`IccCacheOptions`]; consulted by future BPC /// apply paths (commit 2 of `docs/PLAN-BPC.md`). bpc_mode: BpcMode, + /// Which A2B table of the source CMYK profile the CLUT bake samples. + cmyk_source_table: CmykSourceTable, /// Enable PDF/X-style proofing: ICCBased source profiles convert through /// the default CMYK profile (the document's OutputIntent) before going /// to sRGB, so all source colour spaces converge through the same @@ -358,6 +364,7 @@ impl IccCache { srgb_profile: ColorProfile::new_srgb(), reverse_cmyk_f64: None, bpc_mode: opts.bpc_mode, + cmyk_source_table: opts.cmyk_source_table, proofing_enabled: false, lab_to_oi_per_intent: [None, None, None, None], }; @@ -373,6 +380,12 @@ impl IccCache { self.bpc_mode } + /// Which A2B table of the source CMYK profile drives CMYK→sRGB. + #[inline] + pub fn cmyk_source_table(&self) -> CmykSourceTable { + self.cmyk_source_table + } + /// Compute the SHA-256 hash of an ICC profile without registering it. pub fn hash_profile(bytes: &[u8]) -> ProfileHash { use sha2::{Digest, Sha256}; @@ -734,7 +747,13 @@ impl IccCache { let clut4 = if n == 4 && !chain_active { // Direct (non-proofing) CMYK profiles: pre-bake a CLUT for fast // image conversion. - let c = perceptual::bake_clut4_perceptual(&profile, 17, bpc_enabled).or_else(|| { + let c = perceptual::bake_clut4_perceptual( + &profile, + 17, + bpc_enabled, + self.cmyk_source_table, + ) + .or_else(|| { let params = if bpc_enabled { detect_source_black_point(transform_8bit.as_ref()) .map(|sbp| compute_bpc_params(sbp, [0.0; 3], bpc::WP_D50)) @@ -1884,10 +1903,26 @@ mod tests { assert!(cache.default_cmyk_hash.is_none()); } + #[test] + fn test_icc_cache_options_cmyk_source_table() { + // Colorimetric (A2B1) stays the default: the perceptual table changes + // every CMYK pixel, so it is opt-in. + assert_eq!( + IccCache::new().cmyk_source_table(), + CmykSourceTable::Colorimetric + ); + let cache = IccCache::new_with_options(IccCacheOptions { + cmyk_source_table: CmykSourceTable::Perceptual, + ..Default::default() + }); + assert_eq!(cache.cmyk_source_table(), CmykSourceTable::Perceptual); + } + #[test] fn test_icc_cache_options_bpc_off() { let cache = IccCache::new_with_options(IccCacheOptions { bpc_mode: BpcMode::Off, + cmyk_source_table: CmykSourceTable::default(), source_cmyk_profile: None, }); assert_eq!(cache.bpc_mode(), BpcMode::Off); @@ -1902,6 +1937,7 @@ mod tests { }; let cache = IccCache::new_with_options(IccCacheOptions { bpc_mode: BpcMode::On, + cmyk_source_table: CmykSourceTable::default(), source_cmyk_profile: Some(cmyk_bytes.clone()), }); assert!(cache.default_cmyk_hash().is_some()); @@ -1919,6 +1955,7 @@ mod tests { // — the profile's as-mapped black projected through moxcms's sRGB B2A. let mut off = IccCache::new_with_options(IccCacheOptions { bpc_mode: BpcMode::Off, + cmyk_source_table: CmykSourceTable::default(), source_cmyk_profile: Some(cmyk_bytes.clone()), }); let off_rgb = off.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap(); @@ -1930,6 +1967,7 @@ mod tests { // visibly darker than the no-BPC baseline by a substantial margin." let mut on = IccCache::new_with_options(IccCacheOptions { bpc_mode: BpcMode::On, + cmyk_source_table: CmykSourceTable::default(), source_cmyk_profile: Some(cmyk_bytes), }); let on_rgb = on.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap(); @@ -1968,6 +2006,7 @@ mod tests { }; let mut cache = IccCache::new_with_options(IccCacheOptions { bpc_mode: BpcMode::On, + cmyk_source_table: CmykSourceTable::default(), source_cmyk_profile: Some(cmyk_bytes), }); // CMYK white (no ink) must still render as sRGB white under BPC. @@ -1987,10 +2026,12 @@ mod tests { // the per-color path behaviour. let off = IccCache::new_with_options(IccCacheOptions { bpc_mode: BpcMode::Off, + cmyk_source_table: CmykSourceTable::default(), source_cmyk_profile: Some(cmyk_bytes.clone()), }); let on = IccCache::new_with_options(IccCacheOptions { bpc_mode: BpcMode::On, + cmyk_source_table: CmykSourceTable::default(), source_cmyk_profile: Some(cmyk_bytes), }); let off_hash = *off.default_cmyk_hash().unwrap(); @@ -2035,6 +2076,7 @@ mod tests { }; let mut cache = IccCache::new_with_options(IccCacheOptions { bpc_mode: BpcMode::Off, + cmyk_source_table: CmykSourceTable::default(), source_cmyk_profile: Some(cmyk_bytes), }); let hash = *cache.default_cmyk_hash().unwrap(); @@ -2141,6 +2183,7 @@ mod tests { }; let mut cache = IccCache::new_with_options(IccCacheOptions { bpc_mode: BpcMode::Off, + cmyk_source_table: CmykSourceTable::default(), source_cmyk_profile: Some(cmyk_bytes), }); let rgb = cache.convert_cmyk(0.0, 0.0, 0.0, 0.0).unwrap(); @@ -2162,6 +2205,7 @@ mod tests { }; let mut cache = IccCache::new_with_options(IccCacheOptions { bpc_mode: BpcMode::On, + cmyk_source_table: CmykSourceTable::default(), source_cmyk_profile: Some(cmyk_bytes), }); let hash = *cache.default_cmyk_hash().unwrap(); diff --git a/crates/stet-graphics/src/icc/perceptual.rs b/crates/stet-graphics/src/icc/perceptual.rs index efad9bd..5f660ae 100644 --- a/crates/stet-graphics/src/icc/perceptual.rs +++ b/crates/stet-graphics/src/icc/perceptual.rs @@ -41,7 +41,21 @@ use super::bpc::{WP_D50, apply_bpc_rgb_u8, compute_bpc_params, lab_to_xyz_d50}; /// the legal range. const PCS_LAB_DENOM: f32 = 65280.0; -/// Sample a CMYK profile's `A2B1` (colorimetric) table into a [`Clut4`]. +/// Which of a CMYK profile's A2B tables feeds the CLUT bake. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CmykSourceTable { + /// `A2B1`, matching lcms2's `cmsDoTransform(RelCol)`. + #[default] + Colorimetric, + /// `A2B0`, matching lcms2's (and Ghostscript's and ImageMagick's) default + /// Perceptual intent. Profiles built for print carry a noticeably darker + /// black here: Japan Color 2001 Coated renders K100 as (35,25,22) rather + /// than (51,45,43). Falls back to `A2B1` when a profile has no perceptual + /// table. + Perceptual, +} + +/// Sample a CMYK profile's A2B table into a [`Clut4`]. /// /// Out-of-gamut Lab values clip to the sRGB boundary, matching lcms2's /// `cmsDoTransform(RelCol)` behaviour and avoiding the desaturation that @@ -60,6 +74,7 @@ pub(super) fn bake_clut4_perceptual( profile: &ColorProfile, grid_n: usize, bpc_enabled: bool, + source_table: CmykSourceTable, ) -> Option { if profile.color_space != DataColorSpace::Cmyk || profile.pcs != DataColorSpace::Lab { return None; @@ -68,7 +83,14 @@ pub(super) fn bake_clut4_perceptual( return None; } - let colorimetric = SampledLut::from_warehouse(profile.lut_a_to_b_colorimetric.as_ref()?)?; + let table = match source_table { + CmykSourceTable::Perceptual => profile + .lut_a_to_b_perceptual + .as_ref() + .or(profile.lut_a_to_b_colorimetric.as_ref()), + CmykSourceTable::Colorimetric => profile.lut_a_to_b_colorimetric.as_ref(), + }; + let colorimetric = SampledLut::from_warehouse(table?)?; // BPC is computed against this sampler's own (1,1,1,1) output, not // moxcms's transform output, so the source black-point matches what