collective

Browse / github.com/storytold/designcraft / patch

Preserve nested knockout groups and report unsupported overprint

Preserve nested knockout groups and report unsupported overprint Nested knockout groups bypassed PDF effect rasterization and blended overlapping children incorrectly. The shared capture helper now follows visible nested children and replaces each selected subtree in its original sibling slot at 300 ppi, preserving ancestor transforms, opacity, clipping and stacking. Captures retain the full source document for composition, and replaced text frames remain as nonprinting layout placeholders. This keeps threaded-story offsets and wrap/page context intact. The selected item's blend mode stays on the replacement image. A bounded scan retains descendant overpri

Status
open
Score
0.00
Adopters
0 other operators
Author
andeecollard (imported from pull request #1673, open)
Base
2e36dcf909627c8f0e5662fe6b4825b1c1c24420
Size
+519 −64 in 13 files
Review
pending
Shared
2026-10-28
Id
44ed3b78feddf343a240d8ac

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd/export.rs crates/engine/tests/it/main.rs crates/engine/tests/it/pdf_backend.rs crates/engine/tests/it/print_state.rs crates/pdf/src/export.rs crates/pdf/src/lib.rs crates/pdf/src/text.rs crates/render/src/blendspace.rs crates/render/src/lib.rs docs/gaps.md docs/print-state-regressions.md docs/target-app-parity.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -11,6 +11,10 @@  DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-23** · PDF effect captures retain full story context and nonprinting layout+  frames, preserve the captured item’s blend mode, and retain descendant overprint diagnostics.+  Synthetic regressions reproduce all three review findings before the fix.+ ## Status (2026-10-01)  **Working today**@@ -348,6 +352,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m  Newest first. +- **2026-10-23** · PDF export applies the existing 300 ppi effects/knockout fallback inside nested groups, preserving ancestor transforms and compositing and retaining text-wrap stand-ins. Explicit stroke and painted-gap overprint now report the same unsupported-export warning as fills/text. Synthetic RGB controls and independent Poppler rendering verify the isolated fixes; no print-fidelity percentage change.+ - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs--- a/crates/engine/src/cmd/export.rs+++ b/crates/engine/src/cmd/export.rs@@ -127,7 +127,7 @@ fn export_pptx(s: &mut Session, p: &Value) -> Result<Value> {     // Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame     // stays text: a picture of it would drop the rest of its story).     let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true);-    let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, true, |it| {+    let (raster, rasterized, _) = rasterize_where(&st.doc, &s.cache, 300.0, true, |it| {         !threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it)))     });     let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc);@@ -245,7 +245,7 @@ fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec<String>) -> fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {     let st = s.doc()?;     // Object Export Options › Rasterize: those objects go in as images.-    let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it));+    let (raster, _, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it));     let epub_doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc);     let fixed = p.get("fixedLayout").and_then(Value::as_bool).unwrap_or(false);     let want_cover = p.get("cover").and_then(Value::as_bool).unwrap_or(false);@@ -525,13 +525,13 @@ fn raster_effects(it: &designcraft_doc::Item) -> bool {         || e.directional_feather.on } -/// A copy of `d` where spread-level objects with soft effects are 300 ppi images of their whole+/// A copy of `d` where objects with soft effects (also inside groups) are 300 ppi images of their whole /// appearance (transparent around them, so they composite over what's behind).-fn rasterize_effects(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache) -> (Option<designcraft_doc::Document>, usize) {+fn rasterize_effects(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache) -> (Option<designcraft_doc::Document>, usize, bool) {     rasterize_where(d, cache, 300.0, true, |it| raster_effects(it) && !threaded(d, it)) } -/// A copy of `d` where the spread-level objects `pred` picks are images of their appearance at+/// A copy of `d` where the spread items or nested group children `pred` picks are images of their appearance at /// `ppi` (transparent around them). With `keep_wrap`, each object also stays behind as a /// nonprinting [`wrap_stand_in`], so text wraps around it as before. fn rasterize_where(@@ -540,39 +540,50 @@ fn rasterize_where(     ppi: f64,     keep_wrap: bool,     pred: impl Fn(&designcraft_doc::Item) -> bool,-) -> (Option<designcraft_doc::Document>, usize) {+) -> (Option<designcraft_doc::Document>, usize, bool) {     use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef};-    let todo: Vec<(usize, ItemId)> =-        d.spreads.iter().enumerate().flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && pred(it)).map(move |it| (si, it.id))).collect();+    let mut todo = Vec::new();+    for (si, sp) in d.spreads.iter().enumerate() {+        let mut stack: Vec<_> =+            sp.items.iter().filter(|it| d.layer(it.layer).is_some_and(|l| l.visible && l.printable)).map(|it| (&**it, 0)).collect();+        while let Some((it, depth)) = stack.pop() {+            if it.hidden || it.nonprinting {+                continue;+            }+            if pred(it) {+                todo.push((si, it.id));+                // A rasterized ancestor already includes its descendants.+            } else if depth < MAX_STAND_IN_DEPTH {+                stack.extend(it.shown_children().map(|it| (&**it, depth + 1)));+            }+        }+    }     if todo.is_empty() {-        return (None, 0);+        return (None, 0, false);     }     let mut out = d.clone();     let mut rr = designcraft_render::Renderer::new();     rr.threads = designcraft_render::default_threads();     let k = ppi / 72.0;     let mut n = 0;+    let mut overprint = false;     for (si, id) in todo {         let Some(it) = d.item(id) else { continue };         let reach = it.bounds().inflate(             designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0,             designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0,         );-        // Just this object on its spread, no paper.-        let mut solo = d.clone();-        let sp = std::sync::Arc::make_mut(&mut solo.spreads[si]);-        sp.items.retain(|x| x.id == id);-        for pg in &mut sp.pages {-            pg.show_parent_items = false;-        }+        // Keep all story frames, wrap obstacles and page context available to composition.+        // The renderer captures just this item, in its parent-local coordinate space.         let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32);         if w as u64 * h as u64 > 60_000_000 {             continue;         }         let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0));-        let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() };+        let opts =+            designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, isolated_item: Some(id), ..Default::default() };         let img = rr.render(-            &solo,+            d,             cache,             &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }],             w,@@ -605,19 +616,82 @@ fn rasterize_where(             fill: Default::default(),         });         img_item.stroke.weight = 0.0;+        img_item.blend = it.blend;         img_item.alt_text = it.alt_text.clone();         img_item.export_options = it.export_options.clone();-        // Same place in the stacking order.-        let osp = std::sync::Arc::make_mut(&mut out.spreads[si]);-        if let Some(pos) = osp.items.iter().position(|x| x.id == id) {-            osp.items[pos] = std::sync::Arc::new(img_item);-            if keep_wrap && has_wrap(it, 0) {-                osp.items.insert(pos, std::sync::Arc::new(wrap_stand_in(it, 0)));-            }-            n += 1;+        // Same slot in the original hierarchy, leaving ancestor transforms,+        // blending and clipping intact. Resolve again after earlier insertions.+        let Some(mut loc) = out.find(id) else { continue };+        let Some(pos) = loc.path.pop() else { continue };+        let siblings = if loc.path.is_empty() {+            &mut std::sync::Arc::make_mut(&mut out.spreads[si]).items+        } else {+            let Some(items) = out.item_mut_at(&loc).and_then(Item::children_mut) else { continue };+            items+        };+        let Some(slot) = siblings.get_mut(pos) else { continue };+        *slot = std::sync::Arc::new(img_item);+        if contains_text(it, 0) {+            // Composition must still find the replaced frames, including frames before+            // and after this group in a threaded story. Nonprinting skips the whole tree.+            let mut stand_in = it.clone();+            stand_in.nonprinting = true;+            siblings.insert(pos, std::sync::Arc::new(stand_in));+        } else if keep_wrap && has_wrap(it, 0) {+            siblings.insert(pos, std::sync::Arc::new(wrap_stand_in(it, 0)));         }+        overprint |= painted_overprint(d, it, 0);+        n += 1;+    }+    (Some(out), n, overprint)+}++/// Text frames retained for layout after their visible appearance becomes an image.+fn contains_text(it: &designcraft_doc::Item, depth: usize) -> bool {+    depth <= MAX_STAND_IN_DEPTH && (it.text_frame().is_some() || it.children().iter().any(|c| contains_text(c, depth + 1)))+}++/// Unsupported object-paint overprint must remain visible when the paint becomes pixels.+/// Match the PDF backend's painted-gap rules (solid and clipped dashed strokes have no gap).+fn painted_overprint(d: &designcraft_doc::Document, it: &designcraft_doc::Item, depth: usize) -> bool {+    use designcraft_doc::{StrokeAlign, StrokeType};+    if depth > MAX_STAND_IN_DEPTH || it.hidden || it.nonprinting {+        return false;+    }+    if it.shown_children().any(|c| painted_overprint(d, c, depth + 1)) {+        return true;+    }+    if (!it.children().is_empty() && !it.has_nested_items()) || it.path.is_empty() {+        return false;+    }+    let paint =+        |name: &str, tint: f32| d.resolve_color(name, tint).is_some() || designcraft_color::swatch::resolve_gradient(&d.swatches, name).is_some();+    if it.fill.overprint && !it.fill.is_none() && paint(&it.fill.swatch, it.fill.tint) {+        return true;+    }+    let st = &it.stroke;+    if st.is_none() || !paint(&st.swatch, st.tint) {+        return false;+    }+    if st.overprint {+        return true;+    }+    if !st.gap_overprint || d.resolve_color(&st.gap_swatch, st.gap_tint).is_none() {+        return false;+    }+    let kind = d.stroke_kind(&st.kind);+    if kind.bands().is_some() || matches!(kind, StrokeType::Wavy | StrokeType::Hashed) {+        return true;+    }+    if it.path.is_closed() && st.align != StrokeAlign::Center {+        return false;+    }+    let length = designcraft_doc::path_length_bound(&it.path.to_bezpath());+    match kind {+        StrokeType::Dashed { pattern } => StrokeType::expandable_dashes(&pattern, length),+        StrokeType::Dotted => StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], length),+        _ => false,     }-    (Some(out), n) }  /// Whether `it`, its placed graphic or an item in it wraps text.@@ -727,12 +801,15 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {     let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0);     let (layered, layer_count) = rasterize_layered(base)?;     let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base);-    let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache);+    let (fx_doc, fx_count, overprint) = rasterize_effects(base2, &s.cache);     let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2);     let boxed = super::interchange::with_pdf_boxes(doc);     let doc: &designcraft_doc::Document = &boxed;     let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;     let mut r = r;+    if overprint && !r.warnings.iter().any(|w| w == designcraft_pdf::OVERPRINT_WARNING) {+        r.warnings.push(designcraft_pdf::OVERPRINT_WARNING.into());+    }     // Transparency Blend Space: the page group of pages with transparency.     let spreads = designcraft_pdf::sheet_spreads(doc, &opts);     let blended: Vec<bool> = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect();@@ -1009,37 +1086,109 @@ mod text_tests {         assert_eq!(text.matches("/S/Transparency/CS/DeviceCMYK").count(), 1, "only the page with transparency");     } +    #[test]+    fn rasterized_group_preserves_thread_composition_and_captured_middle_frame() {+        use designcraft_doc::{ItemId, SpreadRef};+        use designcraft_geom::{Affine, Vec2};+        for nested in [false, true] {+            let mut s = Session::new();+            s.execute("file.new", &json!({"width":300,"height":100})).unwrap();+            s.execute("edit.transparencyBlendSpace", &json!({"space":"rgb"})).unwrap();+            let mut frames = Vec::new();+            for x in [10, 110, 210] {+                let text =+                    if frames.is_empty() { "First Second Third Fourth Fifth Sixth Seventh Eighth Ninth Tenth ".repeat(40) } else { String::new() };+                frames.push(+                    s.execute("frame.create", &json!({"rect":[x,10,x+80,90],"content":"text","text":text,"caret":false})).unwrap()["id"]+                        .as_u64()+                        .unwrap(),+                );+            }+            for pair in frames.windows(2) {+                s.execute("text.thread", &json!({"from":pair[0],"to":pair[1]})).unwrap();+            }+            let mark = s.execute("frame.create", &json!({"rect":[110,5,115,8]})).unwrap()["id"].clone();+            let group = s.execute("object.group", &json!({"ids":[frames[1],mark]})).unwrap()["id"].as_u64().unwrap();+            s.execute("object.transparencyGroup", &json!({"ids":[group],"knockout":true})).unwrap();+            if nested {+                let mark = s.execute("frame.create", &json!({"rect":[195,5,198,8]})).unwrap()["id"].clone();+                let outer = s.execute("object.group", &json!({"ids":[group,mark]})).unwrap()["id"].clone();+                s.execute("transform.move", &json!({"ids":[outer],"dx":13,"dy":2})).unwrap();+                s.execute("transform.scale", &json!({"ids":[outer],"sx":0.9,"sy":0.9})).unwrap();+            }+            let d = &s.doc().unwrap().doc;+            let story = d.item(ItemId(frames[0])).unwrap().text_frame().unwrap().story;+            let composed = s.cache.get(d, story, None);+            assert_eq!(composed.frames.len(), 3);+            assert!(composed.frames[1].range.start > 0 && composed.frames[2].range.start > composed.frames[1].range.start);+            let it = d.item(ItemId(group)).unwrap();+            let reach = it.bounds().inflate(+                designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0,+                designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0,+            );+            let k = 300.0 / 72.0;+            let (w, h) = ((reach.width() * k).ceil() as u32, (reach.height() * k).ceil() as u32);+            let mut renderer = designcraft_render::Renderer::new();+            let hidden = frames.iter().filter(|id| **id != frames[1]).map(|id| ItemId(*id)).collect();+            let parent = d.parent_xf(&d.find(ItemId(group)).unwrap());+            let expected = renderer.render(+                d,+                &s.cache,+                &[designcraft_render::Placed { spread: SpreadRef::Doc(0), xf: Affine::translate(Vec2::ZERO) }],+                w,+                h,+                Affine::scale(k) * Affine::translate((-reach.x0, -reach.y0)) * parent.inverse(),+                &designcraft_render::RenderOptions { paper: false, printing_only: true, hidden, ..Default::default() },+            );+            let (fx, n, _) = super::rasterize_effects(d, &s.cache);+            assert_eq!(n, 1);+            let fx = fx.unwrap();+            let after = s.cache.get(&fx, story, None);+            assert_eq!(+                after.frames.iter().map(|f| (f.frame, f.range.clone())).collect::<Vec<_>>(),+                composed.frames.iter().map(|f| (f.frame, f.range.clone())).collect::<Vec<_>>()+            );+            let asset = fx.assets.values().find(|a| a.name.starts_with("effects-")).unwrap();+            assert_eq!(asset.data.as_slice(), expected.to_png(), "captured text must keep its original story offset, nested={nested}");+        }+    }+     #[test]     fn objects_with_soft_effects_keep_their_text_wrap_in_pdf() {-        let mut s = Session::new();-        s.execute("file.new", &json!({})).unwrap();-        let text = "Words wrap around the objects on this page. ".repeat(60);-        let t = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text", "text": text, "caret": false})).unwrap()["id"]-            .as_u64()-            .unwrap();-        // A feathered frame with a contour wrap of the frame itself.-        let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone();-        s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap();-        s.execute("object.textWrap", &json!({"ids": [a], "mode": "contour", "contour": "sameAsClipping", "offset": 6})).unwrap();-        // A feathered frame whose placed graphic has a bounding-box wrap.-        let png = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![90; 40 * 40 * 4] }.to_png();-        let b = s.execute("frame.create", &json!({"rect": [300, 400, 400, 500]})).unwrap()["id"].as_u64().unwrap();-        s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "a.png", "frame": b})).unwrap();-        s.execute("object.textWrap", &json!({"ids": [b], "content": true, "mode": "boundingBox", "offset": 6})).unwrap();-        s.execute("object.feather", &json!({"ids": [a, b], "width": 14})).unwrap();--        let d = &s.doc().unwrap().doc;-        let sid = d.item(designcraft_doc::ItemId(t)).and_then(|it| it.text_frame()).unwrap().story;-        let before = designcraft_compose::frame_specs(d, sid)[0].exclusions.clone();-        assert_eq!(before.len(), 2);-        let (fx, n) = super::rasterize_effects(d, &s.cache);-        assert_eq!(n, 2);-        let fx = fx.unwrap();-        assert_eq!(designcraft_compose::frame_specs(&fx, sid)[0].exclusions, before);-        // The objects that keep the wrap don't print; their images do.-        let sp = &fx.spreads[0];-        assert_eq!(sp.items.iter().filter(|it| it.nonprinting).count(), 2);-        assert_eq!(sp.items.iter().filter(|it| !it.nonprinting && it.graphic().is_some()).count(), 2);+        for nested in [false, true] {+            let mut s = Session::new();+            s.execute("file.new", &json!({})).unwrap();+            let text = "Words wrap around the objects on this page. ".repeat(60);+            let t = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text", "text": text, "caret": false})).unwrap()["id"]+                .as_u64()+                .unwrap();+            // A feathered frame with a contour wrap of the frame itself.+            let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone();+            s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap();+            s.execute("object.textWrap", &json!({"ids": [a], "mode": "contour", "contour": "sameAsClipping", "offset": 6})).unwrap();+            // A feathered frame whose placed graphic has a bounding-box wrap.+            let png = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![90; 40 * 40 * 4] }.to_png();+            let b = s.execute("frame.create", &json!({"rect": [300, 400, 400, 500]})).unwrap()["id"].as_u64().unwrap();+            s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "a.png", "frame": b})).unwrap();+            s.execute("object.textWrap", &json!({"ids": [b], "content": true, "mode": "boundingBox", "offset": 6})).unwrap();+            s.execute("object.feather", &json!({"ids": [a, b], "width": 14})).unwrap();++            if nested {+                s.execute("object.group", &json!({"ids":[a,b]})).unwrap();+            }+            let d = &s.doc().unwrap().doc;+            let sid = d.item(designcraft_doc::ItemId(t)).and_then(|it| it.text_frame()).unwrap().story;+            let before = designcraft_compose::frame_specs(d, sid)[0].exclusions.clone();+            assert_eq!(before.len(), 2);+            let (fx, n, _) = super::rasterize_effects(d, &s.cache);+            assert_eq!(n, 2);+            let fx = fx.unwrap();+            assert_eq!(designcraft_compose::frame_specs(&fx, sid)[0].exclusions, before);+            // The objects that keep the wrap don't print; their images do.+            let items: Vec<_> = fx.all_items().into_iter().filter_map(|id| fx.item(id)).collect();+            assert_eq!(items.iter().filter(|it| it.nonprinting).count(), 2);+            assert_eq!(items.iter().filter(|it| !it.nonprinting && it.graphic().is_some()).count(), 2);+        }     }      #[test]diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs--- a/crates/engine/tests/it/main.rs+++ b/crates/engine/tests/it/main.rs@@ -14,6 +14,7 @@ mod param_keys; mod pdf_backend; mod preflight_resolution; mod primary_frame_columns;+mod print_state; #[cfg(not(target_arch = "wasm32"))] mod save_state_atomicity; mod stroke_tint_range;diff --git a/crates/engine/tests/it/pdf_backend.rs b/crates/engine/tests/it/pdf_backend.rs--- a/crates/engine/tests/it/pdf_backend.rs+++ b/crates/engine/tests/it/pdf_backend.rs@@ -39,7 +39,7 @@ fn layered_pdf_with_box(second_name: &str, page_box: &str) -> Vec<u8> {     ]) } -fn rendered(bytes: &[u8]) -> designcraft_render::Rendered {+pub(super) fn rendered(bytes: &[u8]) -> designcraft_render::Rendered {     let pm = designcraft_render::render_pdf_page(bytes, 0, 100).expect("render synthetic PDF");     designcraft_render::Rendered {         width: pm.width().into(),@@ -49,7 +49,7 @@ fn rendered(bytes: &[u8]) -> designcraft_render::Rendered { }  // Optional local evidence output; no binary fixtures are checked in.-fn evidence(name: &str, bytes: &[u8]) {+pub(super) fn evidence(name: &str, bytes: &[u8]) {     if let Some(dir) = std::env::var_os("CRAFT_PDF_EVIDENCE_DIR") {         std::fs::create_dir_all(&dir).unwrap();         std::fs::write(std::path::Path::new(&dir).join(name), bytes).unwrap();diff --git a/crates/engine/tests/it/print_state.rs b/crates/engine/tests/it/print_state.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/it/print_state.rs@@ -0,0 +1,161 @@+//! Synthetic print-state diagnostics; no private files or proprietary fonts.+use super::pdf_backend::{evidence, rendered};+use designcraft_engine::Session;+use designcraft_engine::cmd::base64_decode;+use serde_json::json;++fn session() -> Session {+    let mut s = Session::new();+    s.execute("file.new", &json!({"width":100,"height":100,"pages":1})).unwrap();+    s.execute("edit.transparencyBlendSpace", &json!({"space":"rgb"})).unwrap();+    for (name, color) in+        [("Cyan", json!([0, 255, 255])), ("Magenta", json!([255, 0, 255])), ("Red", json!([255, 0, 0])), ("Blue", json!([0, 0, 255]))]+    {+        s.execute("swatch.create", &json!({"name":name,"color":color})).unwrap();+    }+    s+}++fn rectangle(s: &mut Session, swatch: &str) {+    s.execute("frame.create", &json!({"rect":[10,10,90,90]})).unwrap();+    s.execute("object.fill", &json!({"swatch":swatch})).unwrap();+}++#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn rgb_multiply_and_alpha_agree_between_screen_and_export() {+    for (name, back, front, opacity, blend, expected) in+        [("multiply", "Cyan", "Magenta", 1.0, "multiply", [0u8, 0, 255]), ("alpha", "Red", "Blue", 0.5, "normal", [128u8, 0, 128])]+    {+        let mut s = session();+        rectangle(&mut s, back);+        rectangle(&mut s, front);+        s.execute("object.opacity", &json!({"opacity":opacity,"blend":blend})).unwrap();+        let mut renderer = designcraft_render::Renderer::new();+        renderer.threads = 0;+        let screen = renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap();+        let result = s.execute("file.exportPdf", &json!({})).unwrap();+        let pdf = base64_decode(result["base64"].as_str().unwrap());+        let export = rendered(&pdf);+        for (label, pixel) in [("screen", screen.pixel(50, 50)), ("PDF", export.pixel(50, 50))] {+            for channel in 0..3 {+                assert!(pixel[channel].abs_diff(expected[channel]) <= 3, "{name} {label}: {pixel:?} expected {expected:?}");+            }+        }+        evidence(&format!("print-{name}.pdf"), &pdf);+        evidence(&format!("print-{name}-screen.png"), &screen.to_png());+        evidence(&format!("print-{name}-export.png"), &export.to_png());+    }+}++fn overprint_warnings(kind: &str, enabled: bool, visible: bool, grouped: bool) -> Vec<String> {+    let mut s = session();+    s.execute("swatch.create", &json!({"name":"Process Cyan","color":{"c":100,"m":0,"y":0,"k":0}})).unwrap();+    s.execute("swatch.create", &json!({"name":"Process Magenta","color":{"c":0,"m":100,"y":0,"k":0}})).unwrap();+    rectangle(&mut s, if kind == "fill" { "Process Cyan" } else { "[None]" });+    if kind != "fill" {+        let stroke_kind = match kind {+            "gap" => json!({"kind":"dashed","pattern":[8,8]}),+            "outlineGap" => json!({"kind":"thickThin"}),+            _ => json!({"kind":"solid"}),+        };+        s.execute("object.stroke", &json!({"swatch":"Process Cyan","weight":8,"type":stroke_kind,"gapSwatch":"Process Magenta"})).unwrap();+    }+    let flag = match kind {+        "fill" => "overprintFill",+        "stroke" => "overprintStroke",+        _ => "overprintGap",+    };+    let mut attrs = json!({"nonprinting":!visible});+    attrs[flag] = json!(enabled);+    s.execute("object.attributes", &attrs).unwrap();+    if grouped {+        let painted = s.doc().unwrap().selection.items[0];+        let other = s.execute("frame.create", &json!({"rect":[0,0,2,2]})).unwrap()["id"].clone();+        let g = s.execute("object.group", &json!({"ids":[painted.0,other]})).unwrap()["id"].clone();+        s.execute("object.transparencyGroup", &json!({"ids":[g],"knockout":true})).unwrap();+        let other = s.execute("frame.create", &json!({"rect":[95,95,99,99]})).unwrap()["id"].clone();+        s.execute("object.group", &json!({"ids":[g,other]})).unwrap();+    }+    let result = s.execute("file.exportPdf", &json!({})).unwrap();+    result["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str().map(str::to_string)).collect()+}++#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn exported_stroke_and_gap_overprint_are_never_silently_dropped() {+    let mut missing = Vec::new();+    for (kind, grouped) in ["fill", "stroke", "gap", "outlineGap"].into_iter().flat_map(|kind| [(kind, false), (kind, true)]) {+        let warnings = overprint_warnings(kind, true, true, grouped);+        if !warnings.iter().any(|w| w.contains("overprint is not exported yet")) {+            missing.push((kind, grouped));+        }+        for (on, visible) in [(false, true), (true, false)] {+            let warnings = overprint_warnings(kind, on, visible, grouped);+            assert!(!warnings.iter().any(|w| w.contains("overprint")), "inactive {kind}: {warnings:?}");+        }+    }+    assert!(missing.is_empty(), "silent overprint loss: {missing:?}");+    for grouped in [false, true] {+        assert!(!overprint_warnings("inactiveGap", true, true, grouped).iter().any(|w| w.contains("overprint")), "solid strokes have no painted gap");+    }+}++#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn nested_knockout_group_preserves_screen_compositing_on_pdf_export() {+    for (depth, transformed, own_blend) in [(0, false, false), (1, false, false), (2, true, false), (0, false, true), (1, false, true)] {+        let mut s = session();+        s.execute("swatch.create", &json!({"name":"Yellow","color":[255,255,0]})).unwrap();+        if transformed || own_blend {+            rectangle(&mut s, "Cyan");+        }+        let mut ids = Vec::new();+        for (rect, swatch) in [(json!([10, 10, 70, 90]), "Yellow"), (json!([40, 10, 90, 90]), "Magenta")] {+            let id = s.execute("frame.create", &json!({"rect":rect})).unwrap()["id"].clone();+            s.execute("object.fill", &json!({"swatch":swatch})).unwrap();+            s.execute("object.opacity", &json!({"opacity":0.5})).unwrap();+            ids.push(id);+        }+        let group = s.execute("object.group", &json!({"ids":ids})).unwrap();+        s.execute("object.transparencyGroup", &json!({"ids":[group["id"]],"knockout":true})).unwrap();+        if own_blend {+            s.execute("object.opacity", &json!({"ids":[group["id"]],"opacity":0.7,"blend":"multiply"})).unwrap();+        }+        let mut outer = group["id"].clone();+        for _ in 0..depth {+            let sibling = s.execute("frame.create", &json!({"rect":[0,0,5,5]})).unwrap();+            outer = s.execute("object.group", &json!({"ids":[outer,sibling["id"]]})).unwrap()["id"].clone();+        }+        if transformed {+            s.execute("transform.scale", &json!({"ids":[outer],"sx":0.8,"sy":0.8})).unwrap();+            s.execute("transform.move", &json!({"ids":[outer],"dx":4,"dy":-2})).unwrap();+            s.execute("object.opacity", &json!({"ids":[outer],"opacity":0.7,"blend":"multiply"})).unwrap();+        }+        s.execute("frame.create", &json!({"rect":[75,75,85,85]})).unwrap();+        s.execute("object.fill", &json!({"swatch":"Blue"})).unwrap();+        let mut renderer = designcraft_render::Renderer::new();+        renderer.threads = 0;+        let screen = renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap();+        let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap();+        let result = s.execute("file.exportPdf", &json!({})).unwrap();+        assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before, "export leaves the live document unchanged");+        let pdf = base64_decode(result["base64"].as_str().unwrap());+        let export = rendered(&pdf);+        evidence(&format!("knockout-depth-{depth}-blend-{own_blend}.pdf"), &pdf);+        evidence(&format!("knockout-depth-{depth}-blend-{own_blend}-screen.png"), &screen.to_png());+        evidence(&format!("knockout-depth-{depth}-blend-{own_blend}-export.png"), &export.to_png());+        for (x, y) in [(50, 50), (30, 50), (80, 80)] {+            let a = screen.pixel(x, y);+            let raw = export.pixel(x, y);+            let b = raw.map(|v| ((u32::from(v) * u32::from(raw[3]) + 255 * (255 - u32::from(raw[3])) + 127) / 255) as u8);+            for channel in 0..3 {+                assert!(a[channel].abs_diff(b[channel]) <= 3, "depth={depth} at {x},{y} screen={a:?} PDF={b:?}");+            }+        }+        assert!(+            result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("knockout")),+            "raster fallback is disclosed: {result}"+        );+    }+}diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs--- a/crates/pdf/src/export.rs+++ b/crates/pdf/src/export.rs@@ -949,7 +949,7 @@ impl Exporter<'_> {             && let Some(paint) = self.fill_paint(&it.fill, bp.bounding_box())         {             if it.fill.overprint {-                self.warn("overprint is not exported yet");+                self.warn(crate::OVERPRINT_WARNING);             }             s.set_stroke(None);             s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));@@ -1143,6 +1143,9 @@ impl Exporter<'_> {             let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return };             c.into()         };+        if st.overprint {+            self.warn(crate::OVERPRINT_WARNING);+        }         let closed = it.path.is_closed();         let mut cap = match st.cap {             designcraft_doc::Cap::Butt => krilla::paint::LineCap::Butt,@@ -1158,6 +1161,9 @@ impl Exporter<'_> {             if let Some(g) = self.swatch_color(&st.gap_swatch, st.gap_tint)                 && st.gap_swatch != designcraft_color::swatch::NONE             {+                if st.gap_overprint {+                    self.warn(crate::OVERPRINT_WARNING);+                }                 s.set_stroke(Some(Stroke { paint: g.into(), width: st.weight as f32, opacity: NormalizedF32::ONE, ..Default::default() }));                 s.set_fill(None);                 s.draw_path(path);@@ -1213,6 +1219,9 @@ impl Exporter<'_> {             && let Some(g) = self.swatch_color(&st.gap_swatch, st.gap_tint)         {             // Gap colour under dashes and dots.+            if st.gap_overprint {+                self.warn(crate::OVERPRINT_WARNING);+            }             s.set_stroke(Some(Stroke { paint: g.into(), width: width as f32, opacity: NormalizedF32::ONE, ..Default::default() }));             s.draw_path(path);         }diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs--- a/crates/pdf/src/lib.rs+++ b/crates/pdf/src/lib.rs@@ -41,6 +41,10 @@ pub use export::{BookletKind, BookletOptions, booklet_pairs, export_booklet, exp pub use pdfx::{check_pdfx4, cmyk_group_spaces, has_rgb_groups, make_pdfx4}; pub use transitions::{add_blend_space, add_catalog_entries, add_page_entries, add_transitions}; +/// The export warning for overprint, which isn't written to the PDF yet. Shared so callers that+/// add it themselves (for overprint lost in rasterized captures) don't report it twice.+pub const OVERPRINT_WARNING: &str = "overprint is not exported yet";+ /// PDF standard to target. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Standard {diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs--- a/crates/pdf/src/text.rs+++ b/crates/pdf/src/text.rs@@ -484,7 +484,7 @@ impl Exporter<'_> {             return;         }         if st.overprint_fill || st.overprint_stroke {-            self.warn("overprint is not exported yet");+            self.warn(crate::OVERPRINT_WARNING);         }         // Stroke behind fill: a double-width stroke first, as outlines so the text is not         // duplicated, then the fill over its inner half. With no fill, the stroke is clippeddiff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs--- a/crates/render/src/blendspace.rs+++ b/crates/render/src/blendspace.rs@@ -178,6 +178,19 @@ fn pipeline(doc: &Document, sr: SpreadRef, opts: &RenderOptions) -> Pipeline {     }     let Some(sp) = doc.spread(sr) else { return Pipeline::Rgb };     let mut transparent = false;+    if let Some(id) = opts.isolated_item {+        // Its layer is its top-level item's (see `isolated_target`), not its own.+        let Some(item) = crate::isolated_target(doc, sr, id, opts) else { return Pipeline::Rgb };+        let mut captured = item.clone();+        captured.blend = DcBlend::Normal;+        return if !inspect_item(doc, &captured, opts, 0, &mut transparent) {+            Pipeline::Proof+        } else if transparent {+            Pipeline::Cmyk+        } else {+            Pipeline::Rgb+        };+    }     let mut inspect = |item: &Item| {         if !doc.layer(item.layer).is_some_and(|l| l.visible && (!opts.printing_only || l.printable)) {             return true;@@ -285,6 +298,10 @@ impl Renderer {     /// 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<Rect> {+        if f.opts.isolated_item.is_some() {+            // The viewport is already the selected item's parent-local effect bounds.+            return None;+        }         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));@@ -458,6 +475,70 @@ mod tests {         assert!(pixel[..3].iter().all(|v| *v > 235), "{pixel:?}");     } +    #[test]+    fn isolated_capture_keeps_cmyk_ink_passes_and_ignores_sibling_blends() {+        let mut doc = document();+        doc.settings.blend_space = designcraft_doc::BlendSpace::Cmyk;+        dodge(&mut doc, SpreadRef::Doc(0));+        let children = std::mem::take(&mut Arc::make_mut(&mut doc.spreads[0]).items);+        let id = ItemId(doc.alloc());+        let mut group = Item::new(id, doc.default_layer(), Shape::Group, Default::default());+        group.content = Content::Group { items: children };+        doc.insert_item(SpreadRef::Doc(0), group, None).unwrap();+        let other = rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(20.0, 20.0, 90.0, 90.0), "Lilac", DcBlend::Hue);+        let placements = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];+        let opts = RenderOptions { paper: false, blend_space_view: true, hidden: vec![other], ..Default::default() };+        let expected = Renderer::new().render(&doc, &Cache::new(), &placements, 160, 120, Affine::IDENTITY, &opts);+        let mut capture = Renderer::new();+        let actual = capture.render(+            &doc,+            &Cache::new(),+            &placements,+            160,+            120,+            Affine::IDENTITY,+            &RenderOptions { paper: false, blend_space_view: true, isolated_item: Some(id), ..Default::default() },+        );+        assert_eq!(capture.cmyk_passes, 2, "capture must use the existing ink-channel pipeline");+        assert_eq!(actual.pixels, expected.pixels, "only the selected subtree paints; other document content remains available for layout");+    }++    #[test]+    fn isolated_capture_of_a_group_child_follows_the_groups_layer() {+        for blend_space in [designcraft_doc::BlendSpace::Rgb, designcraft_doc::BlendSpace::Cmyk] {+            let mut doc = document();+            doc.settings.blend_space = blend_space;+            // Grouping keeps each child's layer; the group takes the first child's. Normal+            // drawing follows the group's layer only, so the capture must too.+            let hidden = designcraft_doc::LayerId(doc.alloc());+            let mut layer = doc.layers[0].clone();+            (layer.id, layer.name, layer.visible) = (hidden, "Hidden".into(), false);+            doc.layers.push(layer);+            let sibling = rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(0.0, 0.0, 20.0, 20.0), "Lilac", DcBlend::Normal);+            let child = rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(40.0, 40.0, 100.0, 100.0), "Lilac", DcBlend::Multiply);+            doc.item_mut(child).unwrap().layer = hidden;+            let items = vec![doc.remove_item(sibling).unwrap(), doc.remove_item(child).unwrap()];+            let mut group = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Group, Default::default());+            group.content = Content::Group { items };+            doc.insert_item(SpreadRef::Doc(0), group, None).unwrap();+            let placements = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];+            let shown = Renderer::new().render(+                &doc,+                &Cache::new(),+                &placements,+                160,+                120,+                Affine::IDENTITY,+                &RenderOptions { paper: false, ..Default::default() },+            );+            assert!(shown.pixel(70, 70)[3] > 0, "{blend_space:?}: the child shows through its group's layer");+            let opts = RenderOptions { paper: false, blend_space_view: true, printing_only: true, isolated_item: Some(child), ..Default::default() };+            let captured = Renderer::new().render(&doc, &Cache::new(), &placements, 160, 120, Affine::IDENTITY, &opts);+            assert!(captured.pixel(70, 70)[3] > 0, "{blend_space:?}: the capture must not be blank");+            assert_eq!(captured.pixel(10, 10)[3], 0, "{blend_space:?}: only the selected child paints");+        }+    }+     #[test]     fn cmyk_nested_blends_match_top_level_blends() {         let mut doc = document();diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -124,6 +124,10 @@ pub struct RenderOptions {     pub paper: bool,     /// Skip non-printing items and layers (Preview mode, export).     pub printing_only: bool,+    /// Capture this item's subtree in parent-local space while retaining the full+    /// document for text composition. Its own blend mode is applied by the caller+    /// when compositing the captured image over its backdrop.+    pub isolated_item: Option<designcraft_doc::ItemId>,     /// Items not to draw (live drag previews).     pub hidden: Vec<designcraft_doc::ItemId>,     /// Draw text smaller than this many pixels as grey bars ("greeking"); 0 = never.@@ -184,6 +188,7 @@ impl Default for RenderOptions {             background: None,             paper: true,             printing_only: false,+            isolated_item: None,             hidden: vec![],             greek_below_px: 0.0,             page_shadow: false,@@ -381,7 +386,7 @@ impl Renderer {             let visible = pl.xf.inverse().transform_rect_bbox(canvas_visible);             let bounds = sp.bounds();             // Cull spreads far outside the viewport (pasteboard items may extend; allow a margin).-            if !rect_overlaps(bounds.inflate(2000.0, 2000.0), visible) {+            if opts.isolated_item.is_none() && !rect_overlaps(bounds.inflate(2000.0, 2000.0), visible) {                 continue;             }             let f = Frame { doc, cache, view: sview, visible, px, opts, mt };@@ -435,6 +440,18 @@ impl Renderer {     fn draw_spread(&mut self, ctx: &mut RenderContext, f: &Frame, sr: SpreadRef) {         let doc = f.doc;         let Some(sp) = doc.spread(sr) else { return };+        if let Some(id) = f.opts.isolated_item {+            if blendspace::uses_cmyk(doc, sr, f.opts) {+                self.draw_cmyk_spread(ctx, f, sr);+                return;+            }+            if let Some(it) = isolated_target(doc, sr, id, f.opts) {+                let mut captured = it.clone();+                captured.blend = DcBlend::Normal;+                self.draw_item(ctx, f, &captured, Affine::IDENTITY, ShownOn::Own);+            }+            return;+        }         // Paper.         if f.opts.paper {             let paper = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE);@@ -1008,6 +1025,15 @@ fn plate_pixmap(pm: &std::sync::Arc<vello_cpu::Pixmap>, plate: u8) -> std::sync:     out } +/// The item an isolated render captures, if it is on `sr` and its layer shows. The layer is the+/// top-level item's: a group's children keep their own layers, but normal drawing follows only+/// the group's, so a nested child on a hidden layer still shows through a visible group.+pub(crate) fn isolated_target<'a>(doc: &'a Document, sr: SpreadRef, id: designcraft_doc::ItemId, opts: &RenderOptions) -> Option<&'a Item> {+    let loc = doc.find(id).filter(|loc| loc.spread == sr)?;+    let top = doc.spread(sr)?.items.get(*loc.path.first()?)?;+    doc.layer(top.layer).is_some_and(|l| l.visible && (!opts.printing_only || l.printable)).then(|| doc.item_at(&loc)).flatten()+}+ fn rect_overlaps(a: Rect, b: Rect) -> bool {     a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0 }diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -116,6 +116,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 10–20 h. **Doc:** [localization-parity.md](localization-parity.md)  ### 9. PDF output presets and standards (FF)+- **Print-state limitation:** PDF output does not write overprint; explicitly overprinting fills,+  text, strokes and painted stroke gaps report a warning. Nested knockout groups and soft+  effects use the existing warned raster fallback, not native PDF knockout groups. - **Missing:** PDF/X-1a and PDF/X-3 (no flattener to PDF 1.3/1.4), Adobe PDF Presets and   `.joboptions` load/save, output-intent choice beyond the built-in profile, PDF/UA validation;   #159, #32.diff --git a/docs/print-state-regressions.md b/docs/print-state-regressions.mdnew file mode 100644--- /dev/null+++ b/docs/print-state-regressions.md@@ -0,0 +1,15 @@+# Print-state export regressions++A synthetic knockout group with overlapping half-opacity yellow and magenta rectangles rendered correctly on screen and through the existing top-level PDF raster fallback. Nesting it inside a normal group bypassed that fallback: the overlap blended instead of knocking out. Independently rasterized with Poppler over white, the overlap changed from RGB (255,127,191) before the fix to (255,127,255), matching the screen.++The export helper now traverses visible group children to bounded depth, captures the affected item in parent-local coordinates and replaces it in its original sibling slot. Ancestor transforms, opacity, blend mode and clipping remain outside the rasterized child. A selected raster ancestor includes its descendants once. Capture retains the complete source document for composition, including story frames outside the group. Replaced text frames remain as nonprinting layout placeholders, so following frames keep their story offsets. The selected item’s own blend mode is applied to its replacement image; it is not baked against a missing backdrop. Existing text-wrap stand-ins are inserted beside the image at the same hierarchy level. Tests cover top-level and nested knockout, two nested levels with ancestor scale/translation/Multiply/opacity, an opaque sibling above, unchanged live document data, own-item Multiply, middle-of-thread captures and nested soft-effect wrap exclusions.++The renderer’s explicit CMYK-view option retains its ink-channel passes when capturing one subtree, with unrelated sibling blends excluded from that capture’s pipeline choice. A synthetic capture/full-context comparison covers this path. The export fallback keeps its existing render options.++The fallback remains a warned 300 ppi image, not native editable knockout content. This change addresses document-spread group children; it does not establish parent-page, anchored-object, arbitrary deep-tree or physical-print parity.++A separate diagnostic omission silently dropped explicit stroke and stroke-gap overprint. Export now emits the existing unsupported-overprint warning when it paints an overprinting stroke or gap (dashes/dots or outlined styles). Before replacing an effect group, a bounded scan preserves warnings from painted descendant fills, strokes and gaps. Inactive flags, nonprinting objects and solid strokes without a painted gap do not produce these new warnings. This does not implement PDF overprint or change automatic black-overprint behavior.++The existing engine PDF backend fixtures and evidence helper are reused. Analytical RGB Multiply and half-opacity controls pass; overprint is tested as an explicitly reported unsupported state. These are synthetic tests inspired by a private INDD/PDF study, not evidence that either defect caused the user's uncertain reference differences. No private documents, font programs or Adobe code are included.++Independent Poppler checks of five synthetic knockout PDFs, including own-item Multiply and transformed ancestors, match the screen within one RGB level at 15 stable interior samples. Transformed boundary pixels can differ through raster interpolation; this check does not establish general visual or print fidelity.diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md--- a/docs/target-app-parity.md+++ b/docs/target-app-parity.md@@ -390,7 +390,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). - Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width.-- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups).+- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images, including inside groups; ancestor transforms/compositing and text-wrap stand-ins are retained (PDF output has no native knockout groups). - Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step). - Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML. - Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters.@@ -403,7 +403,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Input methods (IME) in the Type tool: a Chinese, Japanese or Korean composition shows its marked text in place in the story, underlined (the clause being converted twice), with the IME's candidate window at the caret through zoom, scrolling, view rotation and vertical text; the commit is one `text.insert` and one undo step, a cancelled composition leaves the document as it was; keys, shortcuts, the clipboard and Undo wait while the IME composes, and a click or another command keeps the marked text as typed. The control channel's `ui.ime` and the MCP `ime` tool (headless too) send IME events. Not yet checked by hand with a system IME (macOS, Windows, Linux, web). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.-- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen.+- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen. PDF overprint remains unsupported; explicit fill/text/stroke/painted-gap requests are reported as export warnings. - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen as rich black (InDesign's default) or accurately as dark grey (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads.