mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
Refactor redact for strict Clippy checks
This commit is contained in:
+315
-237
@@ -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,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?
|
||||
|
||||
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user