diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 71b4684bc..3baec29c2 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -242,32 +242,21 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< /// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state /// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed /// with the caller's font, size and spacing, as the renderer places it. -fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut stack: Vec = Vec::new(); - let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); - let fonts_res = res_dict(doc, resources, b"Font"); - let ext_gstates = res_dict(doc, resources, b"ExtGState"); - let mut xobjects = res_dict(doc, resources, b"XObject"); - // The current path: its operators, its bounding box in user space, and whether it clips. - let mut path: Vec = Vec::new(); - let mut path_box: Option<[f64; 4]> = None; - let mut clip = false; - let mut forced_change = false; - +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, gs: Gs) -> Output { + let mut walk = Walk::new(doc, resources, gs); // The streams are one content stream in pieces, which may be split between any two tokens // (ISO 32000-2 §7.8.2): an operator's operands can end one piece and the operator start the // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); - out.ambiguous = parsed.ambiguous_images > 0; + walk.out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); // Layer removal also changes enclosing replacement spans, so remember the operators before // any cleanup, not just the survivors of the layer pass. let original = parsed.ops.clone(); + let mut forced_change = false; if scope.layers_enabled { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); @@ -277,12 +266,32 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], forced_change = true; } } - let mut pieces: Vec> = streams.iter().map(|_| Vec::new()).collect(); - let mut rewrite: Vec = vec![forced_change; streams.len()]; - for op in parsed.ops { + let (pieces, rewrite) = split_into_pieces(&joined, parsed.ops, streams.len(), forced_change); + let mut processed = Vec::new(); + for (piece, changed) in pieces.into_iter().zip(rewrite) { + let mut done = Piece { ops: Vec::with_capacity(piece.len()), changed }; + for op in piece { + walk.step(doc, scope, resources, op, &mut done); + } + if !walk.path.is_empty() { + done.ops.append(&mut walk.path); + } + processed.push((done.ops, done.changed)); + } + if scope.mode == Mode::Apply { + clean_replacements(doc, resources, &original, &mut processed, scope.report); + } + walk.out.streams = processed.into_iter().zip(comment_edges).map(|((ops, changed), edges)| changed.then(|| piece_bytes(&ops, edges))).collect(); + walk.out +} + +/// Gives each operator to the piece its keyword is in. An operator split across pieces is written +/// whole into its keyword's piece, so every piece it spans is rewritten. +fn split_into_pieces(joined: &Pieces, ops: Vec, count: usize, forced_change: bool) -> (Vec>, Vec) { + let mut pieces: Vec> = (0..count).map(|_| Vec::new()).collect(); + let mut rewrite: Vec = vec![forced_change; count]; + for op in ops { let (first, last) = joined.pieces_of(&op); - // An operator split across pieces is written whole into its keyword's piece, so every - // piece it spans is rewritten. if first < last { for r in rewrite.iter_mut().take(last + 1).skip(first) { *r = true; @@ -292,228 +301,297 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], p.push(op); } } + (pieces, rewrite) +} - let mut processed = Vec::new(); - for (piece, mut changed) in pieces.into_iter().zip(rewrite) { - let mut ops: Vec = Vec::with_capacity(piece.len()); - for op in piece { - let o = op.op.as_slice(); - // Path construction. - if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { - let pts: Vec<(f64, f64)> = match o { - b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), - b"h" | b"W" | b"W*" => Vec::new(), - _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), - }; - for (x, y) in pts { - let (u, v) = gs.ctm.apply(x, y); - path_box = Some(match path_box { - None => [u, v, u, v], - Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], - }); - } - clip |= matches!(o, b"W" | b"W*"); - path.push(op); - continue; - } - if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { - let body = std::mem::take(&mut path); - let bbox = path_box.take(); - let clips = std::mem::replace(&mut clip, false); - let hit = scope.mode == Mode::Apply && o != b"n" && bbox.is_some_and(|b| scope.hits(b)); - if !hit { - ops.extend(body); - ops.push(op); - continue; - } - let b = bbox.unwrap_or_default(); - changed = true; - scope.report.paths_removed += 1; - if scope.covered(b) && clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); - } - // Partial vector redaction must not retain underlying drawing commands - // behind clipping (F-008). Refuse partial clip retention and remove the path. - continue; - } - if !path.is_empty() { - // A path not ended by a painting operator: pass it through. - ops.append(&mut path); - path_box = None; - clip = false; - } - match o { - b"q" => stack.push(gs.clone()), - b"Q" => { - if let Some(g) = stack.pop() { - gs = g; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - gs.ctm = Matrix(m).then(&gs.ctm); - } - } - b"BT" => { - tm = Matrix::IDENTITY; - tlm = Matrix::IDENTITY; - } - b"Tf" => { - gs.size = op.num(1).unwrap_or(gs.size); - if let Some(name) = op.name(0) { - let font_dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); - gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); - gs.font = match scope.fonts.get(name) { - Some(f) => f.clone(), - None => { - let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); - scope.fonts.insert(name.to_vec(), m.clone()); - m - } - }; - } - } - b"gs" => { - if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { - (gs.font, gs.vertical, gs.size) = (font, vertical, size); - } - } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, - b"Td" | b"TD" => { - if let Some([x, y]) = op.nums::<2>() { - if o == b"TD" { - gs.leading = -y; - } - tlm = Matrix::translate(x, y).then(&tlm); - tm = tlm; - } - } - b"Tm" => { - if let Some(m) = op.nums::<6>() { - tlm = Matrix(m); - tm = tlm; - } - } - b"T*" => { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - b"Tj" | b"TJ" | b"'" | b"\"" => { - if o == b"\"" { - gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); - gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); - } - if matches!(o, b"'" | b"\"") { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - let items: Vec = match o { - // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still - // takes the one immediately before it, as the renderer and every other - // reader here do (#3569), so a redaction search matches what is drawn. - b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Vertical-writing text is laid out horizontally by `show`, so its glyphs are - // never matched against a region. Rather than ship a document whose vertical - // text was silently left under a mark, record it so apply can fail closed. - // (Full vertical-text removal is a future enhancement.) - if gs.vertical - && scope.mode == Mode::Apply - && scope.hidden_text.is_none() - && !scope.rects.is_empty() - && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) - { - out.vertical = true; - } - let (new_items, removed) = show(scope, &gs, &mut tm, &items); - if removed == 0 { - ops.push(op); - continue; - } - if scope.mode == Mode::Verify { - out.residue += removed; - ops.push(op); - continue; - } - changed = true; - scope.report.glyphs += removed; - match o { - b"\"" => { - ops.push(Op::new("Tw", vec![num(gs.word_spacing)])); - ops.push(Op::new("Tc", vec![num(gs.char_spacing)])); - ops.push(Op::new("T*", vec![])); - } - b"'" => ops.push(Op::new("T*", vec![])), - _ => {} - } - ops.push(Op::new("TJ", vec![Object::Array(new_items)])); - continue; - } - b"BI" => { - let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); - if scope.hits(b) { - if scope.mode == Mode::Verify { - out.residue += 1; - } else { - scope.report.images_removed += 1; - changed = true; - continue; - } - } - } - b"sh" if scope.mode == Mode::Apply => { - // A shading fills the current clip: clip the regions out. - if let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) { - changed = true; - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.push(op); - ops.push(Op::new("Q", vec![])); - continue; - } - } - b"Do" => { - if let Some(new) = xobject(doc, scope, &op, &gs, resources, &mut xobjects, &mut out) { - changed = true; - if let Some(n) = new { - ops.push(n); - } - continue; - } - } - _ => {} - } - ops.push(op); +/// A rewritten piece's bytes. `(comment_in, comment_out)` say whether the piece before ended +/// inside a comment, and whether this one did. +fn piece_bytes(ops: &[Op], (comment_in, comment_out): (bool, bool)) -> Vec { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes +} + +/// The operators written for one piece of a scope's content, and whether they differ from the source. +struct Piece { + ops: Vec, + changed: bool, +} + +/// The interpreter state carried across the operators of a scope. +struct Walk { + gs: Gs, + stack: Vec, + tm: Matrix, + tlm: Matrix, + fonts_res: Dict, + ext_gstates: Dict, + xobjects: Dict, + fallback: Rc, + /// The current path: its operators, its bounding box in user space, and whether it clips. + path: Vec, + path_box: Option<[f64; 4]>, + clip: bool, + out: Output, +} + +impl Walk { + fn new(doc: &Document, resources: &Dict, gs: Gs) -> Self { + Self { + gs, + stack: Vec::new(), + tm: Matrix::IDENTITY, + tlm: Matrix::IDENTITY, + fonts_res: res_dict(doc, resources, b"Font"), + ext_gstates: res_dict(doc, resources, b"ExtGState"), + xobjects: res_dict(doc, resources, b"XObject"), + fallback: Rc::new(Metrics::fallback()), + path: Vec::new(), + path_box: None, + clip: false, + out: Output::default(), } - if !path.is_empty() { - ops.append(&mut path); + } + + /// Interprets one operator, writing what replaces it to `piece`. + fn step(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) { + let o = op.op.as_slice(); + if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { + self.construct_path(op); + return; } - processed.push((ops, changed)); - } - if scope.mode == Mode::Apply { - clean_replacements(doc, resources, &original, &mut processed, scope.report); - } - for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) { - out.streams.push(changed.then(|| { - // The piece before still ends inside a comment: end it before the first operator. - let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; - bytes.extend(serialize_ops(&ops)); - // The piece ended inside a comment that hid the start of the next one: keep it open. - if comment_out { - bytes.push(b'%'); + if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + self.paint_path(scope, op, piece); + return; + } + if !self.path.is_empty() { + // A path not ended by a painting operator: pass it through. + piece.ops.append(&mut self.path); + self.path_box = None; + self.clip = false; + } + let kept = match o { + b"q" | b"Q" | b"cm" | b"Tf" | b"gs" => { + self.graphics_state(doc, scope, &op); + Some(op) } - bytes - })); + b"BT" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Ts" | b"Tr" | b"Td" | b"TD" | b"Tm" | b"T*" => { + self.text_state(&op); + Some(op) + } + b"Tj" | b"TJ" | b"'" | b"\"" => self.show_text(scope, op, piece), + b"BI" => Self::inline_image(&self.gs, scope, &mut self.out, op, piece), + b"sh" if scope.mode == Mode::Apply => Self::shading(&self.gs, scope, op, piece), + b"Do" => self.draw_xobject(doc, scope, resources, op, piece), + _ => Some(op), + }; + piece.ops.extend(kept); + } + + /// Path construction: collects the operator and grows the path's bounding box. + fn construct_path(&mut self, op: Op) { + let o = op.op.as_slice(); + let pts: Vec<(f64, f64)> = match o { + b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), + b"h" | b"W" | b"W*" => Vec::new(), + _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), + }; + for (x, y) in pts { + let (u, v) = self.gs.ctm.apply(x, y); + self.path_box = Some(match self.path_box { + None => [u, v, u, v], + Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], + }); + } + self.clip |= matches!(o, b"W" | b"W*"); + self.path.push(op); + } + + /// A painting operator ends the path; one under a region is removed (or kept as a clip). + fn paint_path(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) { + let body = std::mem::take(&mut self.path); + let bbox = self.path_box.take(); + let clips = std::mem::replace(&mut self.clip, false); + let hit = scope.mode == Mode::Apply && op.op.as_slice() != b"n" && bbox.is_some_and(|b| scope.hits(b)); + if !hit { + piece.ops.extend(body); + piece.ops.push(op); + return; + } + let b = bbox.unwrap_or_default(); + piece.changed = true; + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. + piece.ops.extend(body); + piece.ops.push(Op::new("n", vec![])); + } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. + } + + fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + match op.op.as_slice() { + b"q" => self.stack.push(self.gs.clone()), + b"Q" => { + if let Some(g) = self.stack.pop() { + self.gs = g; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + self.gs.ctm = Matrix(m).then(&self.gs.ctm); + } + } + b"Tf" => self.select_font(doc, scope, op), + _ => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) { + (self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size); + } + } + } + } + + fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + self.gs.size = op.num(1).unwrap_or(self.gs.size); + let Some(name) = op.name(0) else { return }; + let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); + self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + self.gs.font = match scope.fonts.get(name) { + Some(f) => f.clone(), + None => { + let m = font_dict.as_ref().map_or_else(|| self.fallback.clone(), |d| Rc::new(Metrics::from_dict(doc, d))); + scope.fonts.insert(name.to_vec(), m.clone()); + m + } + }; + } + + /// Text state and text-positioning operators. + fn text_state(&mut self, op: &Op) { + let gs = &mut self.gs; + match op.op.as_slice() { + b"BT" => { + self.tm = Matrix::IDENTITY; + self.tlm = Matrix::IDENTITY; + } + b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = op.num(0).unwrap_or(0.0), + b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), + b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.is("TD") { + gs.leading = -y; + } + self.tlm = Matrix::translate(x, y).then(&self.tlm); + self.tm = self.tlm; + } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + self.tlm = Matrix(m); + self.tm = self.tlm; + } + } + _ => { + self.tlm = Matrix::translate(0.0, -gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + } + } + + /// `Tj`, `TJ`, `'` and `"`: glyphs under a region are cut out (or counted, when verifying). + fn show_text(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let o = op.op.as_slice(); + if o == b"\"" { + self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + } + if matches!(o, b"'" | b"\"") { + self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + let items: Vec = match o { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569), so a redaction search matches what is drawn. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Vertical-writing text is laid out horizontally by `show`, so its glyphs are + // never matched against a region. Rather than ship a document whose vertical + // text was silently left under a mark, record it so apply can fail closed. + // (Full vertical-text removal is a future enhancement.) + if self.gs.vertical + && scope.mode == Mode::Apply + && scope.hidden_text.is_none() + && !scope.rects.is_empty() + && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) + { + self.out.vertical = true; + } + let (new_items, removed) = show(scope, &self.gs, &mut self.tm, &items); + if removed == 0 { + return Some(op); + } + if scope.mode == Mode::Verify { + self.out.residue += removed; + return Some(op); + } + piece.changed = true; + scope.report.glyphs += removed; + match op.op.as_slice() { + b"\"" => { + piece.ops.push(Op::new("Tw", vec![num(self.gs.word_spacing)])); + piece.ops.push(Op::new("Tc", vec![num(self.gs.char_spacing)])); + piece.ops.push(Op::new("T*", vec![])); + } + b"'" => piece.ops.push(Op::new("T*", vec![])), + _ => {} + } + piece.ops.push(Op::new("TJ", vec![Object::Array(new_items)])); + None + } + + /// An inline image under a region is removed (or counted, when verifying). + fn inline_image(gs: &Gs, scope: &mut Scope<'_>, out: &mut Output, op: Op, piece: &mut Piece) -> Option { + if !scope.hits(gs.ctm.bbox([0.0, 0.0, 1.0, 1.0])) { + return Some(op); + } + if scope.mode == Mode::Verify { + out.residue += 1; + return Some(op); + } + scope.report.images_removed += 1; + piece.changed = true; + None + } + + /// A shading fills the current clip: the regions are clipped out. + fn shading(gs: &Gs, scope: &Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) else { return Some(op) }; + piece.changed = true; + piece.ops.push(Op::new("q", vec![])); + piece.ops.extend(c); + piece.ops.push(op); + piece.ops.push(Op::new("Q", vec![])); + None + } + + fn draw_xobject(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) -> Option { + let Some(new) = xobject(doc, scope, &op, &self.gs, resources, &mut self.xobjects, &mut self.out) else { return Some(op) }; + piece.changed = true; + piece.ops.extend(new); + None } - out } fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { diff --git a/crates/redact/src/interp/layers.rs b/crates/redact/src/interp/layers.rs index 32d7afdf1..88d1c4e67 100644 --- a/crates/redact/src/interp/layers.rs +++ b/crates/redact/src/interp/layers.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, HashSet, ObjRef, Object, Op, Scope}; impl<'a> Scope<'a> { /// Is this optional-content reference (an OCG or OCMD) hidden? diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 2708a87a4..6ca5ea280 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CosError, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, is_vertical, num, + overlaps, page_streams, res_dict, +}; /// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, /// so a full-rewrite save stops reaching the original image or form it named. A name that some diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6f69151d8..de9dafe7d 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -409,193 +409,24 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, _>>()?; report.pages = by_page.len(); localize_resources(doc, &by_page)?; - let mut doomed_fields: Vec = Vec::new(); - // The original image and form XObjects the rewrite drops or replaces, on any page. - let mut doomed_xobjects: Vec = Vec::new(); - let mut retired_properties = Vec::new(); let widget_owner: Vec<(ObjRef, ObjRef)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::>()).collect(); let listed_before = listed_annotations(doc); - let mut removed_everywhere: HashSet = HashSet::new(); - + let mut applying = Applying { + report, + doomed_fields: Vec::new(), + doomed_xobjects: Vec::new(), + retired_properties: Vec::new(), + widget_owner, + removed_everywhere: HashSet::new(), + }; for &(pi, page_marks) in &groups { let page = pdfcraft_model::pages(doc).swap_remove(pi); let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - - // 1. Content. - let mut budget = pdfcraft_cos::DecodeBudget::page(); - let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; - let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) - }; - // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot - // tell whether those glyphs lie under a mark. Rather than report success and possibly - // leave the text in place, refuse the whole operation (the caller keeps the previous - // document). The error does not claim the text was under the mark. Full vertical-text - // removal is a future enhancement. - if let Some(error) = out.error { - return Err(error.into()); - } - if out.vertical { - return Err(RedactError::VerticalText(pi + 1)); - } - // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it - // can't read): where the page's operators resume after it is a guess, so neither removal - // nor the verify pass can be trusted on this page. - if out.ambiguous { - return Err(RedactError::Unreadable(pi + 1)); - } - doomed_xobjects.extend_from_slice(&out.doomed); - retired_properties.extend(out.retired_properties); - let mut new_list = list.clone(); - let mut changed = false; - for (i, new) in out.streams.into_iter().enumerate() { - let Some(bytes) = new else { continue }; - let mut dict = match &*doc.resolve(&list[i]) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs - // are redacted, keeping it would leave the redacted text in the file. The item stays - // as plain drawn content but is no longer editable. - dict.remove(b"PCAdded"); - // A new object: the original may be shared with other pages. - new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); - changed = true; - } - if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut res = resources.clone(); - if !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - // A full save keeps whatever is still referenced: unlink the originals this page no - // longer draws so the removed image or form bytes cannot survive in the saved file. - let page_content: Vec> = new_list - .iter() - .map(|o| match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }) - .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); - for (n, r) in &out.xobjects { - xo.set(n.clone(), Object::Ref(*r)); - } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - let (_, cleaned_data) = page_streams( - doc, - &{ - let mut cleaned_page = page.dict.clone(); - cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); - cleaned_page - }, - pi, - )?; - retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; - doc.update_dict(page.obj, |d| { - d.set(b"Contents".to_vec(), Object::Array(new_list)); - d.set(b"Resources".to_vec(), Object::Dict(res)); - })?; - // Tags must not keep what the page no longer shows. - let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); - let (_, new_data) = page_streams(doc, &after, pi)?; - let (changed_ids, empty_ids) = - tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); - report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; - } - - for (old, new, changed, empty) in out.tag_changes { - report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; - } - - // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). - let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); - let mut removed: Vec = Vec::new(); - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let subtype = d.name(b"Subtype").unwrap_or(b""); - if subtype == b"Redact" { - if page_marks.iter().any(|m| m.obj == r) { - removed.push(r); - } - continue; - } - if subtype == b"Popup" { - continue; - } - if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { - removed.push(r); - if subtype == b"Widget" { - if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) - && !doomed_fields.contains(field) - { - doomed_fields.push(*field); - } - } else { - report.annotations += 1; - } - } - } - // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to - // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints - // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at - // least one more annotation of this page or stops, so it ends. - let replies: Vec<(ObjRef, ObjRef)> = annots - .iter() - .filter_map(Object::as_ref) - .filter_map(|r| { - let obj = doc.get(r); - let d = obj.as_dict()?; - (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; - Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) - }) - .collect(); - loop { - let before = removed.len(); - for (r, parent) in &replies { - if !removed.contains(r) && removed.contains(parent) { - removed.push(*r); - report.annotations += 1; - } - } - if removed.len() == before { - break; - } - } - removed_everywhere.extend(removed.iter().copied()); - // Pop-ups dropped with their parent below count too: a reply on another page may answer one. - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { - removed_everywhere.insert(r); - } - } - let kept: Vec = annots - .into_iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - if removed.contains(&r) { - return false; - } - // Pop-ups of removed annotations go too. - let obj = doc.get(r); - !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) - }) - .collect(); - doc.update_dict(page.obj, |d| { - if kept.is_empty() { - d.remove(b"Annots"); - } else { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - })?; + redact_page_content(doc, &mut applying, pi, &page, &rects)?; + remove_page_annotations(doc, &mut applying, &page, &rects, page_marks)?; } + let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, .. } = applying; // 2b. Replies and pop-ups of a removed annotation can be listed on another page than their // parent: their `/IRT` or `/Parent` would keep the removed comment reachable. @@ -702,6 +533,213 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, + /// The original image and form XObjects the rewrite drops or replaces, on any page. + doomed_xobjects: Vec, + retired_properties: Vec, + widget_owner: Vec<(ObjRef, ObjRef)>, + removed_everywhere: HashSet, +} + +/// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. +fn redact_page_content( + doc: &mut Document, + applying: &mut Applying, + pi: usize, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], +) -> Result<(), RedactError> { + let Applying { report, doomed_xobjects, retired_properties, .. } = applying; + // 1. Content. + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let out = { + let mut scope = Scope::new(rects, Mode::Apply, report, budget); + process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + }; + // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot + // tell whether those glyphs lie under a mark. Rather than report success and possibly + // leave the text in place, refuse the whole operation (the caller keeps the previous + // document). The error does not claim the text was under the mark. Full vertical-text + // removal is a future enhancement. + if let Some(error) = out.error { + return Err(error.into()); + } + if out.vertical { + return Err(RedactError::VerticalText(pi + 1)); + } + // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it + // can't read): where the page's operators resume after it is a guess, so neither removal + // nor the verify pass can be trusted on this page. + if out.ambiguous { + return Err(RedactError::Unreadable(pi + 1)); + } + doomed_xobjects.extend_from_slice(&out.doomed); + retired_properties.extend(out.retired_properties); + let mut new_list = list.clone(); + let mut changed = false; + for (i, new) in out.streams.into_iter().enumerate() { + let Some(bytes) = new else { continue }; + let mut dict = match &*doc.resolve(&list[i]) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs + // are redacted, keeping it would leave the redacted text in the file. The item stays + // as plain drawn content but is no longer editable. + dict.remove(b"PCAdded"); + // A new object: the original may be shared with other pages. + new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); + changed = true; + } + if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut res = resources.clone(); + if !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + // A full save keeps whatever is still referenced: unlink the originals this page no + // longer draws so the removed image or form bytes cannot survive in the saved file. + let page_content: Vec> = new_list + .iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }) + .collect(); + interp::prune(&mut xo, &out.replaced, &page_content); + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + let (_, cleaned_data) = page_streams( + doc, + &{ + let mut cleaned_page = page.dict.clone(); + cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); + cleaned_page + }, + pi, + )?; + retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); + type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(new_list)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + // Tags must not keep what the page no longer shows. + let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); + let (_, new_data) = page_streams(doc, &after, pi)?; + let (changed_ids, empty_ids) = + tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); + report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + + for (old, new, changed, empty) in out.tag_changes { + report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; + } + Ok(()) +} + +/// Step 2 for one page: the marks, and whatever lies under them (with their pop-ups). +fn remove_page_annotations( + doc: &mut Document, + applying: &mut Applying, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], + page_marks: &[&Mark], +) -> Result<(), RedactError> { + let Applying { report, doomed_fields, widget_owner, removed_everywhere, .. } = applying; + // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). + let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); + let mut removed: Vec = Vec::new(); + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let subtype = d.name(b"Subtype").unwrap_or(b""); + if subtype == b"Redact" { + if page_marks.iter().any(|m| m.obj == r) { + removed.push(r); + } + continue; + } + if subtype == b"Popup" { + continue; + } + if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { + removed.push(r); + if subtype == b"Widget" { + if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) + && !doomed_fields.contains(field) + { + doomed_fields.push(*field); + } + } else { + report.annotations += 1; + } + } + } + // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to + // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints + // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at + // least one more annotation of this page or stops, so it ends. + let replies: Vec<(ObjRef, ObjRef)> = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } + removed_everywhere.extend(removed.iter().copied()); + // Pop-ups dropped with their parent below count too: a reply on another page may answer one. + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { + removed_everywhere.insert(r); + } + } + let kept: Vec = annots + .into_iter() + .filter(|a| { + let Some(r) = a.as_ref() else { return true }; + if removed.contains(&r) { + return false; + } + // Pop-ups of removed annotations go too. + let obj = doc.get(r); + !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) + }) + .collect(); + doc.update_dict(page.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + })?; + Ok(()) +} + /// Remove redaction marks without applying them (`None` = all), with their replies and pop-ups as /// deleting a comment does: a reply left behind would keep its mark in the file through `/IRT`. /// Returns how many marks went. diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index f431dab90..57971571a 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -178,42 +178,38 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res Ok(()) } -fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { - let mut changed = false; - - macro_rules! prune_category { - ($cat:expr, $used_set:expr) => { - if let Some(sub_obj) = font_res.get($cat) { - let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); - let names: Vec> = sub.iter().map(|(n, _)| n.to_vec()).collect(); - let mut cat_changed = false; - for n in names { - if !$used_set.contains(&n) { - sub.remove(&n); - cat_changed = true; - } - } - if cat_changed { - changed = true; - if sub.is_empty() { - font_res.remove($cat); - } else { - font_res.set($cat.to_vec(), Object::Dict(sub)); - } - } - } - }; +/// Removes the entries of `font_res`'s `category` sub-dictionary that `used` does not name, +/// dropping the sub-dictionary when nothing is left. Whether anything changed. +fn prune_category(doc: &Document, font_res: &mut Dict, category: &[u8], used: &HashSet>) -> bool { + let Some(sub_obj) = font_res.get(category) else { return false }; + let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); + let unused: Vec> = sub.iter().map(|(n, _)| n.to_vec()).filter(|n| !used.contains(n)).collect(); + if unused.is_empty() { + return false; } + for name in &unused { + sub.remove(name); + } + if sub.is_empty() { + font_res.remove(category); + } else { + font_res.set(category.to_vec(), Object::Dict(sub)); + } + true +} - prune_category!(b"XObject", used.xobjects); - prune_category!(b"ColorSpace", used.colorspaces); - prune_category!(b"Pattern", used.patterns); - prune_category!(b"Shading", used.shadings); - prune_category!(b"ExtGState", used.extgstates); - prune_category!(b"Font", used.fonts); - prune_category!(b"Properties", used.properties); - - changed +fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { + let categories: [(&[u8], &HashSet>); 7] = [ + (b"XObject", &used.xobjects), + (b"ColorSpace", &used.colorspaces), + (b"Pattern", &used.patterns), + (b"Shading", &used.shadings), + (b"ExtGState", &used.extgstates), + (b"Font", &used.fonts), + (b"Properties", &used.properties), + ]; + // Every category is pruned, even after an earlier one changed. + categories.into_iter().fold(false, |changed, (category, names)| prune_category(doc, font_res, category, names) | changed) } struct ScanContext<'a> {