mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 04:58:34 -06:00
New modules: interp/layers.rs (hidden-layer handling), interp/prune.rs (resource pruning and unlinking).
832 lines
37 KiB
Rust
832 lines
37 KiB
Rust
//! The content interpreter that applies (or verifies) redaction on one content scope: a page's
|
||
//! streams or a form XObject's.
|
||
//!
|
||
//! It tracks just enough graphics state to place every glyph, image and path in user space:
|
||
//! the CTM stack, the text state (font, size, spacing, scaling, rise, leading) and the text
|
||
//! matrices. In apply mode it rewrites the operators:
|
||
//! - text: glyphs whose boxes overlap a region are cut out of `Tj`/`TJ`/`'`/`"`, replaced by a
|
||
//! `TJ` displacement of the same width so the remaining text stays exactly where it was;
|
||
//! - images: fully covered → the `Do` is removed; partly covered → the covered pixels are
|
||
//! cleared in a copy of the image (or the image is removed when its codec can't be re-encoded);
|
||
//! inline images under a region are removed;
|
||
//! - vectors: fully covered paths are removed; partly covered ones are clipped so nothing shows
|
||
//! inside the regions; shadings are clipped the same way;
|
||
//! - form XObjects under a region are rewritten recursively into new objects (copy-on-write, so
|
||
//! other pages using the original are untouched).
|
||
//!
|
||
//! In verify mode nothing changes; glyphs and inline images that still overlap a region are
|
||
//! counted.
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::rc::Rc;
|
||
|
||
use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string};
|
||
use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream};
|
||
|
||
use crate::{RedactError, Report, image, page_streams, tags};
|
||
use pdfcraft_fonts::pdf::Metrics;
|
||
|
||
mod layers;
|
||
mod prune;
|
||
|
||
use layers::strip_hidden_layers;
|
||
use prune::{clip_out, extgstate_font, prune_form_resources};
|
||
pub(crate) use prune::{prune, prune_properties, resource_ops, unlink_doomed};
|
||
|
||
/// How deep this pass follows form XObjects. A form past it is removed rather than walked, which
|
||
/// is the fail-safe choice for a redaction -- marked content inside a form we cannot read would
|
||
/// otherwise survive -- but it also removes whatever else that form drew, marked or not. So the
|
||
/// limit has to reach as deep as anything the reader can see: the renderer follows forms to 50
|
||
/// (`MAX_NESTED_INTERPRETATION_DEPTH`) and so does Edit text's reader (`text::MAX_FORM_DEPTH`).
|
||
/// At 12, a page with a 13-deep chain lost the text of its innermost form even with nothing in it
|
||
/// marked (#3645); past 50 nothing is drawn by any of the three, so removing it costs nothing
|
||
/// visible.
|
||
pub(crate) const MAX_DEPTH: usize = 50;
|
||
|
||
// A form a reader still shows must be walkable here, or a redaction removes content that is
|
||
// visible; the build says so rather than a test, so the two limits cannot drift apart.
|
||
const _: () = assert!(MAX_DEPTH >= pdfcraft_edit::text::MAX_FORM_DEPTH);
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub(crate) enum Mode {
|
||
Apply,
|
||
Verify,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct Gs {
|
||
ctm: Matrix,
|
||
font: Rc<Metrics>,
|
||
/// The current font writes vertically (a Type0 font with an `-V` encoding or a CMap whose
|
||
/// `/WMode` is 1). Glyph layout here is horizontal, so such text cannot be placed under a
|
||
/// region; apply fails closed when it meets any (see `show`).
|
||
vertical: bool,
|
||
size: f64,
|
||
char_spacing: f64,
|
||
word_spacing: f64,
|
||
render_mode: i64,
|
||
scale: f64,
|
||
leading: f64,
|
||
rise: f64,
|
||
}
|
||
|
||
/// What processing a scope produced.
|
||
#[derive(Default)]
|
||
pub(crate) struct Output {
|
||
/// The rewritten streams (`None` = unchanged).
|
||
pub streams: Vec<Option<Vec<u8>>>,
|
||
/// New XObjects the rewritten content refers to: (resource name, object).
|
||
pub xobjects: Vec<(Vec<u8>, ObjRef)>,
|
||
/// Resource names whose `Do` this scope dropped or pointed at a cleaned copy, so the caller
|
||
/// can unlink the original from the resource dictionary (a full save keeps whatever is still
|
||
/// referenced, which would otherwise retain the removed image or form bytes).
|
||
pub replaced: Vec<Vec<u8>>,
|
||
/// The original objects behind `replaced`, nested scopes included. Other resource
|
||
/// dictionaries may name them too (an inherited or shared /Resources, or the caller's when a
|
||
/// form has none of its own): [`unlink_doomed`] removes those entries.
|
||
pub doomed: Vec<ObjRef>,
|
||
/// Original property values retired by this scope or a nested form. Other resource holders
|
||
/// may still name them; unlink only entries no surviving content uses.
|
||
pub retired_properties: Vec<Object>,
|
||
/// Glyphs / inline images still overlapping a region (verify mode).
|
||
pub residue: usize,
|
||
/// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was
|
||
/// in force: the operation must fail closed rather than leave that text under the mark.
|
||
pub vertical: bool,
|
||
/// The content holds an inline image the renderer reads no image for
|
||
/// ([`pdfcraft_content::Parsed::ambiguous_images`]): what this scope placed after it may not
|
||
/// be what the page shows, so a redaction must not trust it.
|
||
pub ambiguous: bool,
|
||
pub tag_changes: Vec<(ObjRef, ObjRef, HashSet<i64>, HashSet<i64>)>,
|
||
pub error: Option<CosError>,
|
||
}
|
||
|
||
pub(crate) struct Scope<'a> {
|
||
pub rects: &'a [[f64; 4]],
|
||
pub mode: Mode,
|
||
pub report: &'a mut Report,
|
||
/// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box).
|
||
pub hidden_text: Option<[f64; 4]>,
|
||
/// Sanitize: optional content groups that are off; content marked with them is removed.
|
||
pub hidden_layers: HashSet<ObjRef>,
|
||
/// Whether this pass is evaluating optional-content rules. An empty off set is meaningful:
|
||
/// policies such as `/AllOff` can still hide content when every group is on.
|
||
pub layers_enabled: bool,
|
||
/// Off groups referenced by optional content that remains visible and therefore still needs
|
||
/// its original default state after sanitization.
|
||
pub retained_off_layers: HashSet<ObjRef>,
|
||
/// Cache membership results so repeated blocks cannot repeatedly walk hostile expressions.
|
||
ocmd_cache: HashMap<ObjRef, bool>,
|
||
/// Direct `/OC` dictionaries on XObjects have no object reference of their own. Cache them
|
||
/// by the containing XObject so repeated `Do` calls share the same bounded evaluation.
|
||
xobject_oc_cache: HashMap<ObjRef, bool>,
|
||
/// Content removed from hidden layers (blocks and XObjects).
|
||
pub layer_blocks: usize,
|
||
fonts: HashMap<Vec<u8>, Rc<Metrics>>,
|
||
used_names: Vec<Vec<u8>>,
|
||
depth: usize,
|
||
/// Form entries and decoded bytes left for this page, its content streams already charged:
|
||
/// shared forms drawn from other shared forms would otherwise multiply the work with every
|
||
/// level.
|
||
budget: DecodeBudget,
|
||
}
|
||
|
||
impl<'a> Scope<'a> {
|
||
/// A scope whose form walk spends `budget`, what is left of the page's allowance after its
|
||
/// content streams were decoded.
|
||
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self {
|
||
Scope {
|
||
rects,
|
||
mode,
|
||
report,
|
||
hidden_text: None,
|
||
hidden_layers: HashSet::new(),
|
||
layers_enabled: false,
|
||
retained_off_layers: HashSet::new(),
|
||
ocmd_cache: HashMap::new(),
|
||
xobject_oc_cache: HashMap::new(),
|
||
layer_blocks: 0,
|
||
fonts: HashMap::new(),
|
||
used_names: Vec::new(),
|
||
depth: 0,
|
||
budget,
|
||
}
|
||
}
|
||
|
||
/// Sanitizing visits every form XObject (not only those under a region).
|
||
fn everywhere(&self) -> bool {
|
||
self.hidden_text.is_some() || self.layers_enabled
|
||
}
|
||
|
||
fn hits(&self, b: [f64; 4]) -> bool {
|
||
self.rects.iter().any(|r| overlaps(*r, b, 0.0))
|
||
}
|
||
|
||
fn covered(&self, b: [f64; 4]) -> bool {
|
||
self.rects.iter().any(|r| contains(*r, b, 0.01))
|
||
}
|
||
|
||
/// Does a glyph box count as under a region? Any real overlap does (more than a fifth of
|
||
/// the glyph or a point, whichever is less, in both directions), so neighbours that only
|
||
/// touch an edge survive while partly covered glyphs go. Degenerate boxes (zero-size text)
|
||
/// count when their origin is inside.
|
||
fn glyph_hit(&self, b: [f64; 4], origin: (f64, f64)) -> bool {
|
||
let (gw, gh) = (b[2] - b[0], b[3] - b[1]);
|
||
if gw < 0.01 || gh < 0.01 {
|
||
return self.rects.iter().any(|r| origin.0 >= r[0] && origin.0 <= r[2] && origin.1 >= r[1] && origin.1 <= r[3]);
|
||
}
|
||
let (mx, my) = ((gw * 0.2).min(1.0), (gh * 0.2).min(1.0));
|
||
self.rects.iter().any(|r| {
|
||
let ox = b[2].min(r[2]) - b[0].max(r[0]);
|
||
let oy = b[3].min(r[3]) - b[1].max(r[1]);
|
||
ox > mx && oy > my
|
||
})
|
||
}
|
||
}
|
||
|
||
fn res_dict(doc: &Document, res: &Dict, key: &[u8]) -> Dict {
|
||
res.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default()
|
||
}
|
||
|
||
/// Does this font write vertically? Only a Type0 (composite) font can: its `/Encoding` is a
|
||
/// predefined CMap name ending in `-V` (Identity-V and the like), or an embedded CMap stream
|
||
/// whose `/WMode` is 1 (ISO 32000-2 §9.7.5).
|
||
fn is_vertical(doc: &Document, font: &Dict) -> bool {
|
||
if font.name(b"Subtype") != Some(b"Type0".as_slice()) {
|
||
return false;
|
||
}
|
||
match font.get(b"Encoding").map(|e| doc.resolve(e)).as_deref() {
|
||
Some(Object::Name(n)) => n.ends_with(b"-V"),
|
||
Some(Object::Stream(s)) => s.dict.int(b"WMode") == Some(1),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn fresh_name(scope: &mut Scope<'_>, xobjects: &Dict) -> Vec<u8> {
|
||
let mut i = scope.used_names.len() + 1;
|
||
loop {
|
||
let n = format!("PCRedacted{i}").into_bytes();
|
||
if !xobjects.contains(&n) && !scope.used_names.contains(&n) {
|
||
scope.used_names.push(n.clone());
|
||
return n;
|
||
}
|
||
i += 1;
|
||
}
|
||
}
|
||
|
||
/// One glyph of a shown string.
|
||
struct Glyph {
|
||
bytes: std::ops::Range<usize>,
|
||
/// Advance in unscaled text space (before `Th`).
|
||
advance: f64,
|
||
hit: bool,
|
||
}
|
||
|
||
/// Process a scope's content streams (state flows from one stream to the next).
|
||
pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<u8>], resources: &Dict, ctm: Matrix) -> Output {
|
||
let gs = Gs {
|
||
ctm,
|
||
font: Rc::new(Metrics::fallback()),
|
||
vertical: false,
|
||
size: 0.0,
|
||
char_spacing: 0.0,
|
||
word_spacing: 0.0,
|
||
render_mode: 0,
|
||
scale: 1.0,
|
||
leading: 0.0,
|
||
rise: 0.0,
|
||
};
|
||
process_from(doc, scope, streams, resources, gs)
|
||
}
|
||
|
||
/// [`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;
|
||
|
||
// 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;
|
||
// 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();
|
||
if scope.layers_enabled {
|
||
let props = res_dict(doc, resources, b"Properties");
|
||
let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props);
|
||
parsed.ops = kept;
|
||
if removed > 0 {
|
||
scope.layer_blocks += removed;
|
||
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 (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;
|
||
}
|
||
}
|
||
if let Some(p) = pieces.get_mut(last) {
|
||
p.push(op);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
if !path.is_empty() {
|
||
ops.append(&mut path);
|
||
}
|
||
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'%');
|
||
}
|
||
bytes
|
||
}));
|
||
}
|
||
out
|
||
}
|
||
|
||
fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec<Op>, bool)], report: &mut Report) {
|
||
let originals: HashMap<usize, &Op> = original.iter().map(|op| (op.span.start, op)).collect();
|
||
let unchanged: HashSet<usize> =
|
||
processed.iter().flat_map(|(ops, _)| ops).filter(|op| originals.get(&op.span.start) == Some(op)).map(|op| op.span.start).collect();
|
||
let mut stack = Vec::new();
|
||
let mut affected = HashSet::new();
|
||
for op in original {
|
||
match op.op.as_slice() {
|
||
b"BDC" | b"BMC" => stack.push(op.span.start),
|
||
b"EMC" => {
|
||
stack.pop();
|
||
}
|
||
_ if !unchanged.contains(&op.span.start) => affected.extend(stack.iter().copied()),
|
||
_ => {}
|
||
}
|
||
}
|
||
let properties = res_dict(doc, resources, b"Properties");
|
||
for (ops, changed) in processed {
|
||
for op in ops {
|
||
if !op.is("BDC") || !affected.contains(&op.span.start) {
|
||
continue;
|
||
}
|
||
let property = match op.operands.get(1) {
|
||
Some(Object::Name(name)) => properties.get(name).map(|p| doc.resolve(p)),
|
||
Some(p) => Some(doc.resolve(p)),
|
||
None => None,
|
||
};
|
||
if let Some(mut dict) = property.and_then(|p| p.as_dict().cloned()) {
|
||
let mut stripped = false;
|
||
for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] {
|
||
stripped |= dict.remove(key).is_some();
|
||
}
|
||
if stripped && let Some(property) = op.operands.get_mut(1) {
|
||
*property = Object::Dict(dict);
|
||
*changed = true;
|
||
report.replacement_text += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Lay out a shown string (or `TJ` array) glyph by glyph, advancing `tm`. Returns the `TJ`
|
||
/// items with the glyphs under a region replaced by displacements, and how many were removed.
|
||
fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec<Object>, usize) {
|
||
let f = &gs.font;
|
||
let size = gs.size;
|
||
let mut out: Vec<Object> = Vec::new();
|
||
let mut removed = 0;
|
||
let push_num = |out: &mut Vec<Object>, v: f64| {
|
||
if v == 0.0 {
|
||
return;
|
||
}
|
||
if let Some(last) = out.last_mut()
|
||
&& let Some(prev) = last.as_f64()
|
||
{
|
||
*last = num(prev + v);
|
||
return;
|
||
}
|
||
out.push(num(v));
|
||
};
|
||
for item in items {
|
||
match item {
|
||
Object::String(s) => {
|
||
let bytes = &s.bytes;
|
||
let mut glyphs = Vec::new();
|
||
let mut pos = 0;
|
||
for (code, len) in f.codes(bytes) {
|
||
let w0 = f.width(code);
|
||
let spacing = gs.char_spacing + if f.is_space(code, len) { gs.word_spacing } else { 0.0 };
|
||
let trm = Matrix([size * gs.scale, 0.0, 0.0, size, 0.0, gs.rise]).then(tm).then(&gs.ctm);
|
||
let b = trm.bbox([0.0, f.descent, w0, f.ascent]);
|
||
let origin = trm.apply(0.0, 0.0);
|
||
let advance = w0 * size + spacing;
|
||
let hit = match scope.hidden_text {
|
||
// Invisible (Tr 3) and clip-only (Tr 7) text, or text wholly off the page.
|
||
Some(page) => {
|
||
let degenerate = b[2] - b[0] < 0.01 && b[3] - b[1] < 0.01;
|
||
matches!(gs.render_mode, 3 | 7) || !(overlaps(page, b, 0.0) || degenerate)
|
||
}
|
||
None => scope.glyph_hit(b, origin),
|
||
};
|
||
glyphs.push(Glyph { bytes: pos..pos + len, advance, hit });
|
||
*tm = Matrix::translate(advance * gs.scale, 0.0).then(tm);
|
||
pos += len;
|
||
}
|
||
let mut run: Vec<u8> = Vec::new();
|
||
for g in &glyphs {
|
||
if g.hit {
|
||
removed += 1;
|
||
if !run.is_empty() {
|
||
out.push(string(std::mem::take(&mut run)));
|
||
}
|
||
// A TJ number moves by -n/1000 × size (× Th): the removed advance.
|
||
if size != 0.0 {
|
||
push_num(&mut out, -g.advance * 1000.0 / size);
|
||
}
|
||
} else {
|
||
run.extend_from_slice(&bytes[g.bytes.clone()]);
|
||
}
|
||
}
|
||
if !run.is_empty() {
|
||
out.push(string(run));
|
||
}
|
||
}
|
||
other => {
|
||
if let Some(v) = other.as_f64() {
|
||
*tm = Matrix::translate(-v / 1000.0 * size * gs.scale, 0.0).then(tm);
|
||
push_num(&mut out, v);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
(out, removed)
|
||
}
|
||
|
||
/// `Do`: `None` = leave the operator alone; `Some(None)` = drop it; `Some(Some(op))` = replace it.
|
||
fn xobject(
|
||
doc: &mut Document,
|
||
scope: &mut Scope<'_>,
|
||
op: &Op,
|
||
gs: &Gs,
|
||
resources: &Dict,
|
||
xobjects: &mut Dict,
|
||
out: &mut Output,
|
||
) -> Option<Option<Op>> {
|
||
let name = op.last_name()?.to_vec();
|
||
let r = xobjects.get(&name)?.as_ref()?;
|
||
let obj = doc.get(r);
|
||
let Object::Stream(s) = &*obj else { return None };
|
||
match s.dict.name(b"Subtype") {
|
||
_ if s.dict.get(b"OC").is_some_and(|oc| scope.xobject_layer_hidden(doc, r, oc)) => {
|
||
// Verify mode must count hidden XObjects too: scan uses that count to decide
|
||
// whether the HiddenLayers pass is necessary.
|
||
scope.layer_blocks += 1;
|
||
if scope.mode == Mode::Verify {
|
||
return None;
|
||
}
|
||
out.replaced.push(name);
|
||
out.doomed.push(r);
|
||
Some(None)
|
||
}
|
||
Some(b"Image") => {
|
||
let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]);
|
||
if !scope.hits(b) || scope.mode == Mode::Verify {
|
||
return None;
|
||
}
|
||
if scope.covered(b) {
|
||
scope.report.images_removed += 1;
|
||
out.replaced.push(name);
|
||
out.doomed.push(r);
|
||
return Some(None);
|
||
}
|
||
match image::clear(doc, s, &gs.ctm, scope.rects) {
|
||
Ok(None) => None,
|
||
Ok(Some(new)) => {
|
||
let nr = doc.add(Object::Stream(new));
|
||
let n = fresh_name(scope, xobjects);
|
||
xobjects.set(n.clone(), Object::Ref(nr));
|
||
out.xobjects.push((n.clone(), nr));
|
||
out.replaced.push(name);
|
||
out.doomed.push(r);
|
||
scope.report.images_cleared += 1;
|
||
Some(Some(Op::new("Do", vec![Object::Name(n)])))
|
||
}
|
||
Err(()) => {
|
||
scope.report.images_removed += 1;
|
||
out.replaced.push(name);
|
||
out.doomed.push(r);
|
||
Some(None)
|
||
}
|
||
}
|
||
}
|
||
Some(b"Form") => {
|
||
let m = s.dict.get(b"Matrix").and_then(|m| Matrix::from_operands(doc.resolve(m).as_array()?)).unwrap_or_default();
|
||
let fctm = m.then(&gs.ctm);
|
||
let bbox = s.dict.get(b"BBox").and_then(|b| {
|
||
let v: Vec<f64> = doc.resolve(b).as_array()?.iter().filter_map(Object::as_f64).collect();
|
||
(v.len() == 4).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])])
|
||
});
|
||
if let Some(bb) = bbox
|
||
&& !scope.everywhere()
|
||
&& !scope.hits(fctm.bbox(bb))
|
||
{
|
||
return None;
|
||
}
|
||
// Too deep: this form cannot be read, so it goes (Apply) or the page is left alone
|
||
// (Verify), as a form that will not decode does. The page's decode budget below is what
|
||
// keeps a fan-out or a cycle from costing 2^50 walks.
|
||
if scope.depth >= MAX_DEPTH {
|
||
if scope.mode == Mode::Verify {
|
||
// Sanitization removes a Form it cannot enter, so the verification scan
|
||
// must count that conservative removal or it may decide to skip the pass.
|
||
if scope.everywhere() {
|
||
scope.layer_blocks += 1;
|
||
}
|
||
return None;
|
||
}
|
||
scope.report.forms_removed += 1;
|
||
out.replaced.push(name);
|
||
out.doomed.push(r);
|
||
return Some(None);
|
||
}
|
||
// Strict, like page content: a Form read only in part counts as undecodable.
|
||
let data = if scope.budget.enter_form() {
|
||
scope.budget.decode_strict(s)
|
||
} else {
|
||
Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects")))
|
||
};
|
||
let data = match data {
|
||
Ok(d) => d,
|
||
// Past the page's budget nothing more is read: apply refuses the page, and
|
||
// verification counts the form as content that may remain.
|
||
Err(e) if scope.budget.exhausted() => {
|
||
if scope.mode == Mode::Apply {
|
||
out.error = Some(e);
|
||
} else if scope.everywhere() {
|
||
scope.layer_blocks += 1;
|
||
} else {
|
||
out.residue += 1;
|
||
}
|
||
return None;
|
||
}
|
||
Err(_) if scope.mode == Mode::Apply => {
|
||
scope.report.forms_removed += 1;
|
||
out.replaced.push(name);
|
||
out.doomed.push(r);
|
||
return Some(None);
|
||
}
|
||
Err(_) => {
|
||
// Apply drops an undecodable Form while sanitizing. Count it during Verify
|
||
// so a zero-count preflight cannot leave its potentially hidden bytes behind.
|
||
if scope.everywhere() {
|
||
scope.layer_blocks += 1;
|
||
}
|
||
return None;
|
||
}
|
||
};
|
||
let own = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned());
|
||
let res = own.clone().unwrap_or_else(|| resources.clone());
|
||
let saved_fonts = std::mem::take(&mut scope.fonts);
|
||
scope.depth += 1;
|
||
let inner = process_from(doc, scope, std::slice::from_ref(&data), &res, Gs { ctm: fctm, ..gs.clone() });
|
||
scope.depth -= 1;
|
||
scope.fonts = saved_fonts;
|
||
out.residue += inner.residue;
|
||
out.vertical |= inner.vertical;
|
||
out.ambiguous |= inner.ambiguous;
|
||
out.doomed.extend_from_slice(&inner.doomed);
|
||
out.retired_properties.extend(inner.retired_properties);
|
||
out.tag_changes.extend(inner.tag_changes);
|
||
out.error = inner.error.or(out.error.take());
|
||
if scope.mode == Mode::Verify {
|
||
return None;
|
||
}
|
||
let new_data = inner.streams.into_iter().next().flatten();
|
||
if new_data.is_none() && inner.xobjects.is_empty() && inner.replaced.is_empty() {
|
||
return None;
|
||
}
|
||
let mut dict = s.dict.clone();
|
||
dict.remove(b"Length");
|
||
let mut cleaned_res = res.clone();
|
||
// Install rewritten nested forms before finding live property names: a new form may
|
||
// still inherit its caller's resources, and its new Do name must resolve here.
|
||
if !inner.xobjects.is_empty() || !inner.replaced.is_empty() {
|
||
let mut xo = res_dict(doc, &cleaned_res, b"XObject");
|
||
if let Some(data) = &new_data {
|
||
prune(&mut xo, &inner.replaced, std::slice::from_ref(data));
|
||
}
|
||
for (name, reference) in &inner.xobjects {
|
||
xo.set(name.clone(), Object::Ref(*reference));
|
||
}
|
||
cleaned_res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||
}
|
||
if let Some(data) = &new_data {
|
||
let content = std::slice::from_ref(data);
|
||
match prune_properties(doc, &mut cleaned_res, content) {
|
||
Ok(retired) => out.retired_properties.extend(retired),
|
||
Err(error) => {
|
||
out.error = Some(error);
|
||
return None;
|
||
}
|
||
}
|
||
if let Err(error) = prune_form_resources(doc, &mut cleaned_res, content) {
|
||
out.error = Some(error);
|
||
return None;
|
||
}
|
||
if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content) {
|
||
out.error = Some(error);
|
||
return None;
|
||
}
|
||
}
|
||
if cleaned_res != res {
|
||
dict.set(b"Resources".to_vec(), Object::Dict(cleaned_res));
|
||
}
|
||
let (changed, empty) = tags::touched(doc, &res, &data, new_data.as_deref().unwrap_or(&data));
|
||
let stream = match new_data {
|
||
Some(d) => Stream::flate(dict, &d),
|
||
None => Stream { dict, raw: s.raw.clone() },
|
||
};
|
||
let nr = doc.add(Object::Stream(stream));
|
||
out.tag_changes.push((r, nr, changed, empty));
|
||
let n = fresh_name(scope, xobjects);
|
||
xobjects.set(n.clone(), Object::Ref(nr));
|
||
out.xobjects.push((n.clone(), nr));
|
||
out.replaced.push(name);
|
||
out.doomed.push(r);
|
||
scope.report.forms_rewritten += 1;
|
||
Some(Some(Op::new("Do", vec![Object::Name(n)])))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|