M6: refuse an export only for a choice it could have applied

The refusal guard asked whether any item still had the layer fields, while the export walks
also require a graphic whose asset is a PDF. Relinking a placed PDF to an image keeps those
fields on the item (links.relink replaces the asset in place), so a stale field blocked IDML
and PDF/A-2b exports that used to work. The guard, and the package report, now ask the same
question the walks ask: has_applicable_pdf_layer_overrides requires the PDF asset too, so a
stale choice is simply ignored and the tripwire still fires if a future construct ever puts a
real choice where the walk cannot reach it.

Regression: a_stale_choice_on_a_relinked_image_does_not_block_exports places a layered PDF,
hides a layer, relinks that asset to a PNG and exports IDML, PDF/A-2b and plain PDF - all
succeed, and the document reports the stale field while the applicable helper does not.
This commit is contained in:
skylar taylor-barrick
2026-10-24 16:11:28 -06:00
parent 1266ce8236
commit 08876e1868
4 changed files with 52 additions and 6 deletions
+3 -2
View File
@@ -686,8 +686,9 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraf
})
.collect();
if choices.is_empty() {
// Fail closed: a choice this walk cannot reach must not become the file's defaults.
if d.has_pdf_layer_overrides() {
// Fail closed, with the same predicate as the walk: a stale choice on an item that is no
// longer a placed PDF must not block the export.
if super::interchange::has_applicable_pdf_layer_overrides(d) {
return Err(EngineError::Other("a placed PDF's layer choice sits where this raster export cannot apply it".into()));
}
return Ok((None, 0));
+14 -3
View File
@@ -40,6 +40,15 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
}
}
/// Whether a placement still holds an Object Layer Options choice the exports can apply: a
/// graphic whose asset is a PDF. A choice left on an item that is no longer a placed PDF (its
/// link was replaced with another format) is not one, and must not block an export.
pub(crate) fn has_applicable_pdf_layer_overrides(doc: &Document) -> bool {
doc.placed_items().into_iter().any(|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))
})
}
/// 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.
///
@@ -56,9 +65,11 @@ fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result<Option<Documen
})
.collect();
if choices.is_empty() {
// A choice this walk cannot reach would silently become the file's defaults (a newer
// nesting construct, say), so the copy is refused instead.
if doc.has_pdf_layer_overrides() {
// A choice the walk cannot reach would silently become the file's defaults (a newer nesting
// construct, say), so the copy is refused instead. The predicate matches the walk above:
// both ask for a graphic whose asset is a PDF, so stale fields on a relinked image never
// block an export.
if has_applicable_pdf_layer_overrides(doc) {
return Err(EngineError::Other("a placed PDF's layer choice sits where this IDML export cannot apply it".into()));
}
return Ok(None);
+1 -1
View File
@@ -437,7 +437,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
let opts = designcraft_idml::ExportOptions { embed_images: false };
root.write(&path, &designcraft_idml::export_idml_with(&packed, &opts))?;
files.push(path);
if packed.has_pdf_layer_overrides() {
if super::interchange::has_applicable_pdf_layer_overrides(&packed) {
idml_layer_note = "\nPlaced PDF layers\nThe IDML copy links the placed PDFs, so it shows their file defaults. The DesignCraft document keeps each placement's Object Layer Options choice.\n";
}
}
+34
View File
@@ -588,6 +588,40 @@ pub(crate) mod tests {
}
}
/// Relinking a placed PDF to an image leaves the item's layer fields behind. They are no longer a
/// placed-PDF choice, so they must not block an export: the refusal guard asks for a graphic whose
/// asset is a PDF, the same thing the export walks ask for.
#[test]
fn a_stale_choice_on_a_relinked_image_does_not_block_exports() {
let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let id = s
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "art.pdf", "x": 0, "y": 0, "width": 100}))
.unwrap()["id"]
.clone();
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap();
let asset = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().graphic().unwrap().asset;
let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png();
let path = std::env::temp_dir().join(format!(
"dc-relinked-{}-{}.png",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
std::fs::write(&path, &png).unwrap();
s.execute("links.relink", &json!({"asset": asset.0, "path": path.to_string_lossy()})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(!designcraft_render::is_pdf(&d.assets[&asset].data), "the placement is an image now");
assert!(d.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().has_pdf_layer_override(), "the stale field is still on the item");
assert!(d.has_pdf_layer_overrides(), "the field is there");
assert!(!super::super::interchange::has_applicable_pdf_layer_overrides(d), "but it is not a placed-PDF choice");
// Both exports still run: nothing is dropped, and there is no choice to refuse.
s.execute("file.exportIdml", &json!({})).unwrap();
s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
s.execute("file.exportPdf", &json!({})).unwrap();
std::fs::remove_file(&path).ok();
}
#[test]
fn layerless_pdf_rejects_overrides_without_breaking_idml_export() {
let mut s = Session::new();