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.