From e7d492ca1bc8fe791deedc24930c76a63cb8fe8e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 15:20:56 +0000 Subject: [PATCH] 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. --- crates/redact/src/interp.rs | 27 ++++++++++++++++++++++----- crates/redact/src/tests/limits.rs | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) 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()); +}