Files
designcraft/crates/engine/src/cmd/pdflayers.rs
T
skylar taylor-barrick 00d5fa5cdd M6: reject unreadable PDF trailer history links (#1670)
Reject a non-null Prev entry that cannot be read as an offset instead of silently treating it as the end of the guarded trailer chain. Keep absent/null end markers and readable numeric offsets unchanged.

Reproduced CodeRabbit discussion_r4366245854 with an independent probe and a failing native regression before the fix. The synthetic regression covers seven unreadable value types plus absent/null and integer/real history controls.

Validation: all 9 cargo xtask ci gates passed (3063 tests passed, 6 skipped; 16 WASM-safe crates). One unrelated GPU test was labelled leaky by nextest in that run and passed cleanly in an isolated rerun. Independent checks: 17 original supported PDF variants still pass qpdf, logical preservation and Poppler pixels; 6 original refusal cases remain refused. All 11 additional Prev controls pass (7 refused, 4 accepted).
2026-10-24 00:40:33 -06:00

382 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Object › Object Layer Options: show or hide the layers (optional content) of a placed PDF.
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_selection, targets};
use crate::Session;
fn placed_pdf(s: &Session, id: designcraft_doc::ItemId) -> Option<std::sync::Arc<Vec<u8>>> {
let d = &s.active()?.doc;
let g = d.item(id)?.graphic()?;
let a = d.assets.get(&g.asset)?;
designcraft_render::is_pdf(&a.data).then(|| a.data.clone())
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "object.pdfLayers", "Object Layers", [], None, "{id?} → [{name, visible, default}] — the layers of the selected placed PDF", has_selection, |s, p| {
let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.first().copied())).ok_or_else(|| bad("object.pdfLayers", "no selection"))?;
let data = placed_pdf(s, id).ok_or_else(|| bad("object.pdfLayers", "not a placed PDF"))?;
let hidden = s.doc()?.doc.item(id).map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default();
Ok(Value::Array(
designcraft_render::pdf_layers(&data)
.into_iter()
.map(|(name, on)| json!({"visible": if hidden.is_empty() { on } else { !hidden.contains(&name) }, "name": name, "default": on}))
.collect(),
))
}),
cmd!(
"object.layerOptions",
"Object Layer Options…",
["Object"],
None,
"{ids?, hidden: [layer name]} — layers of placed PDFs to hide ([] = the file's own settings)",
has_selection,
|s, p| {
let ids = targets(s, p)?;
let hidden: Vec<String> = p
.get("hidden")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default();
if ids.iter().all(|id| placed_pdf(s, *id).is_none()) {
return Err(bad("object.layerOptions", "select a placed PDF"));
}
// 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() {
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()
{
return Err(bad(
"object.layerOptions",
"the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF",
));
}
}
}
s.edit(|d, _| {
for id in &ids {
if let Some(it) = d.item_mut(*id) {
it.pdf_hidden_layers = hidden.clone();
}
}
Ok(json!({"hidden": hidden}))
})
}
),
]
}
#[cfg(test)]
pub(crate) mod tests {
use serde_json::json;
use crate::Session;
/// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half).
pub(crate) fn two_layer_pdf() -> Vec<u8> {
two_layer_pdf_generation(0)
}
fn two_layer_pdf_generation(generation: i32) -> Vec<u8> {
two_layer_pdf_options(generation, "", "")
}
fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec<u8> {
let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC";
let objs = [
format!(
"<</Type/Catalog/Lang(en-GB)/Metadata 7 0 R/OutputIntents[8 0 R]/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]/Name(Default)/Locked[5 0 R]{config}>>>>>>"
),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 0 R>>>>/Contents 4 0 R>>".to_string(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</Type/OCG/Name(Blue)>>".to_string(),
"<</Type/OCG/Name(Red)>>".to_string(),
"<</Type/Metadata/Subtype/XML/Length 4>>\nstream\n<x/>\nendstream".to_string(),
"<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier(Test)>>".to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes());
}
let x = out.len();
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1);
for o in offs {
t.push_str(&format!("{o:010} {generation:05} n\r\n"));
}
t.push_str(&format!("trailer\n<</Size {}/Root 1 {generation} R{trailer}>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
out.extend_from_slice(t.as_bytes());
out
}
#[test]
fn pdf_layer_update_preserves_catalog_configuration_and_generations() {
for generation in [0, 1] {
let original = two_layer_pdf_generation(generation);
let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap();
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
let appended = std::str::from_utf8(&updated[original.len()..]).unwrap();
for token in [
"/Lang(en-GB)".to_string(),
format!("/Metadata 7 {generation} R"),
format!("/OutputIntents[8 {generation} R]"),
format!("/Locked[5 {generation} R]"),
"/Name(Default)".into(),
format!("/Root 1 {generation} R"),
] {
assert!(appended.contains(&token), "missing {token}: {appended}");
}
assert!(!appended.contains("9999000"));
let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap();
assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible));
}
}
#[test]
fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() {
let original = two_layer_pdf();
let text = std::str::from_utf8(&original).unwrap();
let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
let mut stream_pdf = original[..offset].to_vec();
let mut entries = vec![0, 0, 0, 0, 0, 255, 255];
for number in 1..=9 {
let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() };
entries.push(1);
entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes());
entries.extend_from_slice(&[0, 0]);
}
stream_pdf.extend_from_slice(format!("9 0 obj\n<</Type/XRef/Size 10/Root 1 0 R/W[1 4 2]/Length {}>>\nstream\n", entries.len()).as_bytes());
stream_pdf.extend_from_slice(&entries);
stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes());
let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap();
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
for unsupported in ["/Encrypt 7 0 R", "/Encrypt << /Filter /Standard >>", "/XRefStm 0"] {
let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9"));
assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none());
}
}
#[test]
fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() {
let original = two_layer_pdf();
let text = std::str::from_utf8(&original).unwrap();
let first = original.len();
let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<</Size 9/Root 1 0 R/Prev {prev:010}>>\n");
let main = first + link(0).len();
let mut forward = original.clone();
forward.extend_from_slice(link(main).as_bytes());
let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes());
forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap();
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
let mut cyclic = original;
cyclic.extend_from_slice(link(first).as_bytes());
cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none());
}
fn add_pdf_revision(bytes: &mut Vec<u8>) {
let text = std::str::from_utf8(bytes).unwrap();
let prev: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
let xref = bytes.len();
bytes.extend_from_slice(
format!("xref\n0 1\n0000000000 65535 f\r\ntrailer\n<</Size 9/Root 1 0 R/Prev {prev}>>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
);
}
fn place(s: &mut Session, bytes: &[u8], x: f64) -> serde_json::Value {
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(bytes), "name": "layers.pdf", "x": x, "y": 0, "width": 100}))
.unwrap()["id"]
.clone()
}
fn pixel(s: &Session, x: u32) -> [u8; 4] {
let mut renderer = designcraft_render::Renderer::new();
renderer.threads = 0;
renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 50)
}
#[test]
fn null_encryption_entry_keeps_layer_editing_rendering_and_export_working() {
for generation in [0, 1] {
let bytes = two_layer_pdf_options(generation, "", "/Encrypt null");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let id = place(&mut s, &bytes, 0.0);
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap();
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false);
assert_eq!(pixel(&s, 25), [0, 0, 255, 255]);
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
s.execute("file.exportPdf", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
}
}
#[test]
fn unrewritable_layer_choices_are_rejected_atomically_without_an_undo_step() {
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, &["Red".into()]).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);
s.execute("object.layerOptions", &json!({"ids": [good], "hidden": ["Blue"]})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().history.redo.is_empty());
let before = s.execute("file.serialize", &json!({})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
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_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);
for id in [&good, &bad] {
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], true);
}
}
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
assert_eq!(pixel(&s, 175), [255, 0, 0, 255]);
// A persisted/relinked choice can be unrewritable. Reset must still work.
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_hidden_layers = vec!["Red".into()];
assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "stale hidden content must not reappear on canvas");
assert!(s.execute("file.exportPdf", &json!({})).is_err());
s.execute("object.layerOptions", &json!({"ids": [good, bad], "hidden": []})).unwrap();
assert_eq!(s.execute("object.pdfLayers", &json!({"id": bad})).unwrap()[1]["visible"], true);
s.execute("file.exportPdf", &json!({})).unwrap();
}
#[test]
fn unreadable_non_null_prev_entries_refuse_layer_rewrites() {
for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] {
let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}"));
assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}");
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}");
}
for trailer in ["", "/Prev null"] {
let bytes = two_layer_pdf_options(0, "", trailer);
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}");
}
let mut revised = two_layer_pdf();
let prev =
std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::<usize>().unwrap();
add_pdf_revision(&mut revised);
let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>"));
assert!(real_prev.contains(&format!("/Prev {prev}.0>>")));
for bytes in [revised, real_prev.into_bytes()] {
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work");
}
}
#[test]
fn fake_null_trailer_cannot_bypass_hybrid_rejection() {
for real_guard in ["", "/XRefStm 0"] {
let bytes = two_layer_pdf_options(0, "", real_guard);
let text = std::str::from_utf8(&bytes).unwrap();
for fake in ["/Encrypt null", ""] {
let comment = format!("% trailer <</Size 9/Root 1 0 R{fake}>>\n");
let inside = text.replacen("xref\n", &format!("xref\n{comment}"), 1);
assert!(inside.contains(&format!("xref\n{comment}")));
// Repair-only comments inside a table are always refused, guard or not.
assert_eq!(designcraft_render::pdf_layers(inside.as_bytes()).len(), 2);
assert!(designcraft_render::pdf_hide_layers(inside.as_bytes(), &["Red".into()]).is_none());
// Before xref or after the dictionary, the real trailer decides.
let leading = text.replacen("xref\n", &format!("{comment}xref\n"), 1);
let trailing = text.replacen("startxref\n", &format!("{comment}startxref\n"), 1);
for source in [leading, trailing] {
assert_eq!(designcraft_render::pdf_layers(source.as_bytes()).len(), 2);
assert_eq!(designcraft_render::pdf_hide_layers(source.as_bytes(), &["Red".into()]).is_some(), real_guard.is_empty());
}
}
}
}
#[test]
fn content_all_still_measures_layerless_and_stub_properties() {
use designcraft_doc::PdfCrop;
for props in ["", "/OCProperties null", "/OCProperties<<>>", "/OCProperties<</OCGs[]/D<<>>>>"] {
let content = "0 0 1 rg 0 0 50 100 re f";
let objs = [
format!("<</Type/Catalog/Pages 2 0 R{props}>>"),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n");
for at in offsets {
bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<</Size 5/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
assert!(designcraft_render::pdf_layers(&bytes).is_empty());
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (50.0, 100.0), "{props}");
}
}
#[test]
fn content_all_keeps_the_whole_page_when_layer_rewriting_is_unsupported() {
use designcraft_doc::PdfCrop;
let mut bytes = two_layer_pdf_options(0, "/BaseState/OFF/ON[5 0 R]", "");
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (100.0, 100.0));
for _ in 0..32 {
add_pdf_revision(&mut bytes);
}
assert!(designcraft_render::pdf_hide_layers(&bytes, &[]).is_none());
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap(), ([0.0, 0.0, 1.0, 1.0], (100.0, 100.0)));
}
#[test]
fn placed_pdf_layers_hide() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let pdf = two_layer_pdf();
let r = s
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "art.pdf", "x": 100, "y": 100, "width": 100}))
.unwrap();
let id = r["id"].clone();
let l = s.execute("object.pdfLayers", &json!({"id": id})).unwrap();
assert_eq!(l.as_array().unwrap().len(), 2);
assert_eq!(l[0]["name"], "Blue");
let px = |s: &Session, x: u32| {
let d = s.doc().unwrap().doc.clone();
let mut rr = designcraft_render::Renderer::new();
rr.threads = 0;
rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 150)
};
assert!(px(&s, 125)[2] > 200 && px(&s, 175)[0] > 200, "both layers show");
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap();
let right = px(&s, 175);
assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "the red layer is hidden: {right:?}");
assert!(px(&s, 125)[2] > 200, "blue still shows");
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false);
// PDF export carries the choice (the graphic goes out as an image).
let r = s.execute("file.exportPdf", &json!({})).unwrap();
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("layer")), "{r}");
}
}