From 8d295b101e6a0ae2fd537d853af1b1d652fcb50f Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 17:56:49 +0100 Subject: [PATCH] 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.