use anyhow::Result;
use lopdf::{Dictionary, Document, Object};
use crate::config::{Config, Strip};
use crate::pipeline::{Context, Stage};
pub struct StripDocument;
impl Stage for StripDocument {
fn name(&self) -> &'static str {
"strip"
}
fn enabled(&self, config: &Config) -> bool {
config.strip != Strip::NONE
}
fn run(&self, doc: &mut Document, ctx: &mut Context<'_>) -> Result<()> {
let removed = strip(doc, ctx.config.strip);
if removed > 0 {
ctx.report.note(format!(
"strip: removed {removed} entries ({:?})",
ctx.config.strip
));
}
Ok(())
}
}
const CATALOG_KEYS: [(Strip, &[u8]); 6] = [
(Strip::THREADS, b"Threads"),
(Strip::STRUCT_TREE, b"StructTreeRoot"),
(Strip::STRUCT_TREE, b"MarkInfo"),
(Strip::SPIDER, b"SpiderInfo"),
(Strip::OUTPUT_INTENTS, b"OutputIntents"),
(Strip::METADATA, b"Metadata"),
];
const PAGE_KEYS: [(Strip, &[u8]); 2] = [(Strip::THREADS, b"B"), (Strip::THUMBNAILS, b"Thumb")];
const IMAGE_KEYS: [(Strip, &[u8]); 1] = [(Strip::ALTERNATES, b"Alternates")];
const OBJECT_KEYS: [(Strip, &[u8]); 4] = [
(Strip::METADATA, b"Metadata"),
(Strip::PIECE_INFO, b"PieceInfo"),
(Strip::STRUCT_TREE, b"StructParents"),
(Strip::STRUCT_TREE, b"StructParent"),
];
pub fn strip(doc: &mut Document, flags: Strip) -> usize {
let mut removed = 0;
if let Ok(catalog) = doc.catalog_mut() {
removed += remove_keys(catalog, flags, &CATALOG_KEYS);
}
for obj in doc.objects.values_mut() {
match obj {
Object::Dictionary(dict) if dict.has(b"Type") => {
removed += remove_keys(dict, flags, &OBJECT_KEYS);
if has_type(dict, b"Type", b"Page") {
removed += remove_keys(dict, flags, &PAGE_KEYS);
}
}
Object::Stream(stream) => {
removed += remove_keys(&mut stream.dict, flags, &OBJECT_KEYS);
if has_type(&stream.dict, b"Subtype", b"Image") {
removed += remove_keys(&mut stream.dict, flags, &IMAGE_KEYS);
}
}
_ => {}
}
}
removed
}
fn has_type(dict: &Dictionary, key: &[u8], name: &[u8]) -> bool {
dict.get(key)
.and_then(Object::as_name)
.is_ok_and(|n| n == name)
}
fn remove_keys(dict: &mut Dictionary, flags: Strip, keys: &[(Strip, &[u8])]) -> usize {
keys.iter()
.filter(|(flag, _)| flags.contains(*flag))
.filter(|(_, key)| dict.remove(key).is_some())
.count()
}
#[cfg(test)]
mod tests {
use lopdf::{Stream, dictionary};
use super::*;
fn loaded_doc() -> Document {
let mut doc = Document::with_version("1.5");
let meta = doc.add_object(Stream::new(
dictionary! { "Type" => "Metadata" },
b"<x/>".to_vec(),
));
let thumb = doc.add_object(Stream::new(dictionary! {}, vec![0]));
let image = doc.add_object(Stream::new(
dictionary! { "Type" => "XObject", "Subtype" => "Image", "Alternates" => vec![], "StructParent" => 3 },
vec![0],
));
let fonts = doc.add_object(dictionary! {
"B" => dictionary! { "Type" => "Font", "Subtype" => "Type3", "CharProcs" => dictionary! { "Thumb" => thumb, "Metadata" => thumb } },
"Thumb" => dictionary! {},
});
let pages_id = doc.new_object_id();
let page = doc.add_object(dictionary! {
"Type" => "Page", "Parent" => pages_id, "B" => vec![], "Thumb" => thumb,
"StructParents" => 0, "PieceInfo" => dictionary! {}, "Metadata" => meta,
"Resources" => dictionary! { "XObject" => dictionary! { "Im" => image }, "Font" => fonts },
});
doc.objects.insert(
pages_id,
Object::Dictionary(
dictionary! { "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1 },
),
);
let catalog = doc.add_object(dictionary! {
"Type" => "Catalog", "Pages" => pages_id, "Threads" => vec![], "SpiderInfo" => dictionary! {},
"OutputIntents" => vec![], "StructTreeRoot" => dictionary! {}, "MarkInfo" => dictionary! {},
"Metadata" => meta, "PieceInfo" => dictionary! {},
});
doc.trailer.set("Root", catalog);
doc
}
fn keys(doc: &Document, type_name: &[u8]) -> Vec<String> {
let mut out: Vec<String> = doc
.objects
.values()
.filter_map(|o| match o {
Object::Dictionary(d) => Some(d),
Object::Stream(s) => Some(&s.dict),
_ => None,
})
.filter(|d| {
d.get(b"Type")
.and_then(Object::as_name)
.is_ok_and(|t| t == type_name)
})
.flat_map(|d| {
d.iter()
.map(|(k, _)| String::from_utf8_lossy(k).into_owned())
})
.collect();
out.sort();
out
}
#[test]
fn resource_names_that_look_like_strippable_keys_survive() {
let mut doc = loaded_doc();
let before = doc.objects.len();
strip(
&mut doc,
Strip::THREADS
| Strip::METADATA
| Strip::PIECE_INFO
| Strip::STRUCT_TREE
| Strip::THUMBNAILS
| Strip::ALTERNATES,
);
assert_eq!(doc.objects.len(), before);
let fonts = doc
.objects
.values()
.filter_map(|o| o.as_dict().ok())
.find(|d| d.has(b"B") && !d.has(b"Type"))
.expect("font resources kept");
assert!(fonts.has(b"Thumb"));
let procs = fonts.get(b"B").unwrap().as_dict().unwrap();
let procs = procs.get(b"CharProcs").unwrap().as_dict().unwrap();
assert!(procs.has(b"Thumb") && procs.has(b"Metadata"));
}
#[test]
fn nothing_flagged_removes_nothing() {
let mut doc = loaded_doc();
assert_eq!(strip(&mut doc, Strip::NONE), 0);
}
#[test]
fn each_flag_removes_only_its_keys() {
let mut doc = loaded_doc();
assert_eq!(strip(&mut doc, Strip::THUMBNAILS), 1);
assert!(!keys(&doc, b"Page").contains(&"Thumb".to_string()));
assert!(keys(&doc, b"Page").contains(&"B".to_string()));
assert!(keys(&doc, b"Catalog").contains(&"Threads".to_string()));
}
#[test]
fn standard_flags_strip_the_expected_set() {
let mut doc = loaded_doc();
let flags = Strip::THREADS
| Strip::METADATA
| Strip::PIECE_INFO
| Strip::THUMBNAILS
| Strip::SPIDER
| Strip::ALTERNATES
| Strip::OUTPUT_INTENTS;
strip(&mut doc, flags);
assert_eq!(
keys(&doc, b"Catalog"),
["MarkInfo", "Pages", "StructTreeRoot", "Type"]
);
assert_eq!(
keys(&doc, b"Page"),
["Parent", "Resources", "StructParents", "Type"]
);
assert_eq!(
keys(&doc, b"XObject"),
["Length", "StructParent", "Subtype", "Type"]
);
}
#[test]
fn struct_tree_flag_removes_tree_and_parent_links() {
let mut doc = loaded_doc();
strip(&mut doc, Strip::STRUCT_TREE);
for k in [
"StructTreeRoot",
"MarkInfo",
"StructParents",
"StructParent",
] {
let all = [
keys(&doc, b"Catalog"),
keys(&doc, b"Page"),
keys(&doc, b"XObject"),
]
.concat();
assert!(!all.contains(&k.to_string()), "{k} survived");
}
}
}