mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 11:46:51 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
96a4a9060b
commit
aeccc9c5d3
@@ -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.
|
||||
|
||||
@@ -51,23 +51,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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)"] {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user