diff --git a/ROADMAP.md b/ROADMAP.md index d33353caa..de44b22a4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · On screen, blend modes blend with the objects below and not with the paper, as in InDesign and in PDF export: a Color Dodge or Screen object over blank paper shows as it is. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..a381b1698 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -394,6 +394,13 @@ impl Renderer { } } } + // Blend modes blend with the objects below, not with the paper (as in InDesign and in PDF, + // where the page's group starts transparent): a Color Dodge or Screen object over blank paper + // shows as it is. So the items are drawn in their own group, laid over the paper once. + let isolate = f.opts.paper && spread_blends(doc, sp); + if isolate { + ctx.push_layer(None, None, None, None, None); + } // Layers back to front; within each layer parent items first (document spreads only). for layer in doc.layers.iter().rev() { if !layer.visible || f.opts.printing_only && !layer.printable { @@ -428,6 +435,9 @@ impl Renderer { } } } + if isolate { + ctx.pop_layer(); + } } fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: Option) { @@ -920,6 +930,17 @@ pub(crate) fn overprints(f: &Frame, swatch: &str, tint: f32, flag: bool) -> bool f.opts.overprint_preview && (flag || (f.doc.settings.overprint_black && swatch == designcraft_color::swatch::BLACK && tint >= 0.999)) } +/// Does any item on the spread, or on the parent pages it shows, use a blend mode other than +/// Normal (groups and pasted-in items included)? +fn spread_blends(doc: &Document, sp: &designcraft_doc::Spread) -> bool { + let blends = |it: &Item| { + let mut any = false; + it.walk(&mut |i| any |= i.blend != Default::default()); + any + }; + sp.items.iter().any(|i| blends(i)) || doc.parents.iter().any(|p| p.items.iter().any(|i| blends(i))) +} + pub fn blend_mode(b: DcBlend) -> BlendMode { use DcBlend as B; let mix = match b { diff --git a/crates/render/tests/blend_over_paper.rs b/crates/render/tests/blend_over_paper.rs new file mode 100644 index 000000000..0fd257027 --- /dev/null +++ b/crates/render/tests/blend_over_paper.rs @@ -0,0 +1,48 @@ +//! Blend modes blend with the objects below, never with the paper: over blank paper an object in +//! Color Dodge (or any other mode) shows as it is, as in InDesign and in the exported PDF. + +use designcraft_color::{BlendMode, Color, Swatch}; +use designcraft_compose::Cache; +use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, build::NewDocument}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{Placed, RenderOptions, Renderer}; + +/// A 200 x 100 page: a mid-blue rectangle on the right half, and over both halves a black +/// rectangle in `blend`. +fn document(blend: BlendMode) -> Document { + let mut d = Document::new(&NewDocument { width: 200.0, height: 100.0, facing_pages: false, ..Default::default() }); + d.swatches.push(Swatch::color("Mid blue", Color::rgb(0.2, 0.4, 0.6))); + let mut rect = |r: Rect, swatch: &str, blend: BlendMode| { + let id = ItemId(d.alloc()); + let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(r)); + it.fill = Fill::swatch(swatch); + it.blend = blend; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + }; + rect(Rect::new(100.0, 0.0, 200.0, 100.0), "Mid blue", BlendMode::Normal); + rect(Rect::new(20.0, 20.0, 180.0, 80.0), "[Black]", blend); + d +} + +fn pixel(d: &Document, x: u32, y: u32) -> [u8; 4] { + let mut r = Renderer::new(); + r.threads = 0; + let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let img = r.render(d, &Cache::new(), &placed, 200, 100, Affine::IDENTITY, &RenderOptions::default()); + let i = ((y * 200 + x) * 4) as usize; + [img.pixels[i], img.pixels[i + 1], img.pixels[i + 2], img.pixels[i + 3]] +} + +#[test] +fn a_blend_mode_over_blank_paper_shows_the_object_as_it_is() { + let d = document(BlendMode::ColorDodge); + // Over the paper: black ([Black] shows as about 52 grey), as in Normal (it used to dodge the white paper to white, vanishing). + let over_paper = pixel(&d, 50, 50); + assert!(over_paper[0] < 80 && over_paper[1] < 80 && over_paper[2] < 80, "{over_paper:?}"); + // Over the blue rectangle it still blends: black in Color Dodge leaves the blue as it is. + let over_blue = pixel(&d, 150, 50); + assert!(over_blue[2] > 120 && over_blue[2] > over_blue[0], "{over_blue:?}"); + // Normal is unchanged. + let normal = pixel(&document(BlendMode::Normal), 150, 50); + assert!(normal[0] < 80 && normal[2] < 80, "{normal:?}"); +}