Refactor redact for strict Clippy checks

This commit is contained in:
Dylan M. Taylor
2026-10-27 23:12:51 +00:00
parent bc89733725
commit 33e23da8a9
5 changed files with 568 additions and 453 deletions
+315 -237
View File
@@ -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<u8>], resources: &Dict, mut gs: Gs) -> Output {
let mut out = Output::default();
let fallback = Rc::new(Metrics::fallback());
let mut stack: Vec<Gs> = 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<Op> = 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<u8>], 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<u8>],
forced_change = true;
}
}
let mut pieces: Vec<Vec<Op>> = streams.iter().map(|_| Vec::new()).collect();
let mut rewrite: Vec<bool> = 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<Op>, count: usize, forced_change: bool) -> (Vec<Vec<Op>>, Vec<bool>) {
let mut pieces: Vec<Vec<Op>> = (0..count).map(|_| Vec::new()).collect();
let mut rewrite: Vec<bool> = 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<u8>],
p.push(op);
}
}
(pieces, rewrite)
}
let mut processed = Vec::new();
for (piece, mut changed) in pieces.into_iter().zip(rewrite) {
let mut ops: Vec<Op> = 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::<Vec<_>>().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<Object> = 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<u8> {
// 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<Op>,
changed: bool,
}
/// The interpreter state carried across the operators of a scope.
struct Walk {
gs: Gs,
stack: Vec<Gs>,
tm: Matrix,
tlm: Matrix,
fonts_res: Dict,
ext_gstates: Dict,
xobjects: Dict,
fallback: Rc<Metrics>,
/// The current path: its operators, its bounding box in user space, and whether it clips.
path: Vec<Op>,
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::<Vec<_>>().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<Op> {
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<Object> = 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<Op> {
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<Op> {
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<Op> {
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<Op>, bool)], report: &mut Report) {
+1 -1
View File
@@ -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?
+4 -1
View File
@@ -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
+218 -180
View File
@@ -409,193 +409,24 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
.collect::<Result<Vec<_>, _>>()?;
report.pages = by_page.len();
localize_resources(doc, &by_page)?;
let mut doomed_fields: Vec<ObjRef> = Vec::new();
// The original image and form XObjects the rewrite drops or replaces, on any page.
let mut doomed_xobjects: Vec<ObjRef> = 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::<Vec<_>>()).collect();
let listed_before = listed_annotations(doc);
let mut removed_everywhere: HashSet<ObjRef> = 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<Vec<u8>> = 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<ObjRef> = 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<Object> = 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<Report, Redac
Ok(report)
}
/// What applying the marks collects across pages.
struct Applying {
report: Report,
doomed_fields: Vec<ObjRef>,
/// The original image and form XObjects the rewrite drops or replaces, on any page.
doomed_xobjects: Vec<ObjRef>,
retired_properties: Vec<Object>,
widget_owner: Vec<(ObjRef, ObjRef)>,
removed_everywhere: HashSet<ObjRef>,
}
/// 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<Vec<u8>> = 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<ObjRef> = 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<Object> = 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.
+30 -34
View File
@@ -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<Vec<u8>> = 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<Vec<u8>>) -> 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<Vec<u8>> = 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<Vec<u8>>); 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> {