From 62a26d9e352e2ea8f58ce239dfc408131a5b2d8c Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 17:47:18 +0100 Subject: [PATCH] PDF: preserve catalogs and full references in layer variants --- ROADMAP.md | 4 + crates/engine/src/cmd/pdflayers.rs | 61 ++++++++- crates/render/src/pdf_layers.rs | 200 ++++++++++++++++++++++------- docs/gaps.md | 3 + 4 files changed, 221 insertions(+), 47 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..d853936f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,6 +11,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-23** · PDF layer visibility variants retain catalog/configuration entries and full + object references; bounded incremental updates replace the existing catalog. Synthetic + regression tests cover metadata, output-intent references and nonzero generations. No readiness percentage change. + ## Status (2026-10-01) **Working today** diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index c8badf533..d03aa4e52 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -63,31 +63,84 @@ pub(crate) mod tests { /// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half). pub(crate) fn two_layer_pdf() -> Vec { + two_layer_pdf_generation(0) + } + + fn two_layer_pdf_generation(generation: i32) -> Vec { 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 = [ - "<>>>>>".to_string(), + "<>>>>>".to_string(), "<>".to_string(), "<>>>/Contents 4 0 R>>".to_string(), format!("<>\nstream\n{content}\nendstream", content.len()), "<>".to_string(), "<>".to_string(), + "<>\nstream\n\nendstream".to_string(), + "<>".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!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + 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} 00000 n\r\n")); + t.push_str(&format!("{o:010} {generation:05} n\r\n")); } - t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); + t.push_str(&format!("trailer\n<>\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<>\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 null", "/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 placed_pdf_layers_hide() { let mut s = Session::new(); diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs index bc6d864a7..ccf1198d4 100644 --- a/crates/render/src/pdf_layers.rs +++ b/crates/render/src/pdf_layers.rs @@ -6,20 +6,17 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use craft_pdf::syntax::Pdf; -use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier}; +use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, ObjRef, Object, ObjectIdentifier}; -/// (object number, name, on by default) of each layer. -fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { +/// (object reference, name, on by default) of each layer. +fn ocgs(pdf: &Pdf) -> Vec<(ObjRef, String, bool)> { let xref = pdf.xref(); let Some(cat) = xref.get::>(xref.root_id()) else { return vec![] }; let Some(ocp) = cat.get::>(b"OCProperties") else { return vec![] }; let Some(list) = ocp.get::>(b"OCGs") else { return vec![] }; let cfg = ocp.get::>(b"D"); - let ids_in = |key: &[u8]| -> Vec { - cfg.as_ref() - .and_then(|c| c.get::>(key)) - .map(|a| a.raw_iter().filter_map(|o| o.as_obj_ref()).map(|r| r.obj_number).collect()) - .unwrap_or_default() + let ids_in = |key: &[u8]| -> Vec { + cfg.as_ref().and_then(|c| c.get::>(key)).map(|a| a.raw_iter().filter_map(|o| o.as_obj_ref()).collect()).unwrap_or_default() }; let base_off = cfg.as_ref().and_then(|c| c.get::>(b"BaseState")).is_some_and(|n| n.as_ref() == b"OFF"); let (on, off) = (ids_in(b"ON"), ids_in(b"OFF")); @@ -31,8 +28,8 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { .get::>(b"Name") .map(|s| String::from_utf8_lossy(s.as_bytes()).to_string()) .unwrap_or_default(); - let visible = if base_off { on.contains(&r.obj_number) } else { !off.contains(&r.obj_number) }; - Some((r.obj_number, name, visible)) + let visible = if base_off { on.contains(&r) } else { !off.contains(&r) }; + Some((r, name, visible)) }) .collect() } @@ -42,62 +39,179 @@ pub fn pdf_layers(bytes: &[u8]) -> Vec<(String, bool)> { craft_pdf::open_slice(bytes, None).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default() } -/// `bytes` with exactly the layers named in `hidden` off (the others on). `None` when the file -/// has no such layers or can't be read. +// Copy untouched dictionary entries as raw PDF tokens. In particular, do not reinterpret +// strings, numbers or unknown extension keys. The parser bounds container nesting. +fn patch_dict(dict: &Dict<'_>, updates: &[(&[u8], &[u8])]) -> Option> { + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + let data = if dict.is_empty() { b"<<>>".as_slice() } else { dict.data() }; + if data.len() > 4 * 1024 * 1024 { + return None; + } + let mut reader = Reader::new(data); + reader.forward_tag(b"<<")?; + let mut out = b"<<".to_vec(); + let mut ended = false; + for _ in 0..100_000 { + reader.skip_white_spaces_and_comments(); + if reader.forward_tag(b">>").is_some() { + ended = true; + break; + } + let start = reader.offset(); + let name = reader.read_without_context::>()?; + reader.skip_white_spaces_and_comments(); + reader.skip::>>(false)?; + if !updates.iter().any(|(key, _)| *key == name.as_ref()) { + out.push(b' '); + out.extend_from_slice(data.get(start..reader.offset())?); + } + } + if !ended { + return None; + } + for (key, value) in updates { + if !value.is_empty() { + out.extend_from_slice(b" /"); + out.extend_from_slice(key); + out.push(b' '); + out.extend_from_slice(value); + } + } + out.extend_from_slice(b" >>"); + Some(out) +} + +fn trailer_at(bytes: &[u8], offset: usize) -> Option> { + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + let data = bytes.get(offset..)?; + let mut reader = Reader::new(data); + if data.starts_with(b"xref") { + let pos = data.get(..data.len().min(4 * 1024 * 1024))?.windows(7).position(|w| w == b"trailer")?; + reader.skip_bytes(pos.checked_add(7)?)?; + } else { + // An xref stream's dictionary also holds the trailer entries. + reader.read_without_context::()?; + } + reader.skip_white_spaces_and_comments(); + reader.read_without_context::>() +} + +/// `bytes` with exactly the layers named in `hidden` off (the others on). +/// Returns `None` for unreadable, encrypted, hybrid-reference or over-budget updates. +/// Untouched catalog/configuration entries and full object references are preserved. pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { + crate::check_pdf_input_size(bytes.len()).ok()?; let pdf = craft_pdf::open_slice(bytes, None).ok()?; let layers = ocgs(&pdf); if layers.is_empty() { return None; } - let xref = pdf.xref(); - let cat = xref.get::>(xref.root_id())?; - let pages = cat.get_ref(b"Pages")?; - let all: Vec = layers.iter().map(|(n, _, _)| format!("{n} 0 R")).collect(); - let off: Vec = layers.iter().filter(|(_, name, _)| hidden.contains(name)).map(|(n, _, _)| format!("{n} 0 R")).collect(); - // A new catalog under a fresh object number, with the original's page tree. - let root = 9_999_000; - let mut out = bytes.to_vec(); - if !out.ends_with(b"\n") { - out.push(b'\n'); + let tail = String::from_utf8_lossy(bytes.get(bytes.len().saturating_sub(1024)..)?); + let prev = tail.rsplit_once("startxref")?.1.split_whitespace().next()?.parse::().ok()?; + let trailer = trailer_at(bytes, prev)?; + // Do not copy decrypted dictionaries into an encrypted file, or ignore a hybrid + // xref table. Follow bounded Prev links because encryption can be inherited. + let mut offset = Some(prev); + for _ in 0..32 { + let Some(at) = offset else { break }; + let t = trailer_at(bytes, at)?; + if t.contains_key(b"Encrypt") || t.contains_key(b"XRefStm") { + return None; + } + offset = t.get::(b"Prev"); + if offset.is_some_and(|n| n >= at) { + return None; + } } + if offset.is_some() { + return None; + } + let xref = pdf.xref(); + let root = xref.root_id(); + if root.obj_number <= 0 || !(0..=65535).contains(&root.gen_number) || trailer.get::(b"Size")? <= usize::try_from(root.obj_number).ok()? { + return None; + } + let cat = xref.get::>(root)?; + let ocp = cat.get::>(b"OCProperties")?; + let cfg = ocp.get::>(b"D"); + let mut off = b"[".to_vec(); + for (id, name, _) in &layers { + if hidden.contains(name) { + off.extend_from_slice(format!("{} {} R ", id.obj_number, id.gen_number).as_bytes()); + } + } + off.push(b']'); + let empty = Dict::empty(); + let cfg = patch_dict(cfg.as_ref().unwrap_or(&empty), &[(b"BaseState", b"/ON"), (b"ON", b"[]"), (b"OFF", &off)])?; + let ocp = patch_dict(&ocp, &[(b"D", &cfg)])?; + let catalog = patch_dict(&cat, &[(b"OCProperties", &ocp)])?; + let root_ref = format!("{} {} R", root.obj_number, root.gen_number); + let prev_value = prev.to_string(); + let trailer = patch_dict( + &trailer, + &[ + (b"Root", root_ref.as_bytes()), + (b"Prev", prev_value.as_bytes()), + (b"Type", b""), + (b"Length", b""), + (b"Filter", b""), + (b"DecodeParms", b""), + (b"W", b""), + (b"Index", b""), + ], + )?; + let mut out = bytes.to_vec(); + out.push(b'\n'); let at = out.len(); - out.extend_from_slice( - format!( - "{root} 0 obj\n<>>>>>\nendobj\n", - pages.obj_number, - pages.gen_number, - all.join(" "), - off.join(" ") - ) - .as_bytes(), - ); + // Redefine the existing catalog: no arbitrary fresh object number or Size increase. + out.extend_from_slice(format!("{} {} obj\n", root.obj_number, root.gen_number).as_bytes()); + out.extend_from_slice(&catalog); + out.extend_from_slice(b"\nendobj\n"); let xref_at = out.len(); - let prev = { - let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(1024)..]).to_string(); - tail.rsplit_once("startxref").and_then(|(_, t)| t.split_whitespace().next()).and_then(|n| n.parse::().ok())? - }; - out.extend_from_slice( - format!("xref\n{root} 1\n{at:010} 00000 n\r\ntrailer\n<>\nstartxref\n{xref_at}\n%%EOF\n", root + 1) - .as_bytes(), - ); + out.extend_from_slice(format!("xref\n{} 1\n{at:010} {:05} n\r\ntrailer\n", root.obj_number, root.gen_number).as_bytes()); + out.extend_from_slice(&trailer); + out.extend_from_slice(format!("\nstartxref\n{xref_at}\n%%EOF\n").as_bytes()); Some(out) } /// Shared copies of layer variants (by source address and hidden set). pub(crate) fn layered(data: &Arc>, hidden: &[String]) -> Arc> { type Key = (usize, Vec); - static CACHE: Mutex>>>> = Mutex::new(None); + static CACHE: Mutex>, Arc>)>>> = Mutex::new(None); let key = (Arc::as_ptr(data) as usize, hidden.to_vec()); let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); let map = c.get_or_insert_with(HashMap::new); if let Some(v) = map.get(&key) { - return v.clone(); + return v.1.clone(); } if map.len() > 64 { map.clear(); } let v = pdf_hide_layers(data, hidden).map(Arc::new).unwrap_or_else(|| data.clone()); - map.insert(key, v.clone()); + map.insert(key, (data.clone(), v.clone())); v } + +#[cfg(test)] +mod tests { + use super::*; + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + + #[test] + fn dictionary_patch_preserves_raw_values_and_bounds_updates() { + let data = b"<]/Base#53tate/OFF/Name(a(b)c)>>"; + let dict = Reader::new(data).read_without_context::>().unwrap(); + let out = patch_dict(&dict, &[(b"BaseState", b"/ON")]).unwrap(); + let text = std::str::from_utf8(&out).unwrap(); + assert!(text.contains("/Unknown[(x) 12 1 R ]")); + assert!(text.contains("/Name(a(b)c)")); + assert!(!text.contains("/OFF")); + assert!(text.contains("/BaseState /ON")); + assert_eq!(patch_dict(&Dict::empty(), &[(b"BaseState", b"/ON")]).unwrap(), b"<< /BaseState /ON >>"); + let mut many = b"<< /Big (".to_vec(); + many.resize(4 * 1024 * 1024 + 1, b'x'); + many.extend_from_slice(b") >>"); + let dict = Reader::new(&many).read_without_context::>().unwrap(); + assert!(patch_dict(&dict, &[]).is_none()); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 5738d14c4..8591e39bc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -190,6 +190,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 20–35 h. ### 19. Placed graphics depth (FF) +- **Safety:** PDF visibility variants preserve catalog/configuration entries and nonzero-generation + references, redefining the existing catalog rather than choosing an arbitrary object number. + Encrypted, hybrid-reference and over-budget rewrites are rejected; no print-fidelity claim. - **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer comps beyond visibility; the Clipping Path Options dialog (tracing into a clip that keeps the frame, Threshold/Tolerance/Inset Frame preview, picking an alpha channel or a path saved in a