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 <noreply@anthropic.com>
This commit is contained in:
KnottyDyes
2026-10-24 01:54:58 -06:00
co-authored by Claude Opus 5.5
parent 38d692089f
commit a84faee829
4 changed files with 37 additions and 11 deletions
+5 -7
View File
@@ -118,11 +118,7 @@ fn exported(hidden: bool) -> Vec<u8> {
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}");
}
+11 -2
View File
@@ -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(&[
"<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[5 0 R]>>>>".into(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R>>>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</Type/OCG/Name(Hidden)>>".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();
+20 -1
View File
@@ -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<Vec<ObjectIdentifier>> {
if !dict.contains_key(key) {
return Ok(Vec::new());
@@ -128,6 +134,9 @@ pub(crate) fn prepare(bytes: &Arc<Vec<u8>>, hidden: &[String]) -> Result<Variant
.objects()
.into_iter()
.any(|object| matches!(object, Object::Dict(dict) if dict.get::<Name<'_>>(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::<Dict<'_>>(b"OCProperties").ok_or_else(invalid)?;
@@ -165,6 +174,7 @@ pub(crate) fn prepare(bytes: &Arc<Vec<u8>>, hidden: &[String]) -> Result<Variant
let mut changes = Vec::new();
let mut effective = Vec::new();
let mut renamed = false;
let mut found = vec![false; hidden.len()];
for id in groups {
let group = xref.get::<Dict<'_>>(id).ok_or_else(invalid)?;
if group.get::<Name<'_>>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"OCG") {
@@ -175,7 +185,13 @@ pub(crate) fn prepare(bytes: &Arc<Vec<u8>>, hidden: &[String]) -> Result<Variant
(true, _) => !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<Vec<u8>>, hidden: &[String]) -> Result<Variant
};
changes.push((id, body));
}
if found.contains(&false) {
return Err(missing_layer());
}
effective.sort();
effective.dedup();
let data = if changes.is_empty() { bytes.clone() } else { Arc::new(append(bytes, &changes, xref.root_id())?) };
+1 -1
View File
@@ -45,7 +45,7 @@ fn unlayered_source() -> Vec<u8> {
])
}
fn assemble(objects: &[String]) -> Vec<u8> {
pub(super) fn assemble(objects: &[String]) -> Vec<u8> {
let mut bytes = b"%PDF-1.5\n".to_vec();
let mut offsets = Vec::new();
for (index, body) in objects.iter().enumerate() {