Merge #6459: Removed layers leave every optional-content configuration

# Conflicts:
#	crates/redact/src/tests.rs
This commit is contained in:
bflatastic
2026-11-09 08:02:50 +07:00
2 changed files with 163 additions and 6 deletions
+107 -6
View File
@@ -428,6 +428,101 @@ fn hidden_layers(doc: &Document) -> Vec<ObjRef> {
}
}
/// The objects a layer cleanup has reached by reference, and whether the lists naming each keep it.
type Cleaned = std::collections::HashMap<ObjRef, bool>;
/// `o` cleaned by `clean`: in its list when written there, and when written by reference, once and
/// where it is, however many lists name it (they keep the reference, or drop it when `clean` gives
/// `None`). A list that names one object many times costs one step per object, not one per path,
/// and a cycle stops where it began.
fn once(
doc: &mut Document,
o: &Object,
cleaned: &mut Cleaned,
clean: impl FnOnce(&mut Document, &Object, &mut Cleaned) -> Result<Option<Object>, RedactError>,
) -> Result<Option<Object>, RedactError> {
let Object::Ref(r) = o else { return clean(doc, o, cleaned) };
if let Some(&kept) = cleaned.get(r) {
return Ok(kept.then(|| o.clone()));
}
cleaned.insert(*r, true);
let held: &Object = &doc.resolve(o);
let Some(new) = clean(doc, held, cleaned)? else {
cleaned.insert(*r, false);
return Ok(None);
};
if new != *held {
doc.set_existing(*r, new)?;
}
Ok(Some(o.clone()))
}
/// `o` without the entries `keep` refuses, through nested arrays (an `/Order` holds sub-lists, and
/// `/RBGroups` holds groups), each array written by reference cleaned [`once`]. `None` when `keep`
/// refuses `o` itself, or when `o` is a sub-list (`depth` above 0) that named a layer and names
/// none now: it goes, with the heading that labelled it.
fn filtered(
doc: &mut Document,
o: &Object,
keep: &impl Fn(&Object) -> bool,
depth: usize,
cleaned: &mut Cleaned,
) -> Result<Option<Object>, RedactError> {
if depth > MAX_DIRECT_DEPTH {
return Err(RedactError::TooDeep);
}
if !keep(o) {
return Ok(None);
}
once(doc, o, cleaned, |doc, o, cleaned| {
let Object::Array(a) = o else { return Ok(Some(o.clone())) };
let mut out = Vec::new();
for x in a {
out.extend(filtered(doc, x, keep, depth + 1, cleaned)?);
}
// Anything but a text string (a heading) is a layer or a sub-list.
let names = |l: &[Object]| l.iter().any(|x| !matches!(x, Object::String(_)));
let emptied = depth > 0 && names(a.as_slice()) && !names(out.as_slice());
Ok((!emptied).then_some(Object::Array(out)))
})
}
/// An optional-content configuration without the layers `keep` refuses, in every list that names
/// them: on, off, order, locked, radio-button groups and usage rules (a rule, group or sub-list left
/// with no layer goes). A removed layer still named in any of them would stay in the file, its name
/// included.
fn config_without(doc: &mut Document, mut d: Dict, keep: &impl Fn(&Object) -> bool, cleaned: &mut Cleaned) -> Result<Dict, RedactError> {
for k in [&b"ON"[..], b"OFF", b"Order", b"Locked", b"RBGroups"] {
if let Some(v) = d.get(k).cloned() {
let v = filtered(doc, &v, keep, 0, cleaned)?.unwrap_or_else(|| Object::Array(Vec::new()));
d.set(k.to_vec(), v);
}
}
let Some(rules) = d.get(b"AS").cloned() else { return Ok(d) };
let rules = once(doc, &rules, cleaned, |doc, rules, cleaned| {
let Object::Array(rules) = rules else { return Ok(Some(rules.clone())) };
let mut kept = Vec::new();
for rule in rules {
kept.extend(once(doc, rule, cleaned, |doc, rule, cleaned| {
let Some(mut rule) = rule.as_dict().cloned() else { return Ok(None) };
if let Some(layers) = rule.get(b"OCGs").cloned() {
let Some(layers) = filtered(doc, &layers, keep, 0, cleaned)? else { return Ok(None) };
if doc.resolve(&layers).as_array().is_some_and(Vec::is_empty) {
return Ok(None);
}
rule.set(b"OCGs".to_vec(), layers);
}
Ok(Some(Object::Dict(rule)))
})?);
}
Ok(Some(Object::Array(kept)))
})?;
if let Some(rules) = rules {
d.set(b"AS".to_vec(), rules);
}
Ok(d)
}
/// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them).
fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<(usize, std::collections::HashSet<ObjRef>), RedactError> {
let off = if layers { hidden_layers(doc) } else { Vec::new() };
@@ -755,13 +850,19 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
if let Some(Object::Array(a)) = oc.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) {
oc.set(b"OCGs".to_vec(), Object::Array(a.into_iter().filter(keep).collect()));
}
if let Some(mut d) = sub(doc, &oc, b"D") {
for k in [&b"ON"[..], b"OFF", b"Order", b"Locked"] {
if let Some(Object::Array(a)) = d.get(k).map(|g| (*doc.resolve(g)).clone()) {
d.set(k.to_vec(), Object::Array(a.into_iter().filter(keep).collect()));
}
let mut cleaned = Cleaned::new();
if let Some(d) = sub(doc, &oc, b"D") {
oc.set(b"D".to_vec(), Object::Dict(config_without(doc, d, &keep, &mut cleaned)?));
}
if let Some(Object::Array(configs)) = oc.get(b"Configs").map(|c| (*doc.resolve(c)).clone()) {
let mut kept = Vec::with_capacity(configs.len());
for config in configs {
kept.extend(once(doc, &config, &mut cleaned, |doc, config, cleaned| match config.as_dict() {
Some(c) => Ok(Some(Object::Dict(config_without(doc, c.clone(), &keep, cleaned)?))),
None => Ok(None),
})?);
}
oc.set(b"D".to_vec(), Object::Dict(d));
oc.set(b"Configs".to_vec(), Object::Array(kept));
}
cat.set(b"OCProperties".to_vec(), Object::Dict(oc));
}
+56
View File
@@ -4754,3 +4754,59 @@ fn images_drawn_by_type3_glyphs_stay() {
assert!(has(b"BYGLYPHIMAGE"), "the glyph's image is drawn and must stay");
assert!(has(b"BYFONTFORMIMAGE"), "the image a form in the font's resources draws must stay");
}
/// Layer lists that name one array many times, through arrays that do the same, are cleaned once
/// where each array is, not copied once per path: the file stays as small as it was.
#[test]
fn a_shared_layer_list_is_cleaned_once() {
use crate::sanitize::{Hidden, remove_hidden};
let mut objs = vec![
b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R 7 0 R] /D << /OFF [6 0 R] /Order [9 0 R 9 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
stream("", b"BT /F1 10 Tf 10 100 Td (Kept) Tj ET"),
FONT.replace("95 0 R", "8 0 R").into_bytes(),
b"<< /Type /OCG /Name (SECRETLAYERNAME) >>".to_vec(),
b"<< /Type /OCG /Name (Shown layer) >>".to_vec(),
widths(),
];
// Objects 9 to 22 each name the next one twice; 23 names both layers.
objs.extend((10..24).map(|n| format!("[{n} 0 R {n} 0 R]").into_bytes()));
objs.push(b"[6 0 R 7 0 R]".to_vec());
let mut doc = pdf(objs);
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!(has(b"(Shown layer)") && !has(b"SECRETLAYERNAME"), "the removed layer survived the save");
assert!(bytes.len() < 32 * 1024, "the shared lists were copied once per path: {} bytes", bytes.len());
}
/// A removed off layer leaves every list that names it, or its dictionary, name included, stays in
/// the file: a sub-list of the default `/Order` (its heading too, once it names no layer), a
/// radio-button group (a group left empty goes), a usage rule (one that names it directly too), and
/// the alternate configurations in `/Configs`.
#[test]
fn a_removed_layer_leaves_every_configuration() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R 7 0 R] /D << /OFF [6 0 R] /Order [7 0 R [(Group) 6 0 R]] /RBGroups [[6 0 R 7 0 R] [6 0 R]] /AS [<< /Event /Print /OCGs [6 0 R] /Category [/Print] >> << /Event /View /OCGs 6 0 R /Category [/View] >>] >> /Configs [8 0 R] >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /Properties << /L1 6 0 R >> >> >>".to_vec(),
stream("", b"/OC /L1 BDC BT /F1 10 Tf 10 50 Td (Gone) Tj ET EMC BT /F1 10 Tf 10 100 Td (Kept) Tj ET"),
FONT.replace("95 0 R", "9 0 R").into_bytes(),
b"<< /Type /OCG /Name (SECRETLAYERNAME) >>".to_vec(),
b"<< /Type /OCG /Name (Shown layer) >>".to_vec(),
b"<< /Name (Other view) /OFF [7 0 R] /ON [6 0 R] /Order [6 0 R 7 0 R] >>".to_vec(),
widths(),
]);
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!(has(b"(Shown layer)") && has(b"(Other view)"), "the layer that stays and the configuration stay");
assert!(!has(b"SECRETLAYERNAME"), "the removed layer survived the save");
assert!(!has(b"(Group)"), "the heading of a sub-list left with no layer stayed");
let catalog = doc.get(doc.root().unwrap());
let config = catalog.as_dict().and_then(|c| c.get(b"OCProperties")).and_then(Object::as_dict).and_then(|p| p.get(b"D")).and_then(Object::as_dict);
let group = Object::Array(vec![Object::Ref(ObjRef::new(7, 0))]);
assert_eq!(config.and_then(|d| d.get(b"RBGroups")), Some(&Object::Array(vec![group])), "a radio-button group left empty stayed");
}