fix(redact): mark changed marked-content spans in linear work

clean_replacements extended the affected set with the whole open-span
stack for every changed operator, so nesting depth times changed
operators was the work, and both come from the file.

A change now marks only the innermost open span, and a span passes the
mark to its parent when it closes (or at the end of the stream), so each
operator and each span costs constant work. The spans found affected
are the same.
This commit is contained in:
Dylan M. Taylor
2026-11-03 15:20:56 +00:00
parent 14ae839a28
commit e7d492ca1b
2 changed files with 44 additions and 5 deletions
+22 -5
View File
@@ -604,18 +604,35 @@ fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], process
let originals: HashMap<usize, &Op> = original.iter().map(|op| (op.span.start, op)).collect();
let unchanged: HashSet<usize> =
processed.iter().flat_map(|(ops, _)| ops).filter(|op| originals.get(&op.span.start) == Some(op)).map(|op| op.span.start).collect();
let mut stack = Vec::new();
// Open spans, each with whether something inside it changed. A change marks only the
// innermost span; a span passes that on to its parent when it closes, so every operator costs
// the same however deeply spans nest.
let mut stack: Vec<(usize, bool)> = Vec::new();
let mut affected = HashSet::new();
let close = |stack: &mut Vec<(usize, bool)>, affected: &mut HashSet<usize>| {
if let Some((start, true)) = stack.pop() {
affected.insert(start);
if let Some(parent) = stack.last_mut() {
parent.1 = true;
}
}
};
for op in original {
match op.op.as_slice() {
b"BDC" | b"BMC" => stack.push(op.span.start),
b"EMC" => {
stack.pop();
b"BDC" | b"BMC" => stack.push((op.span.start, false)),
b"EMC" => close(&mut stack, &mut affected),
_ if !unchanged.contains(&op.span.start) => {
if let Some(top) = stack.last_mut() {
top.1 = true;
}
}
_ if !unchanged.contains(&op.span.start) => affected.extend(stack.iter().copied()),
_ => {}
}
}
// Spans the stream leaves open run to its end.
while !stack.is_empty() {
close(&mut stack, &mut affected);
}
let properties = res_dict(doc, resources, b"Properties");
for (ops, changed) in processed {
for op in ops {
+22
View File
@@ -53,3 +53,25 @@ fn a_change_inside_nested_marked_content_touches_every_enclosing_mcid() {
assert_eq!(changed, HashSet::from([0, 1]));
assert_eq!(empty, HashSet::from([1]));
}
/// Every changed operator marked every open marked-content span again, so nesting times changed
/// operators was the work. A change now marks the innermost span, which passes it up as it
/// closes: 5 000 nested spans (below the overlay's nesting cap) around 50 000 redacted strings, a
/// quarter of a billion steps before, still lose every description.
#[test]
fn deeply_nested_spans_around_many_changes_are_cleaned_in_linear_work() {
const DEPTH: usize = 5_000;
const SHOWN: usize = 50_000;
let mut src = b"BT /F1 10 Tf 10 100 Td ".to_vec();
src.extend(b"/Span <</ActualText (LEAK)>> BDC ".repeat(DEPTH));
src.extend(b"(S) Tj 0 0 Td ".repeat(SHOWN));
src.extend(b"EMC ".repeat(DEPTH));
src.extend(b"ET");
let mut doc = one_page(&src, "", vec![]);
mark(&mut doc, 0, &[[5.0, 95.0, 20.0, 110.0]], "");
let started = std::time::Instant::now();
let r = apply(&mut doc, None).unwrap();
assert_eq!((r.glyphs, r.replacement_text), (SHOWN, DEPTH), "{:?}", (r.glyphs, r.replacement_text));
assert!(!content(&doc, 0).contains("LEAK"));
assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed());
}