diff --git a/ROADMAP.md b/ROADMAP.md index 2e9d812b..15ac74e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,7 +49,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. - Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported. - File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. -- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space; on-screen compositing is still RGB. +- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK). - High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). - Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans). - Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images. @@ -167,18 +167,18 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope ## How far from full parity (estimate, 2026-10-04) -**Breadth: ~99% weighted** (P0 core 99%, P1 99%, P2 96%) over the 275 features of the InDesign catalogue, scored +**Breadth: ~99% weighted** (P0 core 99%, P1 100%, P2 97%) over the 275 features of the InDesign catalogue, scored row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still -lack some of InDesign's options or dialog details, so **overall parity including depth is about 82%**. +lack some of InDesign's options or dialog details, so **overall parity including depth is about 83%**. -**Remaining work: about 185 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 60–85 hours with +**Remaining work: about 170 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 60–85 hours with four agents in parallel on separate crates: | Work | Estimate | |---|---| | Open P0 (2: PDF/X-4 validation against a certified checker, PostScript interpretation for EPS) | 8 h | -| Open P1 (1 partial: on-screen blending in the blend space) | 4 h | -| Open P2 (16 partial: vertical/RTL refinements, alternate-layout linking, separations from source colours, buttons as form fields, interactive PDF extras, scripting, localization breadth, …) | 45 h | +| Open P1 (none partial) | 0 h | +| Open P2 (4 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback, vertical metrics and warichu) | 14 h | | Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h | | Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 55 h | diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index d6a754ca..8a20ec78 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1100,6 +1100,16 @@ impl Item { self.shape == Shape::Group && matches!(self.content, Content::Group { .. }) } + /// Does this object (or anything in it) involve transparency (opacity, a blend mode, effects, + /// knockout)? + pub fn involves_transparency(&self) -> bool { + self.opacity < 1.0 + || self.blend != Default::default() + || self.effects.any() + || self.knockout + || self.children().iter().any(|c| c.involves_transparency()) + } + /// A frame with items pasted into it (Edit › Paste Into): drawn clipped to its path. pub fn has_nested_items(&self) -> bool { self.shape != Shape::Group && matches!(&self.content, Content::Group { items } if !items.is_empty()) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index c1447030..47921614 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -226,7 +226,7 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result { /// Does this object (or anything in it) need the transparency flattener? fn transparent(it: &designcraft_doc::Item) -> bool { - it.opacity < 1.0 || it.blend != Default::default() || it.effects.any() || it.knockout || it.children().iter().any(|c| transparent(c)) + it.involves_transparency() } /// Transparency Flattener: each object involving transparency becomes an opaque image of its diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1e8d6c41..d58a8b33 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -130,6 +130,9 @@ pub struct RenderOptions { pub rich_black: bool, /// View › Overprint Preview: overprinting inks mix with what's beneath. pub overprint_preview: bool, + /// Screen: spreads with transparency are shown as blended in the document's blend space (a + /// CMYK blend space keeps their colours in the working CMYK gamut). + pub blend_space_view: bool, } /// View › Display Performance: how placed graphics and effects are drawn on screen. @@ -166,6 +169,7 @@ impl Default for RenderOptions { quality: DisplayQuality::High, rich_black: false, overprint_preview: false, + blend_space_view: false, } } } @@ -303,6 +307,17 @@ impl Renderer { // Render straight into the returned buffer (no extra copy of the frame). let mut pixels = vec![0u8; w as usize * h as usize * 4]; ctx.render(vello_cpu::PixmapMut::new(w, h, &mut pixels).expect("buffer size"), &mut self.resources); + if opts.blend_space_view && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk && opts.plate.is_none() { + let mut img = Rendered { width: w as u32, height: h as u32, pixels }; + for pl in spreads { + let Some(sp) = doc.spread(pl.spread) else { continue }; + if sp.items.iter().any(|it| it.involves_transparency() && !it.hidden) { + let r = (view * pl.xf).transform_rect_bbox(sp.bounds()); + blend_space_view(&mut img, r); + } + } + pixels = img.pixels; + } self.ctx = Some(ctx); self.stories.clear(); self.stats.micros = now().saturating_sub(start); @@ -1066,6 +1081,31 @@ pub fn proof_view(img: &mut Rendered, setup: &designcraft_color::cms::ProofSetup } } +/// A CMYK transparency blend space on screen: the pixels of `area` (device space) through the +/// working CMYK profile and back, as a spread composited in that space looks. +fn blend_space_view(img: &mut Rendered, area: Rect) { + use designcraft_color::cms::{ProofSetup, ProofTarget}; + let lut = designcraft_color::cms::active().proof_lut(&ProofSetup { target: ProofTarget::WorkingCmyk, ..Default::default() }); + let (w, h) = (img.width as i64, img.height as i64); + let (x0, y0) = ((area.x0.floor() as i64).clamp(0, w), (area.y0.floor() as i64).clamp(0, h)); + let (x1, y1) = ((area.x1.ceil() as i64).clamp(0, w), (area.y1.ceil() as i64).clamp(0, h)); + for y in y0..y1 { + let row = &mut img.pixels[((y * w + x0) * 4) as usize..((y * w + x1) * 4) as usize]; + for px in row.chunks_exact_mut(4) { + let a = px[3]; + if a == 0 { + continue; + } + let k = 255.0 / a as f32; + let rgb = [px[0], px[1], px[2]].map(|v| ((v as f32 * k).round()).min(255.0) as u8); + let out = lut.apply8(rgb); + for i in 0..3 { + px[i] = ((out[i] as u32 * a as u32 + 127) / 255) as u8; + } + } + } +} + pub fn separation_view(img: &mut Rendered, plate: Option, ink_limit: Option) { for px in img.pixels.chunks_exact_mut(4) { let a = px[3] as f32 / 255.0; @@ -1207,6 +1247,33 @@ mod tests { assert!(on[2] < 60 && on[0] > 180, "magenta over yellow → red: {on:?}"); } + #[test] + fn cmyk_blend_space_shows_transparent_spreads_in_cmyk() { + let mut d = Document::new(&NewDocument::default()); + d.swatches.push(designcraft_color::swatch::Swatch::color("Green", designcraft_color::Color::rgb8(0, 255, 0))); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = + Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); + it.fill = Fill::swatch("Green"); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let px = |d: &Document, r: &mut Renderer| { + r.render_page(d, &cache, 0, 1.0, false, &RenderOptions { blend_space_view: true, ..Default::default() }).unwrap().pixel(150, 150) + }; + // No transparency on the spread: shown as is. + assert_eq!(px(&d, &mut r)[1], 255); + // With transparency in a CMYK blend space the bright green leaves the RGB gamut's edge. + d.item_mut(id).unwrap().opacity = 0.99; + let cmyk = px(&d, &mut r); + assert!(cmyk[0] > 20 || cmyk[1] < 240, "{cmyk:?}"); + d.settings.blend_space = designcraft_doc::BlendSpace::Rgb; + let rgb = px(&d, &mut r); + assert!(rgb[1] > 245 && rgb[0] < 10, "{rgb:?}"); + } + #[test] fn knockout_group_hides_its_own_objects() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 84b46738..595468fa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -421,6 +421,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, quality: app.ui.display_quality, rich_black: app.ui.rich_black, overprint_preview: app.ui.overprint_preview, + blend_space_view: !app.ui.proof_colors, ..Default::default() }; (st.doc.clone(), placed, w, h, view, opts) @@ -533,6 +534,7 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: quality: app.ui.display_quality, rich_black: app.ui.rich_black, overprint_preview: app.ui.overprint_preview, + blend_space_view: !app.ui.proof_colors, ..Default::default() }; let img = app.canvas.patcher.render(&new, &app.session.cache, &placed, (x1 - x0) as u32, (y1 - y0) as u32, view, &opts); diff --git a/docs/parity.md b/docs/parity.md index 98006f82..94b36473 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | -| P1 | 97 | 96 | 1 | 0 | 99% | +| P1 | 97 | 97 | 0 | 0 | 100% | | P2 | 76 | 72 | 4 | 0 | 97% | | Area | Parity | @@ -21,7 +21,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Frames, shapes & paths | 100% | | Transform | 100% | | Fill, stroke, colour | 100% | -| Effects & transparency | 92% | +| Effects & transparency | 100% | | Placing & links | 93% | | Type & text frames | 99% | | Typography | 100% | @@ -164,7 +164,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Effects & transparency | Inner Shadow, Outer Glow, Inner Glow, Bevel, Satin | P2 | D | | Effects & transparency | Basic/Directional/Gradient Feather | P2 | D | | Effects & transparency | Global Light | P2 | D | -| Effects & transparency | Transparency blend space | P1 | P | +| Effects & transparency | Transparency blend space | P1 | D | | Effects & transparency | Transparency flattener | P2 | D | | Placing & links | Place | P0 | D | | Placing & links | Place into frame / by click or drag | P0 | D |