mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 15:49:42 +00:00
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
311 lines
13 KiB
Rust
311 lines
13 KiB
Rust
//! Object Layer Options for placed PDFs: the file's optional-content layers, and a variant of the
|
|
//! file with some layers off (an appended catalog whose default configuration turns them off,
|
|
//! which the PDF renderer honours).
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use craft_pdf::syntax::Pdf;
|
|
use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, ObjRef, Object, ObjectIdentifier};
|
|
|
|
/// (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::<Dict<'_>>(xref.root_id()) else { return vec![] };
|
|
let Some(ocp) = cat.get::<Dict<'_>>(b"OCProperties") else { return vec![] };
|
|
let Some(list) = ocp.get::<Array<'_>>(b"OCGs") else { return vec![] };
|
|
let cfg = ocp.get::<Dict<'_>>(b"D");
|
|
let ids_in = |key: &[u8]| -> Vec<ObjRef> {
|
|
cfg.as_ref().and_then(|c| c.get::<Array<'_>>(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::<Name<'_>>(b"BaseState")).is_some_and(|n| n.as_ref() == b"OFF");
|
|
let (on, off) = (ids_in(b"ON"), ids_in(b"OFF"));
|
|
list.raw_iter()
|
|
.filter_map(|o| o.as_obj_ref())
|
|
.filter_map(|r| {
|
|
let d = xref.get::<Dict<'_>>(ObjectIdentifier::from(r))?;
|
|
let name = d
|
|
.get::<craft_pdf::syntax::object::String<'_>>(b"Name")
|
|
.map(|s| String::from_utf8_lossy(s.as_bytes()).to_string())
|
|
.unwrap_or_default();
|
|
let visible = if base_off { on.contains(&r) } else { !off.contains(&r) };
|
|
Some((r, name, visible))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Whether interpreting this file can depend on optional-content state. Empty/stub
|
|
/// properties are layerless, but malformed group references with state overrides
|
|
/// still require a conservative all-content crop when rewriting is unavailable.
|
|
pub(crate) fn has_optional_content(pdf: &Pdf) -> bool {
|
|
let Some(cat) = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()) else { return false };
|
|
let Some(ocp) = cat.get::<Dict<'_>>(b"OCProperties") else { return false };
|
|
let nonempty = |dict: &Dict<'_>, key: &[u8]| dict.get::<Array<'_>>(key).is_some_and(|a| a.raw_iter().next().is_some());
|
|
nonempty(&ocp, b"OCGs") || ocp.get::<Dict<'_>>(b"D").is_some_and(|cfg| nonempty(&cfg, b"ON") || nonempty(&cfg, b"OFF"))
|
|
}
|
|
|
|
/// The layers of a placed PDF: (name, visible by default).
|
|
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()
|
|
}
|
|
|
|
// 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<Vec<u8>> {
|
|
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::<Name<'_>>()?;
|
|
reader.skip_white_spaces_and_comments();
|
|
reader.skip::<MaybeRef<Object<'_>>>(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<Dict<'_>> {
|
|
use craft_pdf::syntax::reader::{Reader, ReaderExt};
|
|
const MAX_TABLE_BYTES: usize = 4 * 1024 * 1024;
|
|
let data = bytes.get(offset..)?;
|
|
// Bound even whitespace/comment scanning and context-free dictionary parsing.
|
|
let mut reader = Reader::new(data.get(..data.len().min(MAX_TABLE_BYTES))?);
|
|
reader.skip_white_spaces_and_comments();
|
|
if reader.forward_tag(b"xref").is_some() {
|
|
// Walk table syntax, never a raw substring: a repairable file may contain
|
|
// a fake "trailer" in a comment. Such a comment must not hide real guards.
|
|
let mut ended = false;
|
|
for _ in 0..100_000 {
|
|
if reader.offset() > MAX_TABLE_BYTES {
|
|
return None;
|
|
}
|
|
reader.skip_white_spaces();
|
|
if reader.forward_tag(b"trailer").is_some() {
|
|
ended = true;
|
|
break;
|
|
}
|
|
reader.read_without_context::<u32>()?; // first object number
|
|
reader.skip_white_spaces();
|
|
let count = reader.read_without_context::<usize>()?;
|
|
if count > MAX_TABLE_BYTES / 18 {
|
|
return None;
|
|
}
|
|
for _ in 0..count {
|
|
reader.skip_white_spaces();
|
|
reader.read_without_context::<u32>()?; // offset or next free object
|
|
reader.skip_white_spaces();
|
|
if reader.read_without_context::<u32>()? > 65535 {
|
|
return None;
|
|
}
|
|
reader.skip_white_spaces();
|
|
reader.forward_tag(b"n").or_else(|| reader.forward_tag(b"f"))?;
|
|
if reader.offset() > MAX_TABLE_BYTES {
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
if !ended {
|
|
return None;
|
|
}
|
|
reader.skip_white_spaces();
|
|
} else {
|
|
// An xref stream's dictionary also holds the trailer entries.
|
|
reader.read_without_context::<ObjectIdentifier>()?;
|
|
reader.skip_white_spaces_and_comments();
|
|
}
|
|
reader.read_without_context::<Dict<'_>>()
|
|
}
|
|
|
|
// Null dictionary entries are absent in PDF. Keep references and malformed values
|
|
// fail-closed: trailer_at's dictionaries have no context for resolving references.
|
|
fn has_non_null_entry(dict: &Dict<'_>, key: &[u8]) -> bool {
|
|
dict.contains_key(key) && !matches!(dict.get_raw::<Object<'_>>(key), Some(MaybeRef::NotRef(Object::Null(_))))
|
|
}
|
|
|
|
/// `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<Vec<u8>> {
|
|
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 tail = String::from_utf8_lossy(bytes.get(bytes.len().saturating_sub(1024)..)?);
|
|
let prev = tail.rsplit_once("startxref")?.1.split_whitespace().next()?.parse::<usize>().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);
|
|
let mut visited = std::collections::HashSet::new();
|
|
for _ in 0..32 {
|
|
let Some(at) = offset else { break };
|
|
if !visited.insert(at) {
|
|
return None;
|
|
}
|
|
let t = trailer_at(bytes, at)?;
|
|
if has_non_null_entry(&t, b"Encrypt") || has_non_null_entry(&t, b"XRefStm") {
|
|
return None;
|
|
}
|
|
offset = t.get::<usize>(b"Prev");
|
|
if offset.is_none() && has_non_null_entry(&t, b"Prev") {
|
|
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::<usize>(b"Size")? <= usize::try_from(root.obj_number).ok()? {
|
|
return None;
|
|
}
|
|
let cat = xref.get::<Dict<'_>>(root)?;
|
|
let ocp = cat.get::<Dict<'_>>(b"OCProperties")?;
|
|
let cfg = ocp.get::<Dict<'_>>(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();
|
|
// 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();
|
|
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<Vec<u8>>, hidden: &[String]) -> Option<Arc<Vec<u8>>> {
|
|
type Key = (usize, Vec<String>);
|
|
static CACHE: Mutex<Option<HashMap<Key, (Arc<Vec<u8>>, Option<Arc<Vec<u8>>>)>>> = Mutex::new(None);
|
|
let key = (Arc::as_ptr(data) as usize, hidden.to_vec());
|
|
let mut c = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let map = c.get_or_insert_with(HashMap::new);
|
|
if let Some(v) = map.get(&key) {
|
|
return v.1.clone();
|
|
}
|
|
if map.len() > 64 {
|
|
map.clear();
|
|
}
|
|
let v = pdf_hide_layers(data, hidden).map(Arc::new);
|
|
map.insert(key, (data.clone(), v.clone()));
|
|
v
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use craft_pdf::syntax::reader::{Reader, ReaderExt};
|
|
|
|
#[test]
|
|
fn trailer_lookup_does_not_take_guard_values_from_xref_comments() {
|
|
for guard in ["/Encrypt 12 0 R", "/XRefStm 123"] {
|
|
let bytes = format!("xref\n% trailer <</Size 9/Encrypt null>>\n0 1\n0000000000 65535 f\r\ntrailer\n<</Size 9{guard}>>");
|
|
assert!(trailer_at(bytes.as_bytes(), 0).is_none(), "repair-only table must fail closed: {guard}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_lookup_accepts_whitespace_and_bounded_table_layouts() {
|
|
for eol in ["\n", "\r\n"] {
|
|
let bytes = format!(" \r\nxref{eol}0 1{eol}0000000000 65535 f{eol}7 1{eol}0000000123 00001 n{eol}trailer{eol}<</Size 9/Root 1 0 R>>");
|
|
assert_eq!(trailer_at(bytes.as_bytes(), 0).unwrap().get::<usize>(b"Size"), Some(9));
|
|
}
|
|
assert!(trailer_at(b"xref\n0 1\n0000000000 65535 f\ntrailer% comment\n<< /Size 1 >>", 0).is_none());
|
|
let mut whitespace = vec![b' '; 4 * 1024 * 1024];
|
|
whitespace.extend_from_slice(b"xref\n0 0\ntrailer<<>>");
|
|
assert!(trailer_at(&whitespace, 0).is_none());
|
|
for bytes in [b"xref\n0 999999999\n".as_slice(), b"xref\n0 1\n0000000000 99999 f\ntrailer<<>>", b"xref\n0 1\n0000000000 65535 x\ntrailer<<>>"]
|
|
{
|
|
assert!(trailer_at(bytes, 0).is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn null_trailer_entries_are_absent_but_references_stay_unsupported() {
|
|
for key in ["Encrypt", "XRefStm"] {
|
|
assert!(!has_non_null_entry(&Dict::empty(), key.as_bytes()));
|
|
for (value, allowed) in [("null", true), ("12 0 R", false), ("<< /Filter /Standard >>", false), ("false", false)] {
|
|
let input = format!("<</{key} {value}>>");
|
|
let dict = Reader::new(input.as_bytes()).read_without_context::<Dict<'_>>().unwrap();
|
|
assert_eq!(!has_non_null_entry(&dict, key.as_bytes()), allowed, "{key}: {value}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dictionary_patch_preserves_raw_values_and_bounds_updates() {
|
|
let data = b"<</Unknown[(x) 12 1 R <ff>]/Base#53tate/OFF/Name(a(b)c)>>";
|
|
let dict = Reader::new(data).read_without_context::<Dict<'_>>().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 <ff>]"));
|
|
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::<Dict<'_>>().unwrap();
|
|
assert!(patch_dict(&dict, &[]).is_none());
|
|
}
|
|
}
|