From a84faee829c9e414bc2aa3afc181a9baa6fcd9be Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:45:00 -0600 Subject: [PATCH] PDF: a hidden layer missing from the placed file aborts vector export Main now aborts an export when a stored hidden layer can no longer be applied (#876). Vector export follows it: a layer hidden in Object Layer Options that the placed PDF doesn't have, because the file has no layers or was relinked, fails with an error naming the file instead of exporting everything visible. The image-path tests for oversized images and undecodable content export as PDF/A-2b, which still rasterizes placed PDFs with hidden layers. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/it/pdf_backend.rs | 12 +++++------- crates/pdf/src/export.rs | 13 +++++++++++-- crates/pdf/src/optional_content.rs | 21 ++++++++++++++++++++- crates/pdf/src/registration_tests.rs | 2 +- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/crates/engine/tests/it/pdf_backend.rs b/crates/engine/tests/it/pdf_backend.rs index 5344dc6f7..e68cbae81 100644 --- a/crates/engine/tests/it/pdf_backend.rs +++ b/crates/engine/tests/it/pdf_backend.rs @@ -118,11 +118,7 @@ fn exported(hidden: bool) -> Vec { session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap(); } let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap(); - let bytes = base64_decode(report["base64"].as_str().unwrap()); - if hidden { - assert!(report["warnings"].to_string().contains("hidden layers")); - } - bytes + base64_decode(report["base64"].as_str().unwrap()) } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] @@ -185,7 +181,8 @@ fn oversized_image_does_not_silently_disappear_from_layered_export() { .unwrap(); session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap(); let before = session.execute("file.serialize", &json!({})).unwrap(); - let result = session.execute("file.exportPdf", &json!({})); + // PDF/A-2b has no optional content: placed PDFs with hidden layers go out as images there. + let result = session.execute("file.exportPdf", &json!({"standard": "pdfa2b"})); assert!(result.is_err(), "export must report the image omitted by the backend's resource limit"); assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "a failed export must leave the document unchanged"); let error = result.unwrap_err().to_string(); @@ -317,7 +314,8 @@ fn failed_content_decode_aborts_layered_export_without_changing_the_document() { .unwrap(); session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap(); let before = session.execute("file.serialize", &json!({})).unwrap(); - let error = session.execute("file.exportPdf", &json!({})).unwrap_err().to_string(); + // PDF/A-2b has no optional content: placed PDFs with hidden layers go out as images there. + let error = session.execute("file.exportPdf", &json!({"standard": "pdfa2b"})).unwrap_err().to_string(); assert!(error.contains("broken-content.pdf") && error.contains("content"), "{filter}: {error}"); assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "{filter}"); } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 61fea9ac2..db7c77ae4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1970,7 +1970,15 @@ mod image_content_cache_tests { let mut doc = Document::new(&Default::default()); let cache = Cache::new(); let opts = PdfOptions::default(); - let source = export_pdf(&doc, &cache, &opts).unwrap(); + // One layer, "Hidden", shown by default: hiding it needs a prepared copy, showing it doesn't. + let content = "/OC /a BDC 0 0 1 rg 0 0 100 100 re f EMC"; + let source = registration_tests::assemble(&[ + "<>>>".into(), + "<>".into(), + "<>>>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>".into(), + ]); let a = asset(&mut doc, source.clone(), "a.pdf"); let b = asset(&mut doc, source, "b.pdf"); let mut ex = exporter(&doc, &cache, &opts); @@ -1994,7 +2002,8 @@ mod image_content_cache_tests { assert_eq!(ex.pdf_preparations, count, "count actual preparations, not writer resource deduplication"); } assert_eq!(ex.variants.len(), 3); - assert_eq!(ex.pdfs.len(), 2); + // a, b, and a with its layer hidden (a copy of its own). + assert_eq!(ex.pdfs.len(), 3); let variant = ex.variants.get(&(a, Vec::new())).unwrap(); assert!(Arc::ptr_eq(&variant.data, &doc.assets[&a].data)); surface.finish(); diff --git a/crates/pdf/src/optional_content.rs b/crates/pdf/src/optional_content.rs index 8bd795937..6214f043f 100644 --- a/crates/pdf/src/optional_content.rs +++ b/crates/pdf/src/optional_content.rs @@ -11,6 +11,12 @@ fn invalid() -> PdfError { PdfError::Write("the placed PDF's optional-content state could not be preserved safely".into()) } +/// A layer hidden in Object Layer Options that the placed file doesn't have (relinked, say): +/// exporting would be a guess at what was meant to stay hidden. +fn missing_layer() -> PdfError { + PdfError::Write("a layer hidden in Object Layer Options is not in the placed PDF; relink it or reset its layer options".into()) +} + fn references(dict: &Dict<'_>, key: &[u8]) -> Result> { if !dict.contains_key(key) { return Ok(Vec::new()); @@ -128,6 +134,9 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"OCG"))); + if !hidden.is_empty() { + return Err(missing_layer()); + } return Ok(Variant { data: bytes.clone(), hidden: Vec::new(), warning: None, has_optional_content }); } let properties = root.get::>(b"OCProperties").ok_or_else(invalid)?; @@ -165,6 +174,7 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result>(id).ok_or_else(invalid)?; if group.get::>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"OCG") { @@ -175,7 +185,13 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result !fallback && (off.contains(&id) || (base_off && !on.contains(&id))), (false, Some(name)) => { let name = String::from_utf8_lossy(name.as_bytes()); - hidden.iter().any(|n| n == name.as_ref()) + let mut is_hidden = false; + for (n, found) in hidden.iter().zip(&mut found) { + if n == name.as_ref() { + (*found, is_hidden) = (true, true); + } + } + is_hidden } (false, None) => return Err(invalid()), }; @@ -200,6 +216,9 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result Vec { ]) } -fn assemble(objects: &[String]) -> Vec { +pub(super) fn assemble(objects: &[String]) -> Vec { let mut bytes = b"%PDF-1.5\n".to_vec(); let mut offsets = Vec::new(); for (index, body) in objects.iter().enumerate() {