//! CMYK transparency using two passes of the ordinary scene renderer. //! //! Separable PDF blend functions operate on the complements of the four inks. Render C/M/Y //! complements into RGB and K into a second raster, keeping the existing transparency groups, //! masks, parent-page placement and effects. Convert the final inks to sRGB once, then put the //! transparent result over paper. Work is independent of the number of blended objects. //! //! Nonseparable modes need a four-channel implementation: a spread containing one keeps the //! existing RGB/proof approximation instead of applying RGB Hue/Color formulas to CMY channels. use crate::{Frame, Placed, RenderOptions, Rendered, Renderer}; use designcraft_color::BlendMode as DcBlend; use designcraft_doc::{Document, Item, SpreadRef}; use designcraft_geom::{Affine, Rect, Shape}; use vello_cpu::{RenderContext, peniko}; /// At most one viewport, capped at 64 MiB. The snapshot shares the document's large Arcs; /// comparisons use their identity without scanning text or image bytes. Keeping it prevents address reuse. /// All document fields, colour settings and options participate, including parent pages, /// stories, assets, layer visibility and font scope. `clear_caches` releases it too. pub(crate) struct CachedView { doc: Document, settings: designcraft_color::cms::ColorSettings, opts: RenderOptions, spreads: Vec, view: Affine, image: Rendered, stats: crate::FrameStats, threads: u16, } impl CachedView { pub(crate) const MAX_BYTES: usize = 64 << 20; #[allow(clippy::too_many_arguments)] pub(crate) fn matches( &self, doc: &Document, spreads: &[Placed], width: u32, height: u32, view: Affine, opts: &RenderOptions, threads: u16, ) -> bool { self.image.width == width.clamp(1, crate::MAX_SIDE) && self.image.height == height.clamp(1, crate::MAX_SIDE) && self.view == view && self.opts == *opts && self.spreads == spreads && self.threads == threads && self.settings == designcraft_color::cms::active_settings() && same_document(&self.doc, doc) } pub(crate) fn image(&self) -> Rendered { self.image.clone() } pub(crate) fn stats(&self) -> crate::FrameStats { self.stats } pub(crate) fn new( doc: &Document, spreads: &[Placed], view: Affine, opts: &RenderOptions, image: &Rendered, stats: crate::FrameStats, threads: u16, ) -> Self { Self { doc: doc.clone(), settings: designcraft_color::cms::active_settings(), opts: opts.clone(), spreads: spreads.to_vec(), view, image: image.clone(), stats, threads, } } } /// Compare every field, but keep large immutable data on an identity-only path. Exhaustive /// destructuring makes a new document field require an explicit cache invalidation decision. fn same_document(old: &Document, new: &Document) -> bool { let Document { spreads, parents, stories, styles, assets, title, settings, layers, swatches, color_groups, xml, articles, stroke_styles, inks, endnote_options, endnote_story, conditions, sections, hyperlinks, data_merge, bookmarks, user_words, hyphenation_exceptions, toc, text_variables, footnote_options, xref_formats, index, created, modified, next_id, font_scope, } = new; let same_spreads = |a: &[std::sync::Arc], b: &[std::sync::Arc]| { a.len() == b.len() && a.iter().zip(b).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) }; same_spreads(&old.spreads, spreads) && same_spreads(&old.parents, parents) && std::sync::Arc::ptr_eq(&old.styles, styles) && old.stories.len() == stories.len() && old.stories.iter().zip(stories).all(|((a, x), (b, y))| a == b && std::sync::Arc::ptr_eq(x, y)) && old.assets.len() == assets.len() && old.assets.iter().zip(assets).all(|((a, x), (b, y))| a == b && std::sync::Arc::ptr_eq(x, y)) && old.title == *title && old.settings == *settings && old.layers == *layers && old.swatches == *swatches && old.color_groups == *color_groups && old.xml == *xml && old.articles == *articles && old.stroke_styles == *stroke_styles && old.inks == *inks && old.endnote_options == *endnote_options && old.endnote_story == *endnote_story && old.conditions == *conditions && old.sections == *sections && old.hyperlinks == *hyperlinks && old.data_merge == *data_merge && old.bookmarks == *bookmarks && old.user_words == *user_words && old.hyphenation_exceptions == *hyphenation_exceptions && old.toc == *toc && old.text_variables == *text_variables && old.footnote_options == *footnote_options && old.xref_formats == *xref_formats && old.index == *index && old.created == *created && old.modified == *modified && old.next_id == *next_id && old.font_scope == *font_scope } fn supported(mode: DcBlend) -> bool { !matches!(mode, DcBlend::Hue | DcBlend::Saturation | DcBlend::Color | DcBlend::Luminosity) } #[derive(Clone, Copy, PartialEq, Eq)] enum Pipeline { Rgb, Cmyk, Proof, } /// Whether a visible spread needs, and can use, the ink-channel pipeline. pub(crate) fn uses_cmyk(doc: &Document, sr: SpreadRef, opts: &RenderOptions) -> bool { pipeline(doc, sr, opts) == Pipeline::Cmyk } /// The legacy approximation is retained only for unsupported nonseparable blends. pub(crate) fn uses_rgb_proof(doc: &Document, sr: SpreadRef, opts: &RenderOptions) -> bool { pipeline(doc, sr, opts) == Pipeline::Proof } fn pipeline(doc: &Document, sr: SpreadRef, opts: &RenderOptions) -> Pipeline { if !opts.blend_space_view || opts.plate.is_some() || doc.settings.blend_space != designcraft_doc::BlendSpace::Cmyk { return Pipeline::Rgb; } let Some(sp) = doc.spread(sr) else { return Pipeline::Rgb }; let mut transparent = false; let mut inspect = |item: &Item| { if !doc.layer(item.layer).is_some_and(|l| l.visible && (!opts.printing_only || l.printable)) { return true; } inspect_item(doc, item, opts, 0, &mut transparent) }; for item in &sp.items { if !inspect(item) { return Pipeline::Proof; } } if let SpreadRef::Doc(si) = sr { let first = doc.first_page_of_spread(si); for (pi, page) in sp.pages.iter().enumerate() { if !page.show_parent_items { continue; } let Some((ppi, ppage)) = doc.parent_page_for(first.saturating_add(pi)) else { continue }; let Some(parent) = doc.parents.get(ppi) else { continue }; for item in &parent.items { if page.overridden.contains(&item.id) || parent.pages.len() > 1 && parent.page_at_x(item.bounds().center().x) != Some(ppage) { continue; } if !inspect(item) { return Pipeline::Proof; } } } } if transparent { Pipeline::Cmyk } else { Pipeline::Rgb } } /// Bounded traversal with no item-count-sized temporary allocation, including anchored items. fn inspect_item(doc: &Document, it: &Item, opts: &RenderOptions, depth: usize, transparent: &mut bool) -> bool { if it.hidden || opts.hidden.contains(&it.id) || opts.printing_only && it.nonprinting { return true; } if depth >= 64 || !supported(it.blend) { return false; } *transparent |= it.opacity < 1.0 || it.blend != DcBlend::Normal || it.effects.any() || it.knockout; if let Some(text) = it.text_frame() && let Some(story) = doc.story(text.story) { for object in &story.objects { if !inspect_item(doc, &object.item, opts, depth + 1, transparent) { return false; } } } it.shown_children().all(|child| inspect_item(doc, child, opts, depth + 1, transparent)) } const RGB_N: usize = 9; /// The working CMYK → sRGB table for the current colour settings (9⁴, built once per settings); /// sample it with [`srgb_in`]. fn srgb_table() -> std::sync::Arc> { const N: usize = RGB_N; type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc>); static LUT: std::sync::Mutex> = std::sync::Mutex::new(None); let settings = designcraft_color::cms::active_settings(); let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); match g.as_ref() { Some((s, t)) if *s == settings => t.clone(), _ => { let cms = designcraft_color::cms::active(); let step = |i: usize| i as f32 / (N - 1) as f32; let t: Vec<[f32; 3]> = (0..N * N * N * N) .map(|i| cms.cmyk_to_srgb([step(i / (N * N * N)), step(i / (N * N) % N), step(i / N % N), step(i % N)], false)) .collect(); let t = std::sync::Arc::new(t); *g = Some((settings, t.clone())); t } } } /// Quadrilinear lookup of `cmyk` in an [`srgb_table`]. fn srgb_in(table: &[[f32; 3]], cmyk: [f32; 4]) -> [f32; 3] { const N: usize = RGB_N; let f = cmyk.map(|v| v.clamp(0.0, 1.0) * (N - 1) as f32); let i = f.map(|v| (v as usize).min(N - 2)); let d = [f[0] - i[0] as f32, f[1] - i[1] as f32, f[2] - i[2] as f32, f[3] - i[3] as f32]; let mut out = [0.0f32; 3]; for corner in 0..16usize { let bit = |n: usize| corner >> (3 - n) & 1; let w: f32 = (0..4).map(|n| if bit(n) == 1 { d[n] } else { 1.0 - d[n] }).product(); if w == 0.0 { continue; } let idx = (i[0] + bit(0)) * N * N * N + (i[1] + bit(1)) * N * N + (i[2] + bit(2)) * N + i[3] + bit(3); if let Some(rgb) = table.get(idx) { for k in 0..3 { out[k] += rgb[k] * w; } } } out } impl Renderer { /// The view pixels the spread's transparency can change: the union of its transparent /// objects (opacity, blend, effects, knockout, also nested or anchored) with their effects' /// reach. None: the whole view (a transparent parent-page item, placed through the parent /// page). Empty: nothing transparent is visible. fn transparent_area(&self, f: &Frame, sr: SpreadRef) -> Option { let doc = f.doc; let sp = doc.spread(sr)?; let visible = |item: &Item| doc.layer(item.layer).is_some_and(|l| l.visible && (!f.opts.printing_only || l.printable)); let transparent = |item: &Item| { let mut t = false; inspect_item(doc, item, f.opts, 0, &mut t); t }; if let SpreadRef::Doc(si) = sr { let first = doc.first_page_of_spread(si); for (pi, page) in sp.pages.iter().enumerate() { if !page.show_parent_items { continue; } let Some((ppi, _)) = doc.parent_page_for(first.saturating_add(pi)) else { continue }; if doc .parents .get(ppi) .is_some_and(|parent| parent.items.iter().any(|it| visible(it) && !page.overridden.contains(&it.id) && transparent(it))) { return None; } } } // What an object can paint on the spread: its bounds with its (and its children's) // strokes and effects. let reach = |it: &Item| { let mut o: f64 = 0.0; it.walk(&mut |c| o = o.max(crate::fx::outset(c) + c.stroke.extent())); it.bounds().inflate(o + 2.0, o + 2.0) }; let items: Vec<(Rect, bool)> = sp.items.iter().filter(|it| visible(it)).map(|it| (reach(it), transparent(it))).collect(); let mut area: Option = None; for (r, _) in items.iter().filter(|(_, t)| *t) { area = Some(area.map_or(*r, |a| a.union(*r))); } // Objects the area touches join it whole, so none is drawn partly through the ink passes // and partly through the ordinary drawing (their colour conversions differ slightly). let Some(mut area) = area else { return Some(Rect::ZERO) }; for _ in 0..items.len() { let grown = items.iter().filter(|(r, _)| r.intersect(area).area() > 0.0).fold(area, |a, (r, _)| a.union(*r)); if grown == area { break; } area = grown; } Some(f.view.transform_rect_bbox(area)) } /// The spread with its transparency blended in CMYK. Paper has already been painted in `ctx`; /// it must never be a blend backdrop. Only the area transparent objects can change takes the /// two ink passes (C/M/Y complements, then K); the rest of the view is drawn once, as usual. pub(crate) fn draw_cmyk_spread(&mut self, ctx: &mut RenderContext, f: &Frame, sr: SpreadRef) { let (w, h) = (ctx.width(), ctx.height()); let view = Rect::new(0.0, 0.0, w as f64, h as f64); let area = self.transparent_area(f, sr).unwrap_or(view); let area = Rect::new(area.x0.floor() - 1.0, area.y0.floor() - 1.0, area.x1.ceil() + 1.0, area.y1.ceil() + 1.0).intersect(view); // The document grid is drawn once, by the caller, behind and in front of all this. let rgb = RenderOptions { paper: false, background: None, page_shadow: false, blend_space_view: false, document_grid: Vec::new(), ..f.opts.clone() }; if area.width() < 1.0 || area.height() < 1.0 { // Nothing transparent in view: the ordinary drawing is exact. self.draw_spread(ctx, &Frame { opts: &rgb, ..*f }, sr); return; } // Outside the area: the ordinary drawing (an even-odd clip of the view minus the area). if area != view { let mut outside = view.to_path(0.1); outside.extend(area.to_path(0.1).iter()); ctx.set_transform(Affine::IDENTITY); ctx.set_fill_rule(peniko::Fill::EvenOdd); ctx.push_clip_layer(&outside); ctx.set_fill_rule(peniko::Fill::NonZero); self.draw_spread(ctx, &Frame { opts: &rgb, ..*f }, sr); ctx.pop_layer(); } // Inside it: the two ink passes, with the render threads, over just the area. let (aw, ah) = (area.width() as u16, area.height() as u16); let shifted = Affine::translate((-area.x0, -area.y0)) * f.view; // The passes draw only what reaches the area: items elsewhere are culled. let visible = f.view.inverse().transform_rect_bbox(area); let stats = self.stats; let threads = self.threads; let mut pass = |plate| { let opts = RenderOptions { plate: Some(plate), ..rgb.clone() }; crate::PLATE.with(|p| p.set(opts.plate)); let mut off = RenderContext::new_with(aw, ah, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }); #[cfg(test)] let items = self.stats.items; self.draw_spread(&mut off, &Frame { opts: &opts, view: shifted, visible, mt: threads > 0, ..*f }, sr); #[cfg(test)] { self.cmyk_pass_items = self.stats.items - items; } off.flush(); let mut pm = vello_cpu::Pixmap::new(aw, ah); off.render(&mut pm, &mut self.resources); #[cfg(test)] { self.cmyk_passes += 1; self.cmyk_pass_pixels = aw as usize * ah as usize; } pm }; let mut cmy = pass(4); let black = pass(3); self.cmyk_ran = true; crate::PLATE.with(|p| p.set(f.opts.plate)); self.stats.items = (self.stats.items - stats.items) / 2 + stats.items; self.stats.glyphs = (self.stats.glyphs - stats.glyphs) / 2 + stats.glyphs; let table = srgb_table(); for (c, k) in cmy.data_mut().iter_mut().zip(black.data()) { if c.a == 0 { continue; } let a = c.a as f32; let inks = [1.0 - c.r as f32 / a, 1.0 - c.g as f32 / a, 1.0 - c.b as f32 / a, 1.0 - k.r as f32 / a]; let rgb = if f.opts.rich_black && inks[0..3].iter().all(|v| *v <= 1e-3) && inks[3] >= 0.999 { [0.0; 3] } else { srgb_in(&table, inks) }; let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * a).round() as u8); (c.r, c.g, c.b) = (r, g, b); } ctx.set_transform(Affine::translate((area.x0, area.y0))); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(cmy)), sampler: peniko::ImageSampler::default() }); ctx.fill_rect(&Rect::new(0.0, 0.0, aw as f64, ah as f64)); ctx.set_transform(Affine::IDENTITY); } } #[cfg(test)] mod tests { use super::*; use designcraft_compose::Cache; use designcraft_doc::{Content, Fill, ItemId, Shape, build::NewDocument}; use std::sync::Arc; fn document() -> Document { let mut doc = Document::new(&NewDocument { width: 160.0, height: 120.0, facing_pages: false, ..Default::default() }); doc.swatches.push(designcraft_color::swatch::Swatch::color("Lilac", designcraft_color::Color::rgb8(171, 154, 227))); doc } fn rectangle(doc: &mut Document, sr: SpreadRef, rect: Rect, fill: &str, blend: DcBlend) -> ItemId { let id = ItemId(doc.alloc()); let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(rect)); item.fill = Fill::swatch(fill); item.blend = blend; doc.insert_item(sr, item, None).unwrap(); id } fn draw(doc: &Document, renderer: &mut Renderer) -> Rendered { renderer.render( doc, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], 200, 140, Affine::translate((30.0, 10.0)), &RenderOptions { blend_space_view: true, background: Some([180, 180, 180, 255]), ..Default::default() }, ) } fn dodge(doc: &mut Document, sr: SpreadRef) -> ItemId { rectangle(doc, sr, Rect::new(-25.0, 10.0, 130.0, 70.0), "Lilac", DcBlend::Normal); rectangle(doc, sr, Rect::new(-20.0, 25.0, 100.0, 100.0), designcraft_color::swatch::BLACK, DcBlend::ColorDodge) } fn white(pixel: [u8; 4]) { assert!(pixel[..3].iter().all(|v| *v > 235), "{pixel:?}"); } #[test] fn cmyk_nested_blends_match_top_level_blends() { let mut doc = document(); let black = dodge(&mut doc, SpreadRef::Doc(0)); let mut renderer = Renderer::new(); renderer.threads = 0; let reference = draw(&doc, &mut renderer); let child = doc.remove_item(black).unwrap(); let mut group = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Group, Default::default()); group.content = Content::Group { items: vec![child] }; // A second group ensures the preflight and the rendering both follow nesting. let mut outer = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Group, Default::default()); outer.content = Content::Group { items: vec![Arc::new(group)] }; doc.insert_item(SpreadRef::Doc(0), outer, None).unwrap(); let nested = draw(&doc, &mut renderer); white(nested.pixel(70, 50)); assert_eq!(nested.pixels, reference.pixels); } #[test] fn cmyk_parent_blends_and_overrides_follow_parent_visibility() { let mut doc = document(); let black = dodge(&mut doc, SpreadRef::Parent(0)); let mut renderer = Renderer::new(); renderer.threads = 0; let shown = draw(&doc, &mut renderer); white(shown.pixel(70, 50)); Arc::make_mut(&mut doc.spreads[0]).pages[0].overridden.push(black); let overridden = draw(&doc, &mut renderer); assert_ne!(shown.pixel(70, 50), overridden.pixel(70, 50)); Arc::make_mut(&mut doc.spreads[0]).pages[0].show_parent_items = false; let hidden = draw(&doc, &mut renderer); assert_eq!(hidden.pixel(70, 50), [255; 4]); } #[test] fn cmyk_blending_continues_across_trim_and_keeps_paper_out_of_backdrop() { let mut doc = document(); dodge(&mut doc, SpreadRef::Doc(0)); let mut renderer = Renderer::new(); renderer.threads = 0; let image = draw(&doc, &mut renderer); // x=30 is the left trim. Identical overlap on either side must have identical inks. white(image.pixel(20, 50)); assert_eq!(image.pixel(20, 50), image.pixel(40, 50)); assert!(image.pixel(70, 95)[..3].iter().all(|v| *v < 90)); assert_eq!(image.pixel(5, 130), [180, 180, 180, 255]); // Optional synthetic preview for manual visual QA, never a checked-in fixture. if let Ok(path) = std::env::var("DESIGNCRAFT_CMYK_PREVIEW") { std::fs::write(path, image.to_png()).unwrap(); } } #[test] fn cmyk_effects_blend_with_the_ink_backdrop_even_with_render_threads() { let mut doc = document(); let item = rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(20.0, 20.0, 120.0, 100.0), "C=100 M=0 Y=0 K=0", DcBlend::Normal); let satin = &mut doc.item_mut(item).unwrap().effects.satin; satin.on = true; satin.color = "C=0 M=100 Y=0 K=0".into(); satin.opacity = 1.0; satin.size = 0.0; satin.angle = 0.0; satin.distance = 40.0; let mut renderer = Renderer::new(); renderer.threads = 2; let image = draw(&doc, &mut renderer); let expected = srgb_in(&srgb_table(), [1.0, 1.0, 0.0, 0.0]).map(|v| (v * 255.0).round() as u8); let actual = image.pixel(60, 60); assert!(actual[..3].iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 3), "Multiply satin: {actual:?}, expected {expected:?}"); renderer.threads = 0; assert_eq!(draw(&doc, &mut renderer).pixels, image.pixels); // The effect's blend must also see the object's backdrop in the existing RGB path, // including the multithreaded offscreen-filter implementation. doc.settings.blend_space = designcraft_doc::BlendSpace::Rgb; let rgb = draw(&doc, &mut renderer); let mixed = rgb.pixel(60, 60); assert!(mixed[0] < 100 && mixed[1] < 100 && mixed[2] > 100, "cyan times magenta is blue, not magenta: {mixed:?}"); renderer.threads = 2; assert_eq!(draw(&doc, &mut renderer).pixels, rgb.pixels); } #[test] fn cmyk_pass_count_is_constant_and_unchanged_views_are_cached() { let mut doc = document(); let mut ids = Vec::new(); for _ in 0..10 { ids.push(rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(20.0, 20.0, 100.0, 100.0), "C=100 M=0 Y=0 K=0", DcBlend::Multiply)); } let mut renderer = Renderer::new(); renderer.threads = 0; let before = draw(&doc, &mut renderer); assert_eq!(renderer.cmyk_passes, 2, "ten Multiply objects still need only two ink passes"); assert_eq!(draw(&doc, &mut renderer).pixels, before.pixels); assert_eq!(renderer.cmyk_passes, 2, "an unchanged frame performs no ink passes"); doc.item_mut(ids[9]).unwrap().fill = Fill::swatch(designcraft_color::swatch::BLACK); let after = draw(&doc, &mut renderer); assert_ne!(after.pixel(70, 50), before.pixel(70, 50)); assert_eq!(renderer.cmyk_passes, 4); renderer.clear_caches(); assert!(renderer.cmyk_view.is_none()); assert_eq!(draw(&doc, &mut renderer).pixels, after.pixels); assert_eq!(renderer.cmyk_passes, 6); let (_, story) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 160.0, 30.0), doc.default_layer(), "Hello", Default::default()).unwrap(); let text_before = draw(&doc, &mut renderer); doc.story_mut(story).unwrap().insert(5, " world"); let text_after = draw(&doc, &mut renderer); assert_ne!(text_before.pixels, text_after.pixels, "story edits invalidate the viewport"); assert_eq!(renderer.cmyk_passes, 10); } #[test] fn cmyk_passes_cover_only_what_transparency_can_change() { // A 200×140 view; one small Multiply object in a corner, a solid one far from it. let mut doc = document(); rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(100.0, 70.0, 150.0, 110.0), "C=100 M=0 Y=0 K=0", DcBlend::Normal); rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(5.0, 5.0, 25.0, 25.0), "C=0 M=100 Y=0 K=0", DcBlend::Multiply); let mut renderer = Renderer::new(); renderer.threads = 0; let image = draw(&doc, &mut renderer); assert_eq!(renderer.cmyk_passes, 2); assert!(renderer.cmyk_pass_pixels < 200 * 140 / 8, "{} px", renderer.cmyk_pass_pixels); // The solid object is drawn as before, its colour exact. let cyan = RenderOptions { blend_space_view: false, background: Some([180, 180, 180, 255]), ..Default::default() }; let plain = renderer.render( &doc, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], 200, 140, Affine::translate((30.0, 10.0)), &cyan, ); assert_eq!(image.pixel(150, 100), plain.pixel(150, 100)); // An object the area touches is drawn whole in the ink passes (no seam across it). let mut renderer = Renderer::new(); renderer.threads = 0; rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(15.0, 15.0, 90.0, 60.0), "C=0 M=0 Y=100 K=0", DcBlend::Normal); draw(&doc, &mut renderer); assert!(renderer.cmyk_pass_pixels >= 75 * 45, "{} px", renderer.cmyk_pass_pixels); } #[test] fn ink_passes_draw_only_the_items_that_reach_their_area() { // A small Multiply object in a corner and a solid one far from it: the ink passes cover the // corner and draw just the Multiply object. let mut doc = document(); rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(100.0, 70.0, 150.0, 110.0), "C=100 M=0 Y=0 K=0", DcBlend::Normal); rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(5.0, 5.0, 25.0, 25.0), "C=0 M=100 Y=0 K=0", DcBlend::Multiply); let mut renderer = Renderer::new(); renderer.threads = 0; draw(&doc, &mut renderer); assert_eq!(renderer.cmyk_passes, 2); assert_eq!(renderer.cmyk_pass_items, 1); } #[test] fn the_view_is_kept_only_when_the_ink_passes_ran() { let mut doc = document(); doc.settings.blend_space = designcraft_doc::BlendSpace::Cmyk; let id = rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(20.0, 20.0, 100.0, 100.0), "C=100 M=0 Y=0 K=0", DcBlend::Normal); let mut renderer = Renderer::new(); renderer.threads = 0; draw(&doc, &mut renderer); assert!(renderer.cmyk_view.is_none(), "nothing transparent: no ink passes, nothing kept"); doc.item_mut(id).unwrap().blend = DcBlend::Multiply; draw(&doc, &mut renderer); assert!(renderer.cmyk_view.is_some()); } #[test] fn cmyk_channels_keep_the_same_alpha_through_transparency() { let mut doc = document(); let a = rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(20.0, 20.0, 100.0, 100.0), "C=100 M=0 Y=0 K=0", DcBlend::Normal); let b = rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(40.0, 20.0, 120.0, 100.0), "C=0 M=100 Y=0 K=0", DcBlend::Multiply); doc.item_mut(a).unwrap().opacity = 0.5; doc.item_mut(b).unwrap().opacity = 0.5; doc.item_mut(b).unwrap().effects.gradient_feather.on = true; let children = vec![doc.remove_item(a).unwrap(), doc.remove_item(b).unwrap()]; let mut group = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Group, Default::default()); group.content = Content::Group { items: children }; group.opacity = 0.5; group.isolate = true; doc.insert_item(SpreadRef::Doc(0), group, None).unwrap(); let mut renderer = Renderer::new(); renderer.threads = 0; let placements = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; let options = RenderOptions { paper: false, blend_space_view: true, ..Default::default() }; let out = renderer.render(&doc, &Cache::new(), &placements, 160, 120, Affine::IDENTITY, &options); assert!(out.pixel(30, 40)[3].abs_diff(64) <= 1, "half-opacity object inside a half-opacity group is quarter opaque"); let cmy = renderer.render(&doc, &Cache::new(), &placements, 160, 120, Affine::IDENTITY, &RenderOptions { plate: Some(4), ..options.clone() }); let k = renderer.render(&doc, &Cache::new(), &placements, 160, 120, Affine::IDENTITY, &RenderOptions { plate: Some(3), ..options }); for ((out, cmy), k) in out.pixels.as_chunks::<4>().0.iter().zip(cmy.pixels.as_chunks::<4>().0).zip(k.pixels.as_chunks::<4>().0) { assert_eq!(cmy[3], k[3], "all ink passes have the same coverage"); assert_eq!(out[3], cmy[3], "conversion preserves coverage"); assert!(out[..3].iter().all(|v| *v <= out[3]), "output remains premultiplied"); } } #[test] fn cmyk_nonseparable_modes_use_the_existing_rgb_approximation() { let mut doc = document(); let id = dodge(&mut doc, SpreadRef::Doc(0)); doc.item_mut(id).unwrap().blend = DcBlend::Hue; let mut renderer = Renderer::new(); renderer.threads = 0; draw(&doc, &mut renderer); assert_eq!(renderer.cmyk_passes, 0, "RGB Hue must not be evaluated on CMY channels"); doc.item_mut(id).unwrap().blend = DcBlend::ColorDodge; draw(&doc, &mut renderer); assert_eq!(renderer.cmyk_passes, 2); } }