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() {