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.