diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 460085a320..89769b17ef 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -604,18 +604,35 @@ fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], process let originals: HashMap = original.iter().map(|op| (op.span.start, op)).collect(); let unchanged: HashSet = 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| { + 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 { diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs index 8519e08608..158685c5d3 100644 --- a/crates/redact/src/tests/limits.rs +++ b/crates/redact/src/tests/limits.rs @@ -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 <> 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()); +}