From 7074dc8a856a4b63780fca721862570a193ed0bf Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 09:31:08 +0100 Subject: [PATCH 01/38] M6: preserve per-placement graphic layer options --- ROADMAP.md | 3 +- apps/designcraft-cli/src/perf.rs | 1 + crates/compose/src/tests.rs | 3 + crates/doc/src/assets.rs | 32 ++++++++ crates/doc/src/item.rs | 26 ++++++ crates/engine/src/cmd/datamerge/fill.rs | 1 + crates/engine/src/cmd/export.rs | 2 + crates/engine/src/cmd/file.rs | 3 + crates/engine/src/cmd/links.rs | 3 + crates/engine/src/cmd/mathexpr.rs | 1 + crates/engine/src/cmd/media.rs | 2 + crates/engine/src/cmd/preflight.rs | 22 +++++ crates/engine/src/cmd/table.rs | 1 + crates/engine/src/sample.rs | 1 + crates/engine/tests/graphic_layer_options.rs | 82 +++++++++++++++++++ crates/epub/src/lib.rs | 3 + crates/epub/tests/it/grouped_reading_order.rs | 1 + crates/format/src/lib.rs | 14 +++- crates/idml/src/export.rs | 26 ++++++ crates/idml/src/import.rs | 8 ++ crates/idml/src/tests.rs | 1 + crates/idml/src/tests/assets.rs | 71 ++++++++++++++++ crates/pdf/src/export.rs | 4 + crates/pdf/src/pdfx.rs | 1 + crates/pdf/src/tests.rs | 1 + .../support/image_content_cache_contract.rs | 1 + crates/pptx/src/tests.rs | 1 + crates/render/src/lib.rs | 2 + crates/render/tests/it/colorize.rs | 1 + crates/render/tests/it/placed_pdf.rs | 1 + crates/tools/src/tests.rs | 4 + crates/ui-egui/src/panels/properties.rs | 1 + docs/gaps.md | 4 +- docs/graphic-layer-options.md | 40 +++++++++ 34 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 crates/engine/tests/graphic_layer_options.rs create mode 100644 docs/graphic-layer-options.md diff --git a/ROADMAP.md b/ROADMAP.md index 153555913..01c748d33 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,10 @@ # DesignCraft roadmap **Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) +- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md). - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 0c665f7e7..117adbc66 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -239,6 +239,7 @@ pub fn synthetic(spec: &Spec) -> Result { let (nw, nh) = (aw as f64, ah as f64); let s = (r.width() / nw).max(r.height() / nh); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s), diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3fc1c6ccb..85d73ecca 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -577,6 +577,7 @@ fn same_as_clipping_wrap_follows_the_clipping_path() { ); let triangle = PathData::single(SubPath::polyline(&[Point::new(75.0, 0.0), Point::new(150.0, 200.0), Point::new(0.0, 200.0)], true)); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (150.0, 200.0), xf: designcraft_geom::Affine::translate((150.0, 100.0)), @@ -725,6 +726,7 @@ fn an_image_contour_stops_at_its_frame() { let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, frame); // The image overhangs the frame on every side. it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(d.alloc()), size: (300.0, 250.0), xf: designcraft_geom::Affine::IDENTITY, @@ -769,6 +771,7 @@ fn a_clipping_path_contour_stops_at_its_frame() { true, )); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (150.0, 200.0), xf: designcraft_geom::Affine::translate((150.0, 100.0)), diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 0be5604c3..ed7679ee5 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -15,6 +15,37 @@ use crate::{Asset, AssetId, Content, Document, Item}; /// is treated as using every asset, so nothing it references is merged away or pruned. const MAX_DEPTH: usize = 64; +fn item_layer_options(it: &Item, depth: usize, overrides_only: bool) -> bool { + depth > MAX_DEPTH + || it.graphic().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides())) + || it.children().iter().any(|child| item_layer_options(child, depth + 1, overrides_only)) +} + +fn story_layer_options(st: &Story, depth: usize, overrides_only: bool) -> bool { + depth > MAX_DEPTH + || st.objects.iter().any(|object| item_layer_options(&object.item, depth + 1, overrides_only)) + || st.tables.values().flat_map(|table| &table.cells).any(|cell| { + cell.graphic.as_ref().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides())) + || story_layer_options(&cell.text, depth + 1, overrides_only) + }) + || st.notes.iter().chain(&st.endnotes).any(|note| story_layer_options(¬e.text, depth + 1, overrides_only)) +} + +impl Document { + /// Whether saving needs a reader that retains per-placement graphic layer settings. + /// Exceeding the traversal budget conservatively requires the newer format. + pub fn has_graphic_layer_options(&self) -> bool { + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, false)) + || self.stories.values().any(|story| story_layer_options(story, 0, false)) + } + + /// Includes parent items, anchored objects, graphic cells and nested notes. + pub fn has_graphic_layer_visibility_overrides(&self) -> bool { + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) + || self.stories.values().any(|story| story_layer_options(story, 0, true)) + } +} + impl Asset { /// Whether `other` holds the same file: the same type, PDF page and crop, link and bytes. Files at /// different paths stay apart even with identical bytes, since each can be updated or @@ -356,6 +387,7 @@ mod tests { fn graphic_item(id: u64, asset: u64, crop: f64) -> Item { let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); it.content = Content::Graphic(Graphic { + layer_options: None, asset: AssetId(asset), size: (10.0, 10.0), xf: Affine::translate((crop, 0.0)), diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index cfb13dc53..9c4169fe4 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -596,10 +596,34 @@ pub enum Fitting { CenterContent, } +/// Retained IDML layer choices for one placement, independent of shared asset bytes. +/// Scalar attributes are kept verbatim, including view/export/print state and link-update policy. +/// XML Self IDs are regenerated by the exporter and are not stored here. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphicLayerOptions { + pub attributes: std::collections::BTreeMap, + pub layers: Vec>, +} + +pub const GRAPHIC_LAYER_RENDERING_WARNING: &str = + "Placed graphic layer visibility overrides are preserved but are not applied to rendering or PDF output"; + +impl GraphicLayerOptions { + pub fn has_visibility_overrides(&self) -> bool { + self.layers.iter().any(|layer| { + layer.get("CurrentVisibility").is_some_and(|v| Some(v) != layer.get("OriginalVisibility")) + || ["HasViewState", "HasExportState", "HasPrintState"].iter().any(|key| layer.get(*key).is_some_and(|v| v == "true")) + }) + } +} + /// A placed graphic inside a frame. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Graphic { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_options: Option, pub asset: AssetId, /// Natural size of the graphic in points (pixels at its resolution, or PDF page size). pub size: (f64, f64), @@ -1642,6 +1666,7 @@ mod tests { #[test] fn graphics_without_a_clipping_path_load_and_save_as_before() { let g = Graphic { + layer_options: None, asset: AssetId(3), size: (40.0, 30.0), xf: Affine::translate((5.0, 5.0)), @@ -1653,6 +1678,7 @@ mod tests { fill: Default::default(), }; let v = serde_json::to_value(&g).unwrap(); + assert!(v.get("layerOptions").is_none(), "legacy graphics keep their existing representation"); assert!(v.get("clip").is_none(), "{v}"); assert_eq!(serde_json::from_value::(v.clone()).unwrap(), g); // A clip written with only its type and path gets the default options. diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index d4b2235e9..9547830a7 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -369,6 +369,7 @@ fn place_image( }; let mode = fitting_of(&options.fitting); let mut g = Graphic { + layer_options: None, asset: AssetId(0), size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 551dd7484..3797a9bf6 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -434,6 +434,7 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp ); let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w as f64, h as f64), xf: designcraft_geom::Affine::translate((r.x0, r.y0)) @@ -604,6 +605,7 @@ fn rasterize_where( ); let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach)); img_item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w as f64, h as f64), xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index bc6928362..39b92a942 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -710,6 +710,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k); validate_placement((bw, bh), r, xf, "file.place")?; it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (bw, bh), xf, @@ -736,6 +737,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { it.effects = e.clone(); } it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (bw, bh), xf, @@ -900,6 +902,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { validate_placement((nw, nh), frame_rect, xf, "place.drop")?; let r = s.edit(|d, sel| { let graphic = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (nw, nh), xf, diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index c39e05668..83fe16bf1 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1128,6 +1128,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: sid, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1173,6 +1174,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: missing, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1209,6 +1211,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: missing, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index b684f2457..e5342f79c 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -19,6 +19,7 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w, h), xf: Affine::translate(at), diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index 9e26fb0b4..a4e3f5ae6 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -32,6 +32,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; let r = it.inner_bounds(); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (r.width(), r.height()), xf: Affine::translate((r.x0, r.y0)), @@ -52,6 +53,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let r = super::finite_rect("file.place", Rect::new(x, y, x + w, y + h))?; let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w, h), xf: Affine::translate((x, y)), diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 19d64c1fc..c69050ace 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -249,6 +249,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { let xf = parent * it.xf; pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf))); let page = page_of(d, SpreadRef::Doc(si), it, parent); + if let Content::Graphic(g) = &it.content + && g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) + { + out.push(Issue { + severity: "warning", + kind: "unsupportedGraphicLayers", + message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(), + item: Some(it.id.0), + page, + }); + } match &it.content { Content::Graphic(g) => match d.assets.get(&g.asset) { None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }), @@ -307,6 +318,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { } } } + // Nested graphic cells and anchored/parent items may not have a page frame + // in the walk above. Keep the warning visible after a native save/reopen. + if !out.iter().any(|issue| issue.kind == "unsupportedGraphicLayers") && d.has_graphic_layer_visibility_overrides() { + out.push(Issue { + severity: "warning", + kind: "unsupportedGraphicLayers", + message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(), + item: None, + page: None, + }); + } out } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 776dd5e82..92b46fa77 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -2017,6 +2017,7 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result { let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?; let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?; cell.graphic = Some(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (nw, nh), xf, diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 0009b4adb..a77cb1d7d 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -79,6 +79,7 @@ fn graphic_frame(d: &mut Document, sr: SpreadRef, r: Rect, asset: AssetId, px: ( let (nw, nh) = (px.0 as f64, px.1 as f64); let k = (r.width() / nw).max(r.height() / nh); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k), diff --git a/crates/engine/tests/graphic_layer_options.rs b/crates/engine/tests/graphic_layer_options.rs new file mode 100644 index 000000000..8699a4a66 --- /dev/null +++ b/crates/engine/tests/graphic_layer_options.rs @@ -0,0 +1,82 @@ +//! Per-placement artwork layer metadata must survive native saves and stay visibly unsupported. +use std::io::{Cursor, Read, Write}; + +use designcraft_doc::{Content, GRAPHIC_LAYER_RENDERING_WARNING}; +use designcraft_engine::{DocState, Session}; +use serde_json::json; + +fn fixture() -> Vec { + let xml = r#" + + + + + + + + + "#; + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + z.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(xml.as_bytes()).unwrap(); + z.finish().unwrap().into_inner() +} + +#[test] +fn graphic_layer_options_survive_native_reopen_and_report_unsupported_output() { + let imported = designcraft_idml::import_idml_report(&fixture(), &|_| None).unwrap(); + assert!(imported.warnings.iter().any(|w| w.contains(GRAPHIC_LAYER_RENDERING_WARNING))); + let doc = imported.document; + let options = |d: &designcraft_doc::Document| { + d.spreads[0] + .items + .iter() + .filter_map(|i| match &i.content { + Content::Graphic(g) => g.layer_options.clone(), + _ => None, + }) + .collect::>() + }; + let expected = options(&doc); + let saved = designcraft_format::save(&doc).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&saved)).unwrap(); + let mut meta = String::new(); + z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap(); + assert_eq!(serde_json::from_str::(&meta).unwrap()["version"], 4, "old readers must not silently drop the settings"); + let reopened = designcraft_format::load(&saved).unwrap(); + assert_eq!(options(&reopened), expected); + let mut s = Session::new(); + s.add_document(DocState::new(reopened, None)); + let preflight = s.execute("preflight.run", &json!({})).unwrap(); + assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers")); + let pdf = s.execute("file.exportPdf", &json!({})).unwrap(); + assert!(pdf["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING)); +} + +#[test] +fn graphic_layer_options_in_cells_require_the_new_native_reader() { + let mut doc = designcraft_idml::import_idml(&fixture()).unwrap(); + let graphic = doc.spreads[0] + .items + .iter() + .find_map(|i| match &i.content { + Content::Graphic(g) => Some(g.clone()), + _ => None, + }) + .unwrap(); + std::sync::Arc::make_mut(&mut doc.spreads[0]).items.clear(); + let layer = doc.default_layer(); + let (_, story) = doc + .add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()) + .unwrap(); + let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 20.0); + table.cells[0].graphic = Some(graphic); + doc.story_mut(story).unwrap().insert_table(0, table); + assert!(doc.has_graphic_layer_options()); + let reopened = designcraft_format::load(&designcraft_format::save(&doc).unwrap()).unwrap(); + assert!(reopened.has_graphic_layer_visibility_overrides()); + let mut s = Session::new(); + s.add_document(DocState::new(reopened, None)); + let preflight = s.execute("preflight.run", &json!({})).unwrap(); + assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers")); +} diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 348b9c80e..d0be6713a 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -1531,6 +1531,7 @@ mod tests { let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); item.alt_text = alt.into(); item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, @@ -1939,6 +1940,7 @@ mod tests { let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0))); item.alt_text = alt.into(); item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, @@ -2055,6 +2057,7 @@ mod tests { let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0))); item.alt_text = "Cover \"A\" & B".into(); item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, diff --git a/crates/epub/tests/it/grouped_reading_order.rs b/crates/epub/tests/it/grouped_reading_order.rs index fe2223091..42811f61d 100644 --- a/crates/epub/tests/it/grouped_reading_order.rs +++ b/crates/epub/tests/it/grouped_reading_order.rs @@ -100,6 +100,7 @@ fn nested_and_item_rotations_are_combined_before_measuring_graphics() { let image = ItemId(doc.alloc()); let mut item = Item::new(image, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 10.0))); item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5e87c5408..4a3144f08 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -22,8 +22,8 @@ use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; /// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults; -/// version 3 adds rendering of retained EPS source assets. -pub const VERSION: u32 = 3; +/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer options. +pub const VERSION: u32 = 4; #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -87,7 +87,14 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en // Older readers would silently omit EPS artwork. Preview-only legacy assets and // documents without EPS stay at 2 so their existing readers keep working; writing 1 // would make a reader re-run the version 2 paragraph upgrade over current defaults. - let version = if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { VERSION } else { 2 }; + // Older readers ignore unknown Graphic fields and would silently drop layer choices. + let version = if doc.has_graphic_layer_options() { + 4 + } else if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { + 3 + } else { + 2 + }; let meta = json!({"format": "designcraft", "version": version, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; if let Some(png) = preview { @@ -404,6 +411,7 @@ mod tests { let id = ItemId(d.alloc()); let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0))); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (1.0, 1.0), xf: Affine::translate((crop, crop)), diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index c174c1889..fa25914ad 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -20,6 +20,14 @@ const TOC_STYLE_ID: &str = "TOCStyle/Table of Contents"; const TOC_STYLE_NAME: &str = "Table of Contents"; const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]"; +// Retained IDML attributes have unprefixed ASCII names. Native JSON can be +// edited independently: never let its map keys become XML syntax or Self IDs. +fn graphic_layer_attribute_name(key: &str) -> bool { + !key.is_empty() + && key != "Self" + && key.bytes().enumerate().all(|(i, b)| b.is_ascii_alphabetic() || b == b'_' || (i > 0 && (b.is_ascii_digit() || matches!(b, b'.' | b'-')))) +} + /// Export options. #[derive(Clone, Debug)] pub struct ExportOptions { @@ -2061,6 +2069,24 @@ impl<'a> Ex<'a> { if let Some(c) = &g.clip { el.push(clipping_settings(c)); } + if let Some(options) = &g.layer_options { + let mut option = El::new("GraphicLayerOption"); + for (key, value) in &options.attributes { + if graphic_layer_attribute_name(key) { + option.set(key, value); + } + } + for attributes in &options.layers { + let mut layer = El::new("GraphicLayer").attr("Self", self.fresh()); + for (key, value) in attributes { + if graphic_layer_attribute_name(key) { + layer.set(key, value); + } + } + option.push(layer); + } + el.push(option); + } let uri = match &asset.link { Some(l) => path_to_uri(l), None => format!("file:{}", asset.name), diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index fed9f49b9..9e1c01520 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -3043,6 +3043,13 @@ impl<'r> Importer<'r> { } fn graphic(&mut self, g: &El) -> Content { + let layer_options = g.find("GraphicLayerOption").map(|options| { + let attrs = |e: &El| e.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect(); + designcraft_doc::GraphicLayerOptions { attributes: attrs(options), layers: options.find_all("GraphicLayer").map(attrs).collect() } + }); + if layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) { + self.warnings.push(format!("graphic {}: {}", g.get("Self").unwrap_or("(unnamed)"), designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING)); + } let gxf = parse_affine(g.get("ItemTransform")); let (l, t, r, b) = match g.prop_el("GraphicBounds") { Some(gb) => { @@ -3059,6 +3066,7 @@ impl<'r> Importer<'r> { let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path); let graphic = |asset, size| { Content::Graphic(designcraft_doc::Graphic { + layer_options: layer_options.clone(), asset, size, xf: gxf * Affine::translate((l, t)), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index b72ba1c86..51c362dce 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4453,6 +4453,7 @@ fn exports_graphic_in_table_cell() { }); let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0); t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic { + layer_options: None, asset: id, size: (24.0, 24.0), xf: designcraft_geom::Affine::IDENTITY, diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index 23ec31f34..9372c3adf 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -70,6 +70,77 @@ fn package(frames: &[String]) -> Vec { ]) } +#[test] +fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() { + let option = |n: usize, visible: &str| { + format!( + r#""# + ) + }; + let frames = ["false", "true"] + .into_iter() + .enumerate() + .map(|(i, visible)| { + let n = i + 1; + frame(n, i as f64 * 100.0, 0.0, &image(n, PHOTO, None).replace("", &format!("{}", option(n, visible)))) + }) + .collect::>(); + let reads = Cell::new(0); + let loaded = import_idml_report_with_previews( + &package(&frames), + &|_| { + reads.set(reads.get() + 1); + Some(vec![42; 16]) + }, + &Default::default(), + ) + .unwrap(); + assert_eq!(reads.get(), 1); + assert_eq!(loaded.document.assets.len(), 1, "layer choices belong to placements, not shared image bytes"); + let out = export_idml(&loaded.document); + let spreads = zip_text(&out, "Spreads/"); + assert_eq!(spreads.matches(" g.layer_options.clone(), + _ => None, + }) + .collect::>() + }; + assert_eq!(options(&loaded.document), options(&reopened)); + let twice = zip_text(&export_idml(&reopened), "Spreads/"); + assert_eq!(twice.matches(" Vec<(AssetId, [f64; 4], designcraft_geom::Affine)> { let mut out: Vec<(f64, AssetId, [f64; 4], designcraft_geom::Affine)> = d.spreads[0] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 5d3ad0644..7df892abf 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1381,6 +1381,9 @@ impl Exporter<'_> { /// Draw a placed graphic, inside its clipping path when it has one. pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) { + if g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) { + self.warn(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING); + } match g.clip_region().map(|(region, even_odd)| (to_path(®ion), even_odd)) { Some((Some(clip), even_odd)) => { s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero }); @@ -2175,6 +2178,7 @@ mod eps_tests { designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)), ); item.content = Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (2.0, 1.0), xf: Affine::translate((72.0, 72.0)) * Affine::scale(100.0), diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 11d30602b..5566353fd 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -496,6 +496,7 @@ mod tests { let r = Rect::new(at.0, at.1, at.0 + 100.0, at.1 + 50.0); let mut it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size, xf: Affine::translate(at) * Affine::scale(100.0 / size.0), diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 39f472ffc..663ef9169 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -257,6 +257,7 @@ fn place(d: &mut Document, data: Vec, px: (u32, u32)) { let id = ItemId(d.alloc()); let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0))); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset, size: (px.0 as f64, px.1 as f64), xf: designcraft_geom::Affine::translate((72.0, 72.0)) * designcraft_geom::Affine::scale(50.0), diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs index 2442c33a0..a35fc464e 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -30,6 +30,7 @@ fn place_named(d: &mut Document, data: Vec, x: f64, name: &str, mime: &str, let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 36.0, x + 20.0, 56.0))); item.stroke.weight = 0.0; item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (20.0, 20.0), xf: Affine::translate((x, 36.0)), diff --git a/crates/pptx/src/tests.rs b/crates/pptx/src/tests.rs index ba97a67b8..253c5d260 100644 --- a/crates/pptx/src/tests.rs +++ b/crates/pptx/src/tests.rs @@ -212,6 +212,7 @@ fn pictures_are_cropped_to_their_frames() { it.fill = Fill::none(); // 200 × 100 image at 1:1, its left quarter cut off by the frame. it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (200.0, 100.0), xf: Affine::translate((50.0, 100.0)), diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 522e245cb..d9b527a5e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1984,6 +1984,7 @@ mod tests { 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.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (100.0, 100.0), xf: Affine::translate((100.0, 100.0)), @@ -2038,6 +2039,7 @@ mod tests { 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.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (100.0, 100.0), xf: Affine::translate((100.0, 100.0)), diff --git a/crates/render/tests/it/colorize.rs b/crates/render/tests/it/colorize.rs index 883b36b8e..5bf87a54d 100644 --- a/crates/render/tests/it/colorize.rs +++ b/crates/render/tests/it/colorize.rs @@ -51,6 +51,7 @@ fn shown(file: Vec, fill: Fill) -> Vec<[u8; 4]> { it.fill = Fill::swatch(CYAN); it.stroke = Stroke::default(); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (150.0, 50.0), xf: Affine::translate((100.0, 100.0)), diff --git a/crates/render/tests/it/placed_pdf.rs b/crates/render/tests/it/placed_pdf.rs index 118453067..deb699e11 100644 --- a/crates/render/tests/it/placed_pdf.rs +++ b/crates/render/tests/it/placed_pdf.rs @@ -78,6 +78,7 @@ fn placed(size: f64, pdf: Vec) -> Rendered { let id = ItemId(d.alloc()); let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size))); item.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (size, size), xf: Affine::IDENTITY, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..34bc40fc6 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -27,6 +27,7 @@ fn color_theme_tool_requests_a_palette_without_editing_the_image() { let mut d = Document::new(&NewDocument::default()); let id = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); d.item_mut(id).unwrap().content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1108,6 +1109,7 @@ fn graphic_frame(doc: &mut Document) -> ItemId { let id = ItemId(doc.alloc()); let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (200.0, 100.0), xf: designcraft_geom::Affine::translate((50.0, 100.0)), @@ -1502,6 +1504,7 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { let photo = ItemId(d.alloc()); let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::translate((0.0, 0.0)), @@ -1855,6 +1858,7 @@ fn eyedropper_ignores_inactive_states_of_a_multi_state_object() { let raster = { let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::translate((0.0, 0.0)), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 0422936e3..58cf42b24 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2943,6 +2943,7 @@ mod tests { }), ); doc.item_mut(id).unwrap().content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (72.0, 72.0), xf: designcraft_geom::Affine::IDENTITY, diff --git a/docs/gaps.md b/docs/gaps.md index 6723fe1e7..c9221039b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -202,6 +202,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 20–35 h. ### 19. Placed graphics depth (FF) +- **Have:** per-placement IDML graphic-layer settings survive IDML/native round trips; native format 4 guards older readers. Unsupported layer visibility is reported by import, preflight and PDF export. See [graphic-layer-options.md](graphic-layer-options.md). +- **Still missing:** applying the retained layer visibility to the rendered/PDF appearance; layer-comp choices and the layer-options UI. Metadata preservation does not establish visual fidelity. - **Safety:** PDF visibility variants preserve catalog/configuration entries and nonzero-generation references, redefining the existing catalog rather than choosing an arbitrary object number. Encrypted, hybrid-reference and over-budget rewrites are rejected; no print-fidelity claim. diff --git a/docs/graphic-layer-options.md b/docs/graphic-layer-options.md new file mode 100644 index 000000000..953995839 --- /dev/null +++ b/docs/graphic-layer-options.md @@ -0,0 +1,40 @@ +# Placed graphic layer options + +IDML `GraphicLayerOption` / `GraphicLayer` settings are retained on each +`Graphic.layer_options`, not on the shared asset. Two placements of one linked +file can therefore keep different layer choices while sharing image bytes. +Scalar option/layer attributes, layer order, layer IDs/names, original/current +visibility, link-update policy and view/export/print flags survive import, +native save/reopen and IDML export. XML Self IDs are regenerated. Native-edited +attribute maps cannot inject XML syntax or override those Self IDs. + +This is preservation, **not layer-aware rendering**. The current renderer and +PDF exporter use the asset's composite/default appearance and do not apply +these per-placement choices. Changed visibility and explicit view/export/print +states produce an import warning, a persistent `unsupportedGraphicLayers` +preflight issue, and a PDF export warning. Nested graphic cells, anchored +objects and parent items remain detectable after native reopening. + +Native files containing these settings require format version 4. Earlier +readers reject that version instead of loading and silently discarding the new +field. Documents without layer settings retain their existing version 2/3 +requirements. Older graphics without `layerOptions` still deserialize with +`None`, and their JSON representation is unchanged. + +Synthetic regressions cover independent overrides on two placements sharing +one asset, metadata round trips, native reader requirements, nested cells, +preflight/PDF warnings and invalid native attribute names. Private originals +and artwork are not repository fixtures; any collection results are local +metadata checks, not an independent reference-rendering or fidelity oracle. + +Local collection check (2026-10-23): a five-document pilot passed before +expansion to 205 previously converted documents. All 205 completed IDML → +native save/reopen → IDML; all 1,113 layer records across 68 documents matched +their input scalar attributes and order, ignoring regenerated Self IDs. +Persistent preflight warnings appeared in the 57 documents with overrides. +The private source files, layer names, document paths and per-file logs remain +outside this repository. Original INDD files were not opened by this check. + +Remaining work: apply visibility to PSD layers/PDF optional content, implement +layer-comp choices and a layer-options UI, and validate appearance against +verified same-save reference PDFs. Gap 19 remains open. From 3d362d0bdfda036ac43d3e6b51b18d6aff03c386 Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 10:42:21 +0100 Subject: [PATCH 02/38] M6: preserve nested graphic layers and retain export diagnostics --- crates/doc/src/assets.rs | 6 +++ crates/doc/src/item.rs | 3 ++ crates/engine/src/cmd/export.rs | 4 ++ crates/engine/src/cmd/preflight.rs | 2 +- crates/engine/tests/graphic_layer_options.rs | 21 +++++++++-- crates/idml/src/export.rs | 26 ++++++++++++- crates/idml/src/import.rs | 15 +++++++- crates/idml/src/tests/assets.rs | 39 +++++++++++++++++++- docs/graphic-layer-options.md | 15 ++++++-- 9 files changed, 119 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index ed7679ee5..872d15ba8 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -39,6 +39,12 @@ impl Document { || self.stories.values().any(|story| story_layer_options(story, 0, false)) } + /// Placements outside the ordinary document-spread frame walk used by preflight. + pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool { + self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) + || self.stories.values().any(|story| story_layer_options(story, 0, true)) + } + /// Includes parent items, anchored objects, graphic cells and nested notes. pub fn has_graphic_layer_visibility_overrides(&self) -> bool { self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 9c4169fe4..586358f1d 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -604,6 +604,9 @@ pub enum Fitting { pub struct GraphicLayerOptions { pub attributes: std::collections::BTreeMap, pub layers: Vec>, + /// Parent indices in pre-order; absent entries are top-level layers (older native files). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parents: Vec>, } pub const GRAPHIC_LAYER_RENDERING_WARNING: &str = diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 3797a9bf6..c2ca1e317 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -745,6 +745,10 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { 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; + // Flattening and effect rasterization replace placements, so inspect the source too. + if st.doc.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) { + r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into()); + } // Transparency Blend Space: the page group of pages with transparency. let spreads = designcraft_pdf::sheet_spreads(doc, &opts); let blended: Vec = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect(); diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index c69050ace..a0c1a6da1 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -320,7 +320,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { } // Nested graphic cells and anchored/parent items may not have a page frame // in the walk above. Keep the warning visible after a native save/reopen. - if !out.iter().any(|issue| issue.kind == "unsupportedGraphicLayers") && d.has_graphic_layer_visibility_overrides() { + if d.has_unwalked_graphic_layer_visibility_overrides() { out.push(Issue { severity: "warning", kind: "unsupportedGraphicLayers", diff --git a/crates/engine/tests/graphic_layer_options.rs b/crates/engine/tests/graphic_layer_options.rs index 8699a4a66..117517002 100644 --- a/crates/engine/tests/graphic_layer_options.rs +++ b/crates/engine/tests/graphic_layer_options.rs @@ -13,7 +13,7 @@ fn fixture() -> Vec { - + "#; let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); @@ -64,7 +64,7 @@ fn graphic_layer_options_in_cells_require_the_new_native_reader() { _ => None, }) .unwrap(); - std::sync::Arc::make_mut(&mut doc.spreads[0]).items.clear(); + // Keep the original spread graphic too: its warning must not hide the cell warning. let layer = doc.default_layer(); let (_, story) = doc .add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()) @@ -78,5 +78,20 @@ fn graphic_layer_options_in_cells_require_the_new_native_reader() { let mut s = Session::new(); s.add_document(DocState::new(reopened, None)); let preflight = s.execute("preflight.run", &json!({})).unwrap(); - assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers")); + let warnings: Vec<_> = preflight["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "unsupportedGraphicLayers").collect(); + assert!(warnings.iter().any(|i| i["item"].is_null()), "the cell needs an independent document-level warning"); + assert!(warnings.iter().any(|i| !i["item"].is_null()), "the spread retains its item warning"); +} + +#[test] +fn pdf_layer_warning_survives_soft_effects_and_flattening() { + for flatten in [false, true] { + let doc = designcraft_idml::import_idml(&fixture()).unwrap(); + let id = doc.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().id.0; + let mut session = Session::new(); + session.add_document(DocState::new(doc, None)); + session.execute("object.feather", &json!({"ids":[id], "width":2})).unwrap(); + let out = session.execute("file.exportPdf", &json!({"flatten":flatten})).unwrap(); + assert!(out["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING), "{out}"); + } } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index fa25914ad..0e338c4b6 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -25,6 +25,7 @@ const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]"; fn graphic_layer_attribute_name(key: &str) -> bool { !key.is_empty() && key != "Self" + && key != "xmlns" && key.bytes().enumerate().all(|(i, b)| b.is_ascii_alphabetic() || b == b'_' || (i > 0 && (b.is_ascii_digit() || matches!(b, b'.' | b'-')))) } @@ -2076,13 +2077,36 @@ impl<'a> Ex<'a> { option.set(key, value); } } - for attributes in &options.layers { + // Parent indices must precede their children. Ignore invalid parents and bound DOM depth + // even when a native file has been edited independently of the IDML importer. + let mut depths = Vec::with_capacity(options.layers.len()); + let mut parents = Vec::with_capacity(options.layers.len()); + for i in 0..options.layers.len() { + let parent = options.parents.get(i).copied().flatten().filter(|p| *p < i && depths.get(*p).is_some_and(|d| *d < 240)); + depths.push(parent.and_then(|p| depths.get(p)).copied().unwrap_or(0) + 1); + parents.push(parent); + } + let mut children: Vec> = vec![Vec::new(); options.layers.len()]; + let mut roots = Vec::new(); + for (i, attributes) in options.layers.iter().enumerate().rev() { let mut layer = El::new("GraphicLayer").attr("Self", self.fresh()); for (key, value) in attributes { if graphic_layer_attribute_name(key) { layer.set(key, value); } } + if let Some(nested) = children.get_mut(i) { + for child in std::mem::take(nested).into_iter().rev() { + layer.push(child); + } + } + if let Some(nested) = parents.get(i).copied().flatten().and_then(|parent| children.get_mut(parent)) { + nested.push(layer); + } else { + roots.push(layer); + } + } + for layer in roots.into_iter().rev() { option.push(layer); } el.push(option); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 9e1c01520..3db03576f 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -3045,7 +3045,20 @@ impl<'r> Importer<'r> { fn graphic(&mut self, g: &El) -> Content { let layer_options = g.find("GraphicLayerOption").map(|options| { let attrs = |e: &El| e.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect(); - designcraft_doc::GraphicLayerOptions { attributes: attrs(options), layers: options.find_all("GraphicLayer").map(attrs).collect() } + let mut out = designcraft_doc::GraphicLayerOptions { attributes: attrs(options), ..Default::default() }; + let mut pending: Vec<_> = options.find_all("GraphicLayer").map(|e| (e, None)).collect(); + pending.reverse(); + while let Some((layer, parent)) = pending.pop() { + let index = out.layers.len(); + out.layers.push(attrs(layer)); + out.parents.push(parent); + let children: Vec<_> = layer.find_all("GraphicLayer").collect(); + pending.extend(children.into_iter().rev().map(|e| (e, Some(index)))); + } + if out.parents.iter().all(Option::is_none) { + out.parents.clear(); + } + out }); if layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) { self.warnings.push(format!("graphic {}: {}", g.get("Self").unwrap_or("(unnamed)"), designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING)); diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index 9372c3adf..71c01ff0b 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -70,6 +70,23 @@ fn package(frames: &[String]) -> Vec { ]) } +#[test] +fn nested_graphic_layers_preserve_hierarchy_on_roundtrip() { + let option = r#" + + + "#; + let image = image(1, PHOTO, None).replace("", &format!("{option}")); + let d = import_idml(&package(&[frame(1, 0.0, 0.0, &image)])).unwrap(); + let options = |d: &Document| d.spreads[0].items[0].graphic().unwrap().layer_options.clone().unwrap(); + let expected = options(&d); + assert_eq!(expected.layers.len(), 3); + assert_eq!(expected.parents, [None, Some(0), None]); + assert!(expected.has_visibility_overrides()); + let reopened = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(options(&reopened), expected); +} + #[test] fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() { let option = |n: usize, visible: &str| { @@ -121,6 +138,23 @@ fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() { assert_eq!(twice.matches(" Date: Fri, 23 Oct 2026 19:23:13 +0100 Subject: [PATCH 03/38] M6: retain layer order when native parents reopen closed subtrees --- ROADMAP.md | 2 ++ crates/idml/src/export.rs | 18 ++++++++++++------ crates/idml/src/tests/assets.rs | 17 +++++++++++++++++ docs/graphic-layer-options.md | 2 +- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 01c748d33..f361831d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,8 @@ 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** · IDML graphic-layer export rejects parents in already closed subtrees, + preserving native-edited layer order through round trips. Synthetic regression added. - **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites leave document/undo state intact. Null trailer entries remain unencrypted, and unsupported all-content crops retain the whole page rather than silently clipping hidden layers. diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0e338c4b6..77381a215 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2077,14 +2077,20 @@ impl<'a> Ex<'a> { option.set(key, value); } } - // Parent indices must precede their children. Ignore invalid parents and bound DOM depth - // even when a native file has been edited independently of the IDML importer. - let mut depths = Vec::with_capacity(options.layers.len()); + // Only an ancestor whose subtree is still open can parent the next layer. + // Reopening a closed subtree would reorder native-edited lists on roundtrip. + let mut open = Vec::new(); let mut parents = Vec::with_capacity(options.layers.len()); for i in 0..options.layers.len() { - let parent = options.parents.get(i).copied().flatten().filter(|p| *p < i && depths.get(*p).is_some_and(|d| *d < 240)); - depths.push(parent.and_then(|p| depths.get(p)).copied().unwrap_or(0) + 1); - parents.push(parent); + let parent = options.parents.get(i).copied().flatten(); + if let Some(at) = parent.and_then(|p| open.iter().position(|id| *id == p)).filter(|at| *at < 239) { + open.truncate(at + 1); + parents.push(parent); + } else { + open.clear(); + parents.push(None); + } + open.push(i); } let mut children: Vec> = vec![Vec::new(); options.layers.len()]; let mut roots = Vec::new(); diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index 71c01ff0b..48875c256 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -155,6 +155,23 @@ fn malformed_native_graphic_layer_parents_cannot_cycle_or_overflow_xml_depth() { assert_eq!(reopened.spreads[0].items[0].graphic().unwrap().layer_options.as_ref().unwrap().layers.len(), 300); } +#[test] +fn closed_graphic_layer_subtrees_cannot_reorder_native_layers() { + let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap(); + let Content::Graphic(g) = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.spreads[0]).items[0]).content else { + panic!("graphic") + }; + g.layer_options = Some(designcraft_doc::GraphicLayerOptions { + layers: ["A", "B", "C", "D"].into_iter().map(|name| std::collections::BTreeMap::from([("Name".into(), name.into())])).collect(), + parents: vec![None, None, Some(0), Some(2)], + ..Default::default() + }); + let reopened = import_idml(&export_idml(&d)).unwrap(); + let options = reopened.spreads[0].items[0].graphic().unwrap().layer_options.as_ref().unwrap(); + assert_eq!(options.layers.iter().map(|a| a["Name"].as_str()).collect::>(), ["A", "B", "C", "D"]); + assert_eq!(options.parents, [None, None, None, Some(2)]); +} + #[test] fn graphic_layer_attributes_from_native_data_cannot_inject_xml_or_self_ids() { let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap(); diff --git a/docs/graphic-layer-options.md b/docs/graphic-layer-options.md index 7668f42f3..53a527991 100644 --- a/docs/graphic-layer-options.md +++ b/docs/graphic-layer-options.md @@ -8,7 +8,7 @@ visibility, link-update policy and view/export/print flags survive import, native save/reopen and IDML export. XML Self IDs are regenerated. Native-edited attribute maps cannot inject XML syntax, declare namespaces or override those Self IDs. Parent indices are optional in native files; missing entries retain the older -flat representation. Invalid/cyclic parent indices and over-budget nesting +flat representation. Invalid/cyclic parent indices, parents in closed subtrees and over-budget nesting are emitted as top-level layers, retaining their scalar metadata without unbounded XML recursion. From 2633fe8b5dded65f7faeaf666de93b7668fcaa14 Mon Sep 17 00:00:00 2001 From: Andee Collard Date: Sat, 24 Oct 2026 13:15:46 +0100 Subject: [PATCH 04/38] Format: choose the native version in one place; share v4 with #1671 skylartaylor's review: #1647 and #1671 each introduced native format 4 for a different capability, so a reader that understood only one could accept a file carrying the other and silently drop its settings. No release has shipped v4 (main and v0.12.0 still write at most 3), so v4 is defined as one capability covering both retained placed-graphic layer options and explicit all-on PDF layer choices; #1671 is stacked on this branch. Move the version choice into required_version so every new capability is decided in one place, document the shared v4 contract on VERSION, and test that plain, EPS-only and layer-option documents keep 2, 3 and 4 respectively. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/lib.rs | 49 ++++++++++++++++++++++++++--------- docs/graphic-layer-options.md | 5 ++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4a3144f08..d119c3538 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -22,7 +22,10 @@ use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; /// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults; -/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer options. +/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer +/// options. Version 4 is one capability shared with explicit all-on placed-PDF layer choices +/// (storytold/designcraft#1671): it must not be released until a reader understands both, and any +/// later capability needs version 5. [`required_version`] is the only place that picks a version. pub const VERSION: u32 = 4; #[derive(Debug, thiserror::Error)] @@ -67,6 +70,19 @@ pub fn save_with_preview(doc: &Document, preview: Option<&[u8]>) -> Result u32 { + // Older readers ignore unknown Graphic fields and would silently drop layer choices. + if doc.has_graphic_layer_options() { + return 4; + } + // Older readers would silently omit EPS artwork. Preview-only legacy assets and documents + // without EPS stay at 2 so their existing readers keep working; writing 1 would make a + // reader re-run the version 2 paragraph upgrade over current defaults. + if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { 3 } else { 2 } +} + fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_entry: usize) -> Result, FormatError> { // JSON writes infinity and NaN as null, which no load accepts: the file would not open. if let Some(id) = doc.non_finite_item() { @@ -84,17 +100,7 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en z.start_file("mimetype", stored).map_err(io)?; z.write_all(MIME.as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; z.start_file("meta.json", deflate).map_err(io)?; - // Older readers would silently omit EPS artwork. Preview-only legacy assets and - // documents without EPS stay at 2 so their existing readers keep working; writing 1 - // would make a reader re-run the version 2 paragraph upgrade over current defaults. - // Older readers ignore unknown Graphic fields and would silently drop layer choices. - let version = if doc.has_graphic_layer_options() { - 4 - } else if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { - 3 - } else { - 2 - }; + let version = required_version(doc); let meta = json!({"format": "designcraft", "version": version, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; if let Some(png) = preview { @@ -481,6 +487,25 @@ mod tests { assert_eq!(version(&d), 2, "legacy EPS previews stay compatible"); } + #[test] + fn one_place_picks_the_oldest_reader_that_keeps_the_document() { + use designcraft_doc::{Content, GraphicLayerOptions}; + let mut d = Document::new(&NewDocument::default()); + assert_eq!(required_version(&d), 2, "plain documents keep the version 2 reader"); + let aid = AssetId(d.alloc()); + let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec(); + d.assets.insert( + aid, + Arc::new(Asset { id: aid, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(eps), ..Default::default() }), + ); + let id = place(&mut d, aid, 0.0); + assert_eq!(required_version(&d), 3, "EPS sources alone keep the version 3 reader"); + let Some(Content::Graphic(g)) = d.item_mut(id).map(|it| &mut it.content) else { panic!("placed graphic") }; + g.layer_options = Some(GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() }); + assert_eq!(required_version(&d), 4, "retained graphic layer options need version 4 even with EPS"); + assert_eq!(required_version(&load(&save(&d).unwrap()).unwrap()), 4); + } + #[test] fn roundtrip_with_assets() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); diff --git a/docs/graphic-layer-options.md b/docs/graphic-layer-options.md index 53a527991..f38d82d34 100644 --- a/docs/graphic-layer-options.md +++ b/docs/graphic-layer-options.md @@ -27,6 +27,11 @@ field. Documents without layer settings retain their existing version 2/3 requirements. Older graphics without `layerOptions` still deserialize with `None`, and their JSON representation is unchanged. +Version 4 is a single reader capability shared with explicit all-on placed-PDF +layer choices (#1671): a version 4 reader must understand both, so the two +changes ship in the same release. One function in the format crate, +`required_version`, chooses the version for every save. + Synthetic regressions cover independent overrides on two placements sharing one asset, metadata round trips, native reader requirements, nested cells, nested layer hierarchy, preflight/PDF warnings after rasterization and flattening, From 8d295b101e6a0ae2fd537d853af1b1d652fcb50f Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 17:56:49 +0100 Subject: [PATCH 05/38] PDF: distinguish all-on layer choices from file defaults --- ROADMAP.md | 3 + crates/doc/src/assets.rs | 20 +++++++ crates/doc/src/item.rs | 13 +++++ crates/engine/src/cmd/export.rs | 4 +- crates/engine/src/cmd/interchange.rs | 45 ++++++++++++++- crates/engine/src/cmd/pdflayers.rs | 84 ++++++++++++++++++++++++++-- crates/format/src/lib.rs | 44 +++++++++++++-- crates/render/src/lib.rs | 43 ++++++++------ crates/ui-egui/src/dialogs.rs | 9 ++- crates/ui-egui/src/menus.rs | 5 +- docs/gaps.md | 3 + docs/pdf-layer-visibility.md | 9 +++ 12 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 docs/pdf-layer-visibility.md diff --git a/ROADMAP.md b/ROADMAP.md index f361831d3..0f621b7ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,9 @@ 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** · Placed PDF layers can explicitly all be shown, separately from Use File Defaults. + Legacy commands keep their reset behavior; native saves require a capable reader only for + the new all-on state, and PDF/embedded IDML exports preserve the choice. No readiness percentage change. - **2026-10-23** · IDML graphic-layer export rejects parents in already closed subtrees, preserving native-edited layer order through round trips. Synthetic regression added. - **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 872d15ba8..c526c98ba 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -31,6 +31,17 @@ fn story_layer_options(st: &Story, depth: usize, overrides_only: bool) -> bool { || st.notes.iter().chain(&st.endnotes).any(|note| story_layer_options(¬e.text, depth + 1, overrides_only)) } +fn item_any(it: &Item, depth: usize, f: &impl Fn(&Item) -> bool) -> bool { + depth > MAX_DEPTH || f(it) || it.children().iter().any(|child| item_any(child, depth + 1, f)) +} + +fn story_any(st: &Story, depth: usize, f: &impl Fn(&Item) -> bool) -> bool { + depth > MAX_DEPTH + || st.objects.iter().any(|object| item_any(&object.item, depth + 1, f)) + || st.tables.values().flat_map(|table| &table.cells).any(|cell| story_any(&cell.text, depth + 1, f)) + || st.notes.iter().chain(&st.endnotes).any(|note| story_any(¬e.text, depth + 1, f)) +} + impl Document { /// Whether saving needs a reader that retains per-placement graphic layer settings. /// Exceeding the traversal budget conservatively requires the newer format. @@ -39,6 +50,15 @@ impl Document { || self.stories.values().any(|story| story_layer_options(story, 0, false)) } + /// Whether any placed PDF, on document or parent spreads, in groups or anchored, explicitly + /// shows every layer (an override with an empty hidden list). Older readers would take that + /// for "use the file's defaults". Exceeding the traversal budget conservatively says yes. + pub fn has_explicit_all_on_pdf_layers(&self) -> bool { + let all_on = |it: &Item| it.pdf_layer_override && it.pdf_hidden_layers.is_empty(); + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &all_on)) + || self.stories.values().any(|story| story_any(story, 0, &all_on)) + } + /// Placements outside the ordinary document-spread frame walk used by preflight. pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool { self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 586358f1d..1db88edbb 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1398,6 +1398,10 @@ pub struct Item { /// Object › Object Layer Options: layers of a placed PDF that are hidden. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pdf_hidden_layers: Vec, + /// Explicit placed-PDF visibility, including an all-on override with no hidden names. + /// False with an empty hidden list retains the file's own settings (legacy behavior). + #[serde(default, skip_serializing_if = "is_false")] + pub pdf_layer_override: bool, /// Window › Interactive › Media: options of a placed video or sound. #[serde(default, skip_serializing_if = "Option::is_none")] pub media: Option, @@ -1430,7 +1434,15 @@ fn one() -> f32 { 1.0 } +fn is_false(value: &bool) -> bool { + !*value +} + impl Item { + pub fn has_pdf_layer_override(&self) -> bool { + self.pdf_layer_override || !self.pdf_hidden_layers.is_empty() + } + pub fn new(id: ItemId, layer: LayerId, shape: Shape, path: PathData) -> Self { Item { id, @@ -1457,6 +1469,7 @@ impl Item { xml_tag: String::new(), button: None, pdf_hidden_layers: Vec::new(), + pdf_layer_override: false, live_caption: None, form_field: None, export_options: ExportOptions::default(), diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index c2ca1e317..1d1e19c24 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -673,7 +673,7 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I /// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { let ids: Vec = - d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect(); + d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| it.has_pdf_layer_override() && it.graphic().is_some())).collect(); if ids.is_empty() { return Ok((None, 0)); } @@ -690,7 +690,7 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option Vec { fn export_idml(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let opts = designcraft_idml::ExportOptions { embed_images: p.get("embedImages").and_then(Value::as_bool).unwrap_or(true) }; - let bytes = designcraft_idml::export_idml_with(&st.doc, &opts); + let layer_doc = with_pdf_layer_variants(&st.doc, opts.embed_images)?; + let bytes = designcraft_idml::export_idml_with(layer_doc.as_ref().unwrap_or(&st.doc), &opts); match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] @@ -39,6 +40,48 @@ fn export_idml(s: &mut Session, p: &Value) -> Result { } } +/// Each placement gets its own embedded variant so two frames sharing a PDF can +/// choose different layers. The live document and original linked bytes stay untouched. +fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result> { + let ids: Vec<_> = doc + .all_items() + .into_iter() + .filter(|id| { + doc.item(*id).is_some_and(|it| { + it.has_pdf_layer_override() + && it.graphic().and_then(|g| doc.assets.get(&g.asset)).is_some_and(|a| designcraft_render::is_pdf(&a.data)) + }) + }) + .collect(); + if ids.is_empty() { + return Ok(None); + } + if !embed { + return Err(EngineError::Other("PDF layer choices require embedded images in IDML export".into())); + } + let mut out = doc.clone(); + for id in ids { + let Some(it) = doc.item(id) else { continue }; + let Some(asset) = it.graphic().and_then(|g| doc.assets.get(&g.asset)) else { continue }; + let bytes = designcraft_render::pdf_hide_layers(&asset.data, &it.pdf_hidden_layers) + .ok_or_else(|| EngineError::Other(format!("{}: PDF layer choices could not be applied", asset.name)))?; + let mut variant = (**asset).clone(); + variant.id = designcraft_doc::AssetId(out.alloc()); + variant.data = std::sync::Arc::new(bytes); + variant.link = None; + let aid = variant.id; + out.assets.insert(aid, std::sync::Arc::new(variant)); + if let Some(it) = out.item_mut(id) { + if let designcraft_doc::Content::Graphic(g) = &mut it.content { + g.asset = aid; + } + it.pdf_layer_override = false; + it.pdf_hidden_layers.clear(); + } + } + Ok(Some(out)) +} + /// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`] from /// beside `package` (the package's file, if it has one), so the automation read root applies to /// the paths the package names, and its PDF crops resolved. diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index 937fe81c0..8d339dc9d 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -17,11 +17,13 @@ pub fn specs() -> Vec { cmd!(query "object.pdfLayers", "Object Layers", [], None, "{id?} → [{name, visible, default}] — the layers of the selected placed PDF", has_selection, |s, p| { let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.first().copied())).ok_or_else(|| bad("object.pdfLayers", "no selection"))?; let data = placed_pdf(s, id).ok_or_else(|| bad("object.pdfLayers", "not a placed PDF"))?; - let hidden = s.doc()?.doc.item(id).map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default(); + let item = s.doc()?.doc.item(id); + let hidden = item.map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default(); + let overridden = item.is_some_and(|i| i.has_pdf_layer_override()); Ok(Value::Array( designcraft_render::pdf_layers(&data) .into_iter() - .map(|(name, on)| json!({"visible": if hidden.is_empty() { on } else { !hidden.contains(&name) }, "name": name, "default": on})) + .map(|(name, on)| json!({"visible": if overridden { !hidden.contains(&name) } else { on }, "name": name, "default": on, "overridden": overridden})) .collect(), )) }), @@ -30,16 +32,23 @@ pub fn specs() -> Vec { "Object Layer Options…", ["Object"], None, - "{ids?, hidden: [layer name]} — layers of placed PDFs to hide ([] = the file's own settings)", + "{ids?, hidden: [layer name], useDefaults?: bool} — hide layers; useDefaults:false makes [] explicitly all on; legacy [] restores file defaults", has_selection, |s, p| { - let ids = targets(s, p)?; + let ids: Vec<_> = targets(s, p)?.into_iter().filter(|id| placed_pdf(s, *id).is_some()).collect(); let hidden: Vec = p .get("hidden") .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) .unwrap_or_default(); - if ids.iter().all(|id| placed_pdf(s, *id).is_none()) { + let use_defaults = match p.get("useDefaults") { + None => hidden.is_empty(), + Some(v) => v.as_bool().ok_or_else(|| bad("object.layerOptions", "useDefaults must be a boolean"))?, + }; + if use_defaults && !hidden.is_empty() { + return Err(bad("object.layerOptions", "useDefaults cannot be combined with hidden layers")); + } + if ids.is_empty() { return Err(bad("object.layerOptions", "select a placed PDF")); } // Validate every selected PDF before editing any item or recording undo. @@ -62,9 +71,10 @@ pub fn specs() -> Vec { for id in &ids { if let Some(it) = d.item_mut(*id) { it.pdf_hidden_layers = hidden.clone(); + it.pdf_layer_override = !use_defaults; } } - Ok(json!({"hidden": hidden})) + Ok(json!({"hidden": hidden, "useDefaults": use_defaults})) }) } ), @@ -378,4 +388,66 @@ pub(crate) mod tests { let r = s.execute("file.exportPdf", &json!({})).unwrap(); assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("layer")), "{r}"); } + #[test] + fn placed_pdf_all_on_is_distinct_from_defaults_and_survives_exports() { + let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name":"off.pdf", "x":100,"y":100,"width":100})) + .unwrap()["id"] + .clone(); + let visible = |s: &mut Session| s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"].as_bool().unwrap(); + let pixel = |s: &Session| { + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + rr.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(175, 150) + }; + assert!(!visible(&mut s)); + assert!(pixel(&s)[1] > 200, "red hidden by default"); + s.execute("object.layerOptions", &json!({"ids":[id], "hidden":[], "useDefaults":false})).unwrap(); + assert!(visible(&mut s)); + assert!(pixel(&s)[0] > 200 && pixel(&s)[1] < 40, "all-on renders red"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!visible(&mut s)); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(visible(&mut s)); + let bytes = designcraft_format::save(&s.doc().unwrap().doc).unwrap(); + let roundtrip = designcraft_format::load(&bytes).unwrap(); + let item = roundtrip.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap(); + assert!(item.pdf_layer_override && item.pdf_hidden_layers.is_empty()); + let exported = s.execute("file.exportPdf", &json!({})).unwrap(); + let bytes = super::super::file::base64_decode(exported["base64"].as_str().unwrap()); + let raster = designcraft_render::render_pdf_page(&bytes, 0, 842).unwrap(); + let px = raster.data().get(150 * usize::from(raster.width()) + 175).unwrap(); + assert!(px.r > 200 && px.g < 40, "PDF export retains red: {px:?}"); + let idml = s.execute("file.exportIdml", &json!({})).unwrap(); + let bytes = super::super::file::base64_decode(idml["base64"].as_str().unwrap()); + let imported = designcraft_idml::import_idml(&bytes).unwrap(); + let pdf_asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap(); + assert!(designcraft_render::pdf_layers(&pdf_asset.data).iter().all(|(_, on)| *on)); + assert!(s.execute("file.exportIdml", &json!({"embedImages":false})).is_err()); + assert_eq!(s.doc().unwrap().doc.assets.values().find(|a| a.name == "off.pdf").unwrap().data.as_slice(), pdf); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[]})).unwrap(); + assert!(!visible(&mut s), "legacy empty list still resets defaults"); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":false})).unwrap(); + s.execute("object.layerOptions", &json!({"ids":[id],"useDefaults":true})).unwrap(); + assert!(!visible(&mut s), "explicit reset restores defaults"); + } + #[test] + fn pdf_layer_options_ignore_non_pdf_targets_in_mixed_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let placed = s + .execute( + "file.place", + &json!({"base64": super::super::file::base64_encode(&two_layer_pdf()), "name":"art.pdf", "x":100,"y":100,"width":100}), + ) + .unwrap(); + let other = s.execute("frame.create", &json!({"rect":[0,0,20,20]})).unwrap(); + s.execute("object.layerOptions", &json!({"ids":[placed["id"],other["id"]],"hidden":[],"useDefaults":false})).unwrap(); + assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(other["id"].as_u64().unwrap())).unwrap().has_pdf_layer_override()); + assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"hidden":["Red"],"useDefaults":true})).is_err()); + assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"useDefaults":"false"})).is_err()); + } } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d119c3538..af1051ab0 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -22,10 +22,10 @@ use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; /// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults; -/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer -/// options. Version 4 is one capability shared with explicit all-on placed-PDF layer choices -/// (storytold/designcraft#1671): it must not be released until a reader understands both, and any -/// later capability needs version 5. [`required_version`] is the only place that picks a version. +/// version 3 adds rendering of retained EPS source assets; version 4 is one capability covering both +/// retained placed-graphic layer options and explicit all-on placed-PDF layer choices, so a version 4 +/// reader understands both. Any later capability needs version 5. [`required_version`] is the only +/// place that picks a version. pub const VERSION: u32 = 4; #[derive(Debug, thiserror::Error)] @@ -73,8 +73,10 @@ const LARGE_ENTRY: usize = (u32::MAX as usize).saturating_sub(64 << 20); /// The oldest reader capability that keeps everything in `doc`. Documents that need nothing newer /// keep their older requirement so existing readers still open them. fn required_version(doc: &Document) -> u32 { - // Older readers ignore unknown Graphic fields and would silently drop layer choices. - if doc.has_graphic_layer_options() { + // Version 4 covers both of these: older readers ignore the unknown fields, so they would + // silently drop retained graphic layer choices, or read an explicit all-on PDF layer choice + // (empty hidden list) as "use the file's defaults". A nonempty hidden list stays readable. + if doc.has_graphic_layer_options() || doc.has_explicit_all_on_pdf_layers() { return 4; } // Older readers would silently omit EPS artwork. Preview-only legacy assets and documents @@ -454,6 +456,36 @@ mod tests { buf.into_inner() } + #[test] + fn explicit_all_on_pdf_layers_require_a_capable_reader_only_when_needed() { + let mut d = Document::new(&NewDocument::default()); + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { + id: aid, + name: "art.pdf".into(), + mime: "application/pdf".into(), + data: Arc::new(b"%PDF-1.7".to_vec()), + ..Default::default() + }), + ); + let id = place(&mut d, aid, 0.0); + let version = |d: &Document| { + let bytes = save(d).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut meta = String::new(); + z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap(); + serde_json::from_str::(&meta).unwrap()["version"].as_u64().unwrap() + }; + assert_eq!(version(&d), 2); + d.item_mut(id).unwrap().pdf_layer_override = true; + assert_eq!(version(&d), 4); + assert!(load(&save(&d).unwrap()).unwrap().item(id).unwrap().pdf_layer_override); + d.item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()]; + assert_eq!(version(&d), 2, "legacy nonempty overrides remain readable"); + } + #[test] fn eps_source_requires_new_reader_but_preview_documents_keep_the_older_one() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d9b527a5e..83e8def38 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -654,13 +654,10 @@ impl Renderer { let g = &if it.media.is_some() { it.drawn_graphic(&f.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() }; ctx.set_transform(f.view * xf); ctx.push_clip_layer(bp); - if !it.pdf_hidden_layers.is_empty() { - HIDDEN_LAYERS.with(|h| *h.borrow_mut() = it.pdf_hidden_layers.clone()); - } + let hidden = it.has_pdf_layer_override().then(|| it.pdf_hidden_layers.clone()); + let previous = HIDDEN_LAYERS.with(|h| h.replace(hidden)); self.draw_graphic(ctx, f, g, xf); - if !it.pdf_hidden_layers.is_empty() { - HIDDEN_LAYERS.with(|h| h.borrow_mut().clear()); - } + HIDDEN_LAYERS.with(|h| h.replace(previous)); ctx.pop_layer(); } // Items pasted into the frame, clipped by it. @@ -880,12 +877,14 @@ impl Renderer { let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt(); let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen }; let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone()); - let data = if !hidden.is_empty() && is_pdf(&asset.data) { - // Stale persisted/relinked choices must not reveal hidden source content. - let Some(variant) = pdf_layers::layered(&asset.data, &hidden) else { return }; - variant - } else { - asset.data.clone() + let data = match hidden.as_deref().filter(|_| is_pdf(&asset.data)) { + // An explicit choice (including all-on) that cannot be rewritten fails closed: + // stale persisted/relinked choices must not reveal hidden source content. + Some(hidden) => { + let Some(variant) = pdf_layers::layered(&asset.data, hidden) else { return }; + variant + } + None => asset.data.clone(), }; // A placed PDF cropped to a box: the whole page, placed so that the box spans graphic space. let [l, t, r, b] = shown_box(asset).unwrap_or([0.0, 0.0, 1.0, 1.0]); @@ -1025,7 +1024,7 @@ fn rect_overlaps(a: Rect, b: Rect) -> bool { thread_local! { /// Object Layer Options of the placed PDF being drawn. - static HIDDEN_LAYERS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + static HIDDEN_LAYERS: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; /// Appearance of Black for the render on this thread: 100% K as pure black. static RICH_BLACK: std::cell::Cell = const { std::cell::Cell::new(false) }; /// Separations Preview plate for the render on this thread. @@ -1296,11 +1295,23 @@ pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], /// Rasterize a placed page for export, preserving available interpreter diagnostics. /// This cannot detect backend approximations that do not emit an interpreter warning. pub fn pdf_page_png_checked(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Result, PdfRasterError> { + pdf_page_png_options_checked(bytes, page, max_side, (!hidden.is_empty()).then_some(hidden), shown) +} + +/// Rasterize with an explicit visibility override: `Some(&[])` enables every layer; +/// `None` retains file defaults. The legacy entry point keeps its empty-list semantics. +pub fn pdf_page_png_options_checked( + bytes: &[u8], + page: usize, + max_side: u32, + hidden: Option<&[String]>, + shown: Option<[f64; 4]>, +) -> Result, PdfRasterError> { check_pdf_input_size(bytes.len())?; - let data = if hidden.is_empty() { - std::borrow::Cow::Borrowed(bytes) - } else { + let data = if let Some(hidden) = hidden { std::borrow::Cow::Owned(pdf_hide_layers(bytes, hidden).ok_or(PdfRasterError::LayerRewrite)?) + } else { + std::borrow::Cow::Borrowed(bytes) }; let raster = render_pdf_raster(&data, page, max_side)?; if raster.diagnostics.image_decode_failed { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9b7bc2b79..f0ff60e09 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2411,8 +2411,15 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { && let Some(o) = l.as_object_mut() { o.insert("visible".into(), json!(on)); + d.fields.insert("useDefaults".into(), json!(false)); } } + if ui.button(crate::i18n::tr(&app.ui.language, "Use File Defaults")).clicked() { + for layer in &mut layers { + layer["visible"] = layer["default"].clone(); + } + d.fields.insert("useDefaults".into(), json!(true)); + } d.fields.insert("layers".into(), json!(layers)); } "colorSettings" => { @@ -3269,7 +3276,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "layerOptions" => { let hidden: Vec = d.fields.get("layers").and_then(Value::as_array).map(|a| a.iter().filter(|l| l["visible"] == false).map(|l| l["name"].clone()).collect()).unwrap_or_default(); - app.run("object.layerOptions", json!({"hidden": hidden})) + app.run("object.layerOptions", json!({"hidden": if d.b("useDefaults") { Vec::::new() } else { hidden }, "useDefaults": d.b("useDefaults")})) } "colorSettings" => { let r = app.run("color.settings", json!({"rgb": d.s("rgb"), "cmyk": d.s("cmyk"), "intent": d.s("intent"), "bpc": d.b("bpc")}))?; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3c052b783..c85a87420 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1556,7 +1556,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option v, Err(e) => return Some(Err(e.to_string())), }; - app.ui.dialog = Some(crate::dialogs::Dialog::new("layerOptions", json!({"layers": layers}))); + app.ui.dialog = Some(crate::dialogs::Dialog::new( + "layerOptions", + json!({"useDefaults": !layers.as_array().and_then(|a| a.first()).and_then(|l| l["overridden"].as_bool()).unwrap_or(false), "layers": layers}), + )); Ok(Value::Null) } "view.rotateSpread" => { diff --git a/docs/gaps.md b/docs/gaps.md index c9221039b..59ecc7122 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -207,6 +207,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Safety:** PDF visibility variants preserve catalog/configuration entries and nonzero-generation references, redefining the existing catalog rather than choosing an arbitrary object number. Encrypted, hybrid-reference and over-budget rewrites are rejected; no print-fidelity claim. +- **Layer choices:** placed PDFs distinguish explicit all-on visibility from file defaults; + choices survive native saves, raster/PDF output and embedded IDML export. IDML layer + variants are embedded copies; external-only export is rejected when choices would be lost. - **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer comps beyond visibility; the Clipping Path Options dialog (tracing into a clip that keeps the frame, Threshold/Tolerance/Inset Frame preview, picking an alpha channel or a path saved in a diff --git a/docs/pdf-layer-visibility.md b/docs/pdf-layer-visibility.md new file mode 100644 index 000000000..3362b9dbb --- /dev/null +++ b/docs/pdf-layer-visibility.md @@ -0,0 +1,9 @@ +# Placed PDF layer visibility + +Object Layer Options distinguishes a PDF's own default configuration from an explicit visibility choice, including showing every layer. Use File Defaults resets that choice. Only placed PDFs in a mixed selection are changed. + +The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Queries include `overridden` separately from each layer's `visible` and `default` states. + +Native saves use format 4 only for an explicit all-on override: older readers would otherwise silently render file defaults. Existing nonempty hidden lists retain their older semantics and format requirements. Raster and PDF export honor the choice; PDF export keeps its existing warned rasterization behavior. Embedded IDML export stores a separate PDF variant per placement and leaves original linked bytes untouched. External-only IDML export rejects active PDF layer choices because the original external file would lose the choice. + +This fixes visibility-state handling, not full InDesign layer/print parity. It does not implement editing the contents of placed PDFs or native INDD placement. Catalog/reference safety fixes are tracked separately. The concurrent graphic-layer metadata PR also changes native format capability; its version selection must be reconciled when merging both features. From 57658368af0e7b401b1d87a8980bc68bd0937c5e Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 19:29:29 +0100 Subject: [PATCH 06/38] PDF: validate layer overrides and query mixed selections --- ROADMAP.md | 4 ++++ crates/engine/src/cmd/pdflayers.rs | 33 +++++++++++++++++++++++++++++- docs/pdf-layer-visibility.md | 2 +- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0f621b7ce..42a6b2d21 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,6 +25,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope regression tests cover metadata, output-intent references, nonzero generations and forward Prev links; repeated cross-reference offsets are rejected explicitly. No readiness percentage change. +- **2026-10-23** · PDF layer commands reject overrides on layerless files atomically; + default layer queries find a placed PDF anywhere in mixed selections. Regression tests cover + IDML export after rejection and both selection orders. + ## Status (2026-10-01) **Working today** diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index 8d339dc9d..1b1bdf61d 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -15,7 +15,7 @@ fn placed_pdf(s: &Session, id: designcraft_doc::ItemId) -> Option Vec { vec![ cmd!(query "object.pdfLayers", "Object Layers", [], None, "{id?} → [{name, visible, default}] — the layers of the selected placed PDF", has_selection, |s, p| { - let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.first().copied())).ok_or_else(|| bad("object.pdfLayers", "no selection"))?; + let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.iter().copied().find(|id| placed_pdf(s, *id).is_some()))).ok_or_else(|| bad("object.pdfLayers", "no selection"))?; let data = placed_pdf(s, id).ok_or_else(|| bad("object.pdfLayers", "not a placed PDF"))?; let item = s.doc()?.doc.item(id); let hidden = item.map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default(); @@ -51,6 +51,11 @@ pub fn specs() -> Vec { if ids.is_empty() { return Err(bad("object.layerOptions", "select a placed PDF")); } + // A layerless/unreadable PDF cannot carry a visibility variant. Validate + // every target before editing so a mixed request remains atomic. + if !use_defaults && ids.iter().any(|id| placed_pdf(s, *id).is_none_or(|data| designcraft_render::pdf_layers(&data).is_empty())) { + return Err(bad("object.layerOptions", "the selected PDF has no readable optional-content layers")); + } // Validate every selected PDF before editing any item or recording undo. // Repeated placements of the same source need only one rewrite check. if !hidden.is_empty() { @@ -434,6 +439,27 @@ pub(crate) mod tests { s.execute("object.layerOptions", &json!({"ids":[id],"useDefaults":true})).unwrap(); assert!(!visible(&mut s), "explicit reset restores defaults"); } + #[test] + fn layerless_pdf_rejects_overrides_without_breaking_idml_export() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let pdf = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); + let id = s.execute("file.place", &json!({"base64":pdf,"name":"plain.pdf","x":0,"y":0,"width":100})).unwrap()["id"].clone(); + let layered = s + .execute( + "file.place", + &json!({"base64":super::super::file::base64_encode(&two_layer_pdf()),"name":"layered.pdf","x":120,"y":0,"width":100}), + ) + .unwrap()["id"] + .clone(); + let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + assert!(s.execute("object.layerOptions", &json!({"ids":[layered,id],"hidden":[],"useDefaults":false})).is_err()); + assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before); + assert!(s.execute("file.exportIdml", &json!({})).is_ok()); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":true})).unwrap(); + assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().has_pdf_layer_override()); + } + #[test] fn pdf_layer_options_ignore_non_pdf_targets_in_mixed_selection() { let mut s = Session::new(); @@ -445,6 +471,11 @@ pub(crate) mod tests { ) .unwrap(); let other = s.execute("frame.create", &json!({"rect":[0,0,20,20]})).unwrap(); + for ids in [json!([other["id"], placed["id"]]), json!([placed["id"], other["id"]])] { + s.execute("selection.set", &json!({"ids":ids})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({})).unwrap().as_array().unwrap().len(), 2); + } + assert!(s.execute("object.pdfLayers", &json!({"id":other["id"]})).is_err(), "explicit non-PDF id remains an error"); s.execute("object.layerOptions", &json!({"ids":[placed["id"],other["id"]],"hidden":[],"useDefaults":false})).unwrap(); assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(other["id"].as_u64().unwrap())).unwrap().has_pdf_layer_override()); assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"hidden":["Red"],"useDefaults":true})).is_err()); diff --git a/docs/pdf-layer-visibility.md b/docs/pdf-layer-visibility.md index 3362b9dbb..a6c7bed38 100644 --- a/docs/pdf-layer-visibility.md +++ b/docs/pdf-layer-visibility.md @@ -2,7 +2,7 @@ Object Layer Options distinguishes a PDF's own default configuration from an explicit visibility choice, including showing every layer. Use File Defaults resets that choice. Only placed PDFs in a mixed selection are changed. -The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Queries include `overridden` separately from each layer's `visible` and `default` states. +The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Overrides on PDFs without readable optional-content layers are rejected before any target is edited. With no explicit ID, a query selects the first placed PDF anywhere in the selection; an explicit non-PDF ID remains an error. Queries include `overridden` separately from each layer's `visible` and `default` states. Native saves use format 4 only for an explicit all-on override: older readers would otherwise silently render file defaults. Existing nonempty hidden lists retain their older semantics and format requirements. Raster and PDF export honor the choice; PDF export keeps its existing warned rasterization behavior. Embedded IDML export stores a separate PDF variant per placement and leaves original linked bytes untouched. External-only IDML export rejects active PDF layer choices because the original external file would lose the choice. From 96a4a9060b12f93e9e8bdb25548cc9e019d5c7cd Mon Sep 17 00:00:00 2001 From: Andee Collard Date: Sat, 24 Oct 2026 13:51:19 +0100 Subject: [PATCH 07/38] Format: test one version 4 reader keeping both layer capabilities skylartaylor review of #1647/#1671: both PRs introduced native format 4 for different capabilities. Stacked on #1647, version 4 is one capability: required_version (the single place that picks a version) asks for it when a document has retained placed-graphic layer options or an explicit all-on PDF layer choice, so a version 4 reader understands both. That merge was made while rebasing "PDF: distinguish all-on layer choices from file defaults", which also replaced its Document::all_items scan (document spreads only) with Document::has_explicit_all_on_pdf_layers, covering parent spreads, groups, anchored objects and nested story content; an all-on PDF on a parent page previously saved as version 2. This save/reopen test holds both features in one document, checks each alone still needs 4, that documents with neither keep 3 (EPS) or 2, and that a parent-spread all-on choice needs 4. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/lib.rs | 59 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index af1051ab0..e33183dc2 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -486,6 +486,65 @@ mod tests { assert_eq!(version(&d), 2, "legacy nonempty overrides remain readable"); } + #[test] + fn one_version_4_reader_keeps_both_explicit_all_on_and_graphic_layer_options() { + use designcraft_doc::{Content, GraphicLayerOptions}; + let mut d = Document::new(&NewDocument::default()); + let pdf = AssetId(d.alloc()); + d.assets.insert( + pdf, + Arc::new(Asset { + id: pdf, + name: "art.pdf".into(), + mime: "application/pdf".into(), + data: Arc::new(b"%PDF-1.7".to_vec()), + ..Default::default() + }), + ); + let eps = AssetId(d.alloc()); + let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec(); + d.assets.insert( + eps, + Arc::new(Asset { id: eps, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(source), ..Default::default() }), + ); + let all_on = place(&mut d, pdf, 0.0); + let layered = place(&mut d, eps, 0.0); + assert_eq!(required_version(&d), 3, "neither feature: EPS documents keep version 3"); + let options = GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() }; + let set_options = |d: &mut Document, value: Option| { + let Some(Content::Graphic(g)) = d.item_mut(layered).map(|it| &mut it.content) else { panic!("placed graphic") }; + g.layer_options = value; + }; + set_options(&mut d, Some(options.clone())); + d.item_mut(all_on).unwrap().pdf_layer_override = true; + let bytes = save(&d).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap(); + let mut meta = String::new(); + z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap(); + assert_eq!(serde_json::from_str::(&meta).unwrap()["version"], 4); + let reopened = load(&bytes).unwrap(); + let it = reopened.item(all_on).unwrap(); + assert!(it.pdf_layer_override && it.pdf_hidden_layers.is_empty(), "explicit all-on survives"); + assert_eq!(reopened.item(layered).unwrap().graphic().unwrap().layer_options.as_ref(), Some(&options), "graphic layer options survive"); + assert_eq!(required_version(&reopened), 4); + // Either feature alone needs version 4; dropping both restores the older requirement. + set_options(&mut d, None); + assert_eq!(required_version(&d), 4, "explicit all-on alone"); + d.item_mut(all_on).unwrap().pdf_layer_override = false; + assert_eq!(required_version(&d), 3); + set_options(&mut d, Some(options)); + assert_eq!(required_version(&d), 4, "graphic layer options alone"); + set_options(&mut d, None); + d.assets.remove(&eps); + d.remove_item(layered).unwrap(); + assert_eq!(required_version(&d), 2, "neither feature and no EPS: version 2"); + // An explicit all-on choice on a parent spread also needs the version 4 reader. + let mut parent_item = (*d.remove_item(all_on).unwrap()).clone(); + parent_item.pdf_layer_override = true; + d.insert_item(SpreadRef::Parent(0), parent_item, None).unwrap(); + assert_eq!(required_version(&d), 4, "parent-spread placements are not skipped"); + } + #[test] fn eps_source_requires_new_reader_but_preview_documents_keep_the_older_one() { let mut d = Document::new(&NewDocument::default()); From aeccc9c5d3ea2828905e82f003c7fbbc547fefd7 Mon Sep 17 00:00:00 2001 From: Andee Collard Date: Sat, 24 Oct 2026 13:51:19 +0100 Subject: [PATCH 08/38] PDF: validate an actual layer rewrite for explicit all-on choices skylartaylor's review: a PDF can list readable layers yet fail the visibility rewrite. On a synthetic 33-section xref history, object.layerOptions with useDefaults:false and an empty hidden list was accepted (only hidden names triggered #1670's rewrite check), the canvas could not draw the choice and PDF export then failed. Every explicit choice now runs one actual pdf_hide_layers rewrite per distinct source before any item is edited or undo is recorded; this subsumes the separate layerless check. Resetting to file defaults (useDefaults:true or the legacy empty list) needs no variant and bypasses the check. #1670's fail-closed canvas handling is kept alongside the explicit-override state: a stored explicit choice (including all-on) whose source can no longer be rewritten draws nothing rather than the unmodified source, and export errors. The new regression test fails before this change (the all-on request is accepted) and covers both selection orders, atomicity, undo depth, reset bypass and the stale all-on fail-closed path. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 ++ crates/engine/src/cmd/pdflayers.rs | 65 +++++++++++++++++++++++------- docs/pdf-layer-visibility.md | 6 +-- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 42a6b2d21..0de1cfdde 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,9 @@ 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-24** · Explicit PDF layer choices, including all-on, are checked with one actual + rewrite per distinct source before editing; reset to file defaults bypasses the check. Native + format 4 covers both explicit all-on PDF layers and retained graphic-layer options. No readiness percentage change. - **2026-10-23** · Placed PDF layers can explicitly all be shown, separately from Use File Defaults. Legacy commands keep their reset behavior; native saves require a capable reader only for the new all-on state, and PDF/embedded IDML exports preserve the choice. No readiness percentage change. diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index 1b1bdf61d..c4fe0a31f 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -51,23 +51,20 @@ pub fn specs() -> Vec { if ids.is_empty() { return Err(bad("object.layerOptions", "select a placed PDF")); } - // A layerless/unreadable PDF cannot carry a visibility variant. Validate - // every target before editing so a mixed request remains atomic. - if !use_defaults && ids.iter().any(|id| placed_pdf(s, *id).is_none_or(|data| designcraft_render::pdf_layers(&data).is_empty())) { - return Err(bad("object.layerOptions", "the selected PDF has no readable optional-content layers")); - } - // Validate every selected PDF before editing any item or recording undo. - // Repeated placements of the same source need only one rewrite check. - if !hidden.is_empty() { + // Validate every selected PDF before editing any item or recording undo, so a + // mixed request stays atomic. Any explicit choice, including all-on + // (useDefaults:false with no hidden names), needs one actual rewrite per distinct + // source: a PDF can list readable layers and still fail the rewrite, and a + // layerless PDF cannot be rewritten at all. Resetting to the file defaults needs no + // variant and is always allowed. + if !use_defaults { let mut checked = std::collections::HashSet::new(); for id in &ids { - if let Some(data) = placed_pdf(s, *id) - && checked.insert(std::sync::Arc::as_ptr(&data)) - && designcraft_render::pdf_hide_layers(&data, &hidden).is_none() - { + let Some(data) = placed_pdf(s, *id) else { continue }; + if checked.insert(std::sync::Arc::as_ptr(&data)) && designcraft_render::pdf_hide_layers(&data, &hidden).is_none() { return Err(bad( "object.layerOptions", - "the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF", + "the selected PDF layers could not be changed (no readable optional-content layers, or an unsupported file); use the file defaults or replace the PDF", )); } } @@ -258,7 +255,7 @@ pub(crate) mod tests { let redo = s.doc().unwrap().history.redo.len(); for ids in [json!([good, bad]), json!([bad, good])] { let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err(); - assert!(err.to_string().contains("could not be hidden")); + assert!(err.to_string().contains("could not be changed")); assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before); assert_eq!(s.doc().unwrap().history.undo.len(), undo); assert_eq!(s.doc().unwrap().history.redo.len(), redo); @@ -278,6 +275,46 @@ pub(crate) mod tests { s.execute("file.exportPdf", &json!({})).unwrap(); } + #[test] + fn explicit_all_on_validates_an_actual_rewrite_before_editing() { + // 33 xref sections: the layers stay readable, but the visibility rewrite refuses the history. + let mut unsupported = two_layer_pdf(); + for _ in 0..32 { + add_pdf_revision(&mut unsupported); + } + assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2); + assert!(designcraft_render::pdf_hide_layers(&unsupported, &[]).is_none()); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap(); + let good = place(&mut s, &two_layer_pdf(), 0.0); + let bad = place(&mut s, &unsupported, 100.0); + let before = s.execute("file.serialize", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for ids in [json!([good, bad]), json!([bad, good]), json!([bad])] { + let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": [], "useDefaults": false})).unwrap_err(); + assert!(err.to_string().contains("could not be changed"), "{err}"); + assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before, "rejected all-on must not edit any target"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + assert_eq!(pixel(&s, 175), [255, 0, 0, 255], "the unsupported source keeps showing its file defaults"); + s.execute("file.exportPdf", &json!({})).unwrap(); + // The supported source alone still accepts the explicit all-on choice. + s.execute("object.layerOptions", &json!({"ids": [good], "hidden": [], "useDefaults": false})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": good})).unwrap()[1]["overridden"], true); + // Resetting to file defaults needs no variant, so it bypasses the rewrite check. + for reset in [json!({"ids": [good, bad], "hidden": []}), json!({"ids": [good, bad], "useDefaults": true})] { + s.execute("object.layerOptions", &reset).unwrap(); + for id in [&good, &bad] { + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["overridden"], false); + } + } + // A stale explicit all-on choice that outlived its rewritable source still fails closed. + let bad_id = serde_json::from_value(bad.clone()).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_layer_override = true; + assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "unrewritable explicit choice must not draw the unmodified source"); + assert!(s.execute("file.exportPdf", &json!({})).is_err()); + } + #[test] fn unreadable_non_null_prev_entries_refuse_layer_rewrites() { for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] { diff --git a/docs/pdf-layer-visibility.md b/docs/pdf-layer-visibility.md index a6c7bed38..c93e704b4 100644 --- a/docs/pdf-layer-visibility.md +++ b/docs/pdf-layer-visibility.md @@ -2,8 +2,8 @@ Object Layer Options distinguishes a PDF's own default configuration from an explicit visibility choice, including showing every layer. Use File Defaults resets that choice. Only placed PDFs in a mixed selection are changed. -The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Overrides on PDFs without readable optional-content layers are rejected before any target is edited. With no explicit ID, a query selects the first placed PDF anywhere in the selection; an explicit non-PDF ID remains an error. Queries include `overridden` separately from each layer's `visible` and `default` states. +The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Every explicit choice, including all-on (`useDefaults: false` with an empty `hidden` list), is checked with one actual visibility rewrite per distinct source before any target is edited or an undo step is recorded: a PDF can list readable layers yet refuse the rewrite (for example an over-budget cross-reference history), and PDFs without readable optional-content layers cannot be rewritten at all. Resetting to file defaults needs no rewrite and is always allowed. A stored choice whose source can no longer be rewritten still fails closed: the canvas does not draw the unmodified source and PDF export reports an error. With no explicit ID, a query selects the first placed PDF anywhere in the selection; an explicit non-PDF ID remains an error. Queries include `overridden` separately from each layer's `visible` and `default` states. -Native saves use format 4 only for an explicit all-on override: older readers would otherwise silently render file defaults. Existing nonempty hidden lists retain their older semantics and format requirements. Raster and PDF export honor the choice; PDF export keeps its existing warned rasterization behavior. Embedded IDML export stores a separate PDF variant per placement and leaves original linked bytes untouched. External-only IDML export rejects active PDF layer choices because the original external file would lose the choice. +Native saves use format 4 only for an explicit all-on override, on document or parent spreads, in groups or anchored: older readers would otherwise silently render file defaults. Format 4 is one reader capability shared with retained placed-graphic layer options (#1647, see [graphic-layer-options.md](graphic-layer-options.md)), so a version 4 reader understands both. Existing nonempty hidden lists retain their older semantics and format requirements. Raster and PDF export honor the choice; PDF export keeps its existing warned rasterization behavior. Embedded IDML export stores a separate PDF variant per placement and leaves original linked bytes untouched. External-only IDML export rejects active PDF layer choices because the original external file would lose the choice. -This fixes visibility-state handling, not full InDesign layer/print parity. It does not implement editing the contents of placed PDFs or native INDD placement. Catalog/reference safety fixes are tracked separately. The concurrent graphic-layer metadata PR also changes native format capability; its version selection must be reconciled when merging both features. +This fixes visibility-state handling, not full InDesign layer/print parity. It does not implement editing the contents of placed PDFs or native INDD placement. Catalog/reference safety fixes landed in #1670. From ab8ec71234049815b83492122d8429b22a01da40 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 24 Oct 2026 15:37:10 -0600 Subject: [PATCH 09/38] PDF: keep both layer readers compiling after the merge Graphic gained an optional layer_options field, so the test-only literals in the PDF crate and in the engine's optional-content support need it. No behaviour changes. --- crates/engine/tests/support/optional_content.rs | 1 + crates/pdf/src/export.rs | 1 + crates/pdf/src/registration_tests.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/engine/tests/support/optional_content.rs b/crates/engine/tests/support/optional_content.rs index 3ed73d74f..b3acb3511 100644 --- a/crates/engine/tests/support/optional_content.rs +++ b/crates/engine/tests/support/optional_content.rs @@ -115,6 +115,7 @@ pub fn document(bytes: Vec, hidden_sets: &[&[&str]]) -> Document { crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), + layer_options: None, }); doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dd59ff418..d06fdde0c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -2009,6 +2009,7 @@ mod image_content_cache_tests { crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), + layer_options: None, }; ex.graphic_unclipped(&mut surface, &graphic, hidden.as_deref()); assert!(ex.layer_error.is_none()); diff --git a/crates/pdf/src/registration_tests.rs b/crates/pdf/src/registration_tests.rs index 278100304..d3d76f961 100644 --- a/crates/pdf/src/registration_tests.rs +++ b/crates/pdf/src/registration_tests.rs @@ -17,6 +17,7 @@ fn placed_document(source: Vec) -> Document { crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), + layer_options: None, }); doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); doc From f09df5123e0e8357383c091a0cf4e3837dabd9a4 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 24 Oct 2026 15:37:12 -0600 Subject: [PATCH 10/38] M6: keep placed-PDF layer choices for parent-page and anchored placements The IDML layer-variant pass and the PDF/A-2b rasterisation pass both walked only document-spread items, so a choice on a parent-page placement (or an anchored one) was replaced by the file's defaults on export, and an external-only IDML export succeeded when it could not carry the choice. Both now cover every placement through Document::placed_items, and the choice is cleared where the placement actually is. Regression: a parent-page placement with an explicit all-on choice keeps all layers in embedded IDML, external-only IDML rejects it, and PDF/A-2b rasterises it. --- crates/doc/src/assets.rs | 9 ++++++ crates/doc/src/edit.rs | 18 ++++++++++++ crates/engine/src/cmd/export.rs | 39 +++++++++++++++++-------- crates/engine/src/cmd/interchange.rs | 43 +++++++++++++++++----------- crates/engine/src/cmd/pdflayers.rs | 32 +++++++++++++++++++++ 5 files changed, 112 insertions(+), 29 deletions(-) diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index c526c98ba..1533b8b4b 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -59,6 +59,15 @@ impl Document { || self.stories.values().any(|story| story_any(story, 0, &all_on)) } + /// Whether any placed PDF, on document or parent spreads, in groups or anchored, has an + /// Object Layer Options choice (hidden layers or explicit all-on). Exceeding the traversal + /// budget conservatively says yes. + pub fn has_pdf_layer_overrides(&self) -> bool { + let chosen = |it: &Item| it.has_pdf_layer_override(); + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &chosen)) + || self.stories.values().any(|story| story_any(story, 0, &chosen)) + } + /// Placements outside the ordinary document-spread frame walk used by preflight. pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool { self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 53308e1b8..617743840 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -642,6 +642,24 @@ impl Document { found } + /// Every item on document and parent spreads, in groups, and among the objects embedded in + /// stories (anchored objects, notes and table cells): walks that must cover every placement + /// use this, not [`Document::all_items`]. + pub fn placed_items(&self) -> Vec<&Item> { + let mut v = Vec::new(); + for sp in self.spreads.iter().chain(&self.parents) { + for it in &sp.items { + it.walk(&mut |i| v.push(i)); + } + } + for st in self.stories.values() { + for o in &st.objects { + o.item.walk(&mut |i| v.push(i)); + } + } + v + } + /// Every item id (pre-order) on document spreads. pub fn all_items(&self) -> Vec { let mut v = Vec::new(); diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f019976f2..bd1fba101 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -671,20 +671,27 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I } /// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. +/// +/// Every placement counts, including parent-page and anchored ones, so a choice is never +/// silently replaced by the file's defaults. fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { - let ids: Vec = - d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| it.has_pdf_layer_override() && it.graphic().is_some())).collect(); - if ids.is_empty() { + let choices: Vec<_> = d + .placed_items() + .into_iter() + .filter(|it| it.has_pdf_layer_override()) + .filter_map(|it| { + let g = it.graphic()?; + let a = d.assets.get(&g.asset).filter(|a| designcraft_render::is_pdf(&a.data))?; + Some((it.id, g.clone(), a.clone(), it.pdf_hidden_layers.clone())) + }) + .collect(); + if choices.is_empty() { return Ok((None, 0)); } let mut out = d.clone(); + let mut images = std::collections::HashMap::new(); let mut n = 0; - for id in ids { - let Some((g, hidden)) = out.item(id).and_then(|it| Some((it.graphic()?.clone(), it.pdf_hidden_layers.clone()))) else { continue }; - let Some(a) = out.assets.get(&g.asset).cloned() else { continue }; - if !designcraft_render::is_pdf(&a.data) { - continue; - } + for (id, g, a, hidden) in choices { // The whole page at 300 ppi (the graphic may show only a box of it). let shown = designcraft_render::shown_box(&a); let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); @@ -705,11 +712,19 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option Result { /// Each placement gets its own embedded variant so two frames sharing a PDF can /// choose different layers. The live document and original linked bytes stay untouched. +/// +/// Every placement counts, including parent-page and anchored ones: a choice that the copy +/// cannot carry is rejected rather than exported as the file's defaults. fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result> { - let ids: Vec<_> = doc - .all_items() + let choices: Vec<_> = doc + .placed_items() .into_iter() - .filter(|id| { - doc.item(*id).is_some_and(|it| { - it.has_pdf_layer_override() - && it.graphic().and_then(|g| doc.assets.get(&g.asset)).is_some_and(|a| designcraft_render::is_pdf(&a.data)) - }) + .filter(|it| it.has_pdf_layer_override()) + .filter_map(|it| { + let asset = it.graphic().and_then(|g| doc.assets.get(&g.asset)).filter(|a| designcraft_render::is_pdf(&a.data))?; + Some((it.id, asset, it.pdf_hidden_layers.clone())) }) .collect(); - if ids.is_empty() { + if choices.is_empty() { return Ok(None); } if !embed { return Err(EngineError::Other("PDF layer choices require embedded images in IDML export".into())); } let mut out = doc.clone(); - for id in ids { - let Some(it) = doc.item(id) else { continue }; - let Some(asset) = it.graphic().and_then(|g| doc.assets.get(&g.asset)) else { continue }; - let bytes = designcraft_render::pdf_hide_layers(&asset.data, &it.pdf_hidden_layers) + let mut variants = std::collections::HashMap::new(); + for (id, asset, hidden) in choices { + let bytes = designcraft_render::pdf_hide_layers(&asset.data, &hidden) .ok_or_else(|| EngineError::Other(format!("{}: PDF layer choices could not be applied", asset.name)))?; let mut variant = (**asset).clone(); variant.id = designcraft_doc::AssetId(out.alloc()); @@ -71,14 +72,22 @@ fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result