Files
pdfcraft/crates/redact/src/interp.rs
T
b642e327e7 Security: fix 10 verified audit findings in redaction, signature validation, and untrusted-input handling (#1099)
* fix(create): cap decoded size of imported PNGs

A PNG's declared dimensions sized an infallible allocation, so a few-byte file claiming huge dimensions aborted the process when the allocation failed. Cap the decoded size at the TIFF ceiling and reserve every input-sized buffer fallibly, returning CreateError::Image instead. (Security audit run-2: v3-024.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(xfa): bound total picture bytes kept during layout

Each laid-out picture kept its own copy, so one picture in a repeating subform multiplied without bound and could exhaust memory on open. Add a per-layout aggregate cap (256 MiB) that stops with XfaError::TooLarge. (Security audit run-2: v3-103.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(xfdf): bound XML nesting and protect redaction marks on import

Deeply nested XFDF/XML overflowed roxmltree's recursive parser and aborted the process; a non-recursive depth scan now rejects it before parsing. Imported comments could also replace a locked or redaction mark by /NM or add a hidden one; place() no longer replaces locked/Redact annotations and clears the hidden/invisible flags on imported redaction marks. (Security audit run-2: v3-105, v3-111.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sign): classify post-signing changes by the signed revision's roles

A signed object's modification was classified from labels in the attacker-written new version, so relabelling a signed page /Type /Sig (or giving it /ModDate), or relabelling a drawn stream /ObjStm or /XRef, let a forged page validate as Valid even under DocMDP P=1. Decide an object's role (page content, signature, metadata, file plumbing) from what it was in the signed revision, not its new dictionary. (Security audit run-2: v3-082, v3-083.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(redact): remove redacted content from the saved file, fail closed otherwise

Apply Redactions and Remove Hidden Information left the originals behind: dropped or cleaned image/form XObjects kept their resource names (so the full rewrite saved the originals), an XFA form kept the redacted value in its datasets packet, and vertical-writing text under a mark was never hit by the horizontal removal geometry yet reported success. Prune replaced XObject names the page no longer draws, drop /XFA and /NeedsRendering when redacting an XFA form, and fail closed (RedactError::VerticalText) rather than silently leave vertical text. (Security audit run-2: v3-064, v3-067, v3-062.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ui-egui): trust the displayed signer, not the document's embedded root

The Signatures panel's one-click 'Add to trusted certificates' trusted the top of the chain the document supplied, installing the author's self-signed root as an anchor instead of the signer shown. Trust the displayed signer certificate. (Security audit run-2: v3-098.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Use main's decoded-size ceiling for PNGs too; keep the depth tests on main's checker

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 15:09:39 -04:00

758 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use crate::{Report, image};
use pdfcraft_fonts::pdf::Metrics;
const MAX_DEPTH: usize = 12;
#[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>>,
/// 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,
}
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 (and membership dictionaries) that are off; content
/// marked with them is removed.
pub hidden_layers: Vec<ObjRef>,
/// 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,
}
impl<'a> Scope<'a> {
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report) -> Self {
Scope {
rects,
mode,
report,
hidden_text: None,
hidden_layers: Vec::new(),
layer_blocks: 0,
fonts: HashMap::new(),
used_names: Vec::new(),
depth: 0,
}
}
/// Sanitizing visits every form XObject (not only those under a region).
fn everywhere(&self) -> bool {
self.hidden_text.is_some() || !self.hidden_layers.is_empty()
}
/// Is this optional-content reference (an OCG or OCMD) hidden?
fn layer_hidden(&self, doc: &Document, o: &Object) -> bool {
if self.hidden_layers.is_empty() {
return false;
}
if let Some(r) = o.as_ref()
&& self.hidden_layers.contains(&r)
{
return true;
}
// A membership dictionary (default policy AnyOn): hidden when all its groups are.
let d = doc.resolve(o);
let Some(d) = d.as_dict() else { return false };
if d.name(b"Type") != Some(b"OCMD") {
return false;
}
let groups: Vec<ObjRef> = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) {
Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(),
Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(),
None => Vec::new(),
};
!groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g))
}
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;
}
}
/// 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
/// surviving `Do` still draws (the same image shown again outside a mark) is kept.
pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec<u8>], content: &[Vec<u8>]) {
if replaced.is_empty() {
return;
}
let joined = Pieces::join(content);
let ops = joined.parse().ops;
let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.name(0) == Some(n));
for n in replaced {
if !still_drawn(n) {
xo.remove(n);
}
}
}
/// The clip that hides everything inside each region: one even-odd clip per region (a big
/// rectangle with the region cut out), in the current user space. Returns `None` when the CTM
/// can't be inverted (nothing is painted then anyway).
fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option<Vec<Op>> {
let inv = ctm.invert()?;
let mut ops = Vec::new();
let big = [around[0] - 10.0, around[1] - 10.0, around[2] + 10.0, around[3] + 10.0];
for r in rects.iter().filter(|r| overlaps(**r, around, 0.0)) {
let outer = [(big[0], big[1]), (big[2], big[1]), (big[2], big[3]), (big[0], big[3])];
let inner = [(r[0], r[1]), (r[0], r[3]), (r[2], r[3]), (r[2], r[1])];
for poly in [outer, inner] {
for (k, (x, y)) in poly.iter().enumerate() {
let (u, v) = inv.apply(*x, *y);
ops.push(Op::new(if k == 0 { "m" } else { "l" }, vec![num(u), num(v)]));
}
ops.push(Op::new("h", vec![]));
}
ops.push(Op::new("W*", vec![]));
ops.push(Op::new("n", vec![]));
}
Some(ops)
}
/// 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 mut out = Output::default();
let fallback = Rc::new(Metrics::fallback());
let mut gs = Gs {
ctm,
font: fallback.clone(),
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,
};
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 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();
if !scope.hidden_layers.is_empty() {
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);
}
}
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;
if scope.covered(b) {
scope.report.paths_removed += 1;
if clips {
// Keep the clipping, drop the painting.
ops.extend(body);
ops.push(Op::new("n", vec![]));
}
} else if clips {
// A clip-and-paint path can't be wrapped in q/Q without losing its clip:
// paint it clipped, then set its clip with a no-op path.
scope.report.paths_clipped += 1;
if let Some(c) = clip_out(scope.rects, &gs.ctm, b) {
let plain: Vec<Op> = body.iter().filter(|p| !p.is("W") && !p.is("W*")).cloned().collect();
ops.push(Op::new("q", vec![]));
ops.extend(c);
ops.extend(plain);
ops.push(op);
ops.push(Op::new("Q", vec![]));
}
ops.extend(body);
ops.push(Op::new("n", vec![]));
} else {
scope.report.paths_clipped += 1;
if let Some(c) = clip_out(scope.rects, &gs.ctm, b) {
ops.push(Op::new("q", vec![]));
ops.extend(c);
ops.extend(body);
ops.push(op);
ops.push(Op::new("Q", vec![]));
}
}
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"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 {
b"TJ" => op.operands.first().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);
}
out.streams.push(changed.then(|| serialize_ops(&ops)));
}
out
}
/// 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.name(0)?.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.layer_hidden(doc, oc)) => {
if scope.mode == Mode::Verify {
return None;
}
scope.layer_blocks += 1;
out.replaced.push(name);
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);
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);
scope.report.images_cleared += 1;
Some(Some(Op::new("Do", vec![Object::Name(n)])))
}
Err(()) => {
scope.report.images_removed += 1;
out.replaced.push(name);
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;
}
if scope.depth >= MAX_DEPTH {
if scope.mode == Mode::Verify {
return None;
}
scope.report.forms_removed += 1;
out.replaced.push(name);
return Some(None);
}
let data = match s.decoded() {
Ok(d) => d,
Err(_) if scope.mode == Mode::Apply => {
scope.report.forms_removed += 1;
out.replaced.push(name);
return Some(None);
}
Err(_) => 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(doc, scope, &[data], &res, fctm);
scope.depth -= 1;
scope.fonts = saved_fonts;
out.residue += inner.residue;
out.vertical |= inner.vertical;
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");
// Rebuild the copy's /Resources whenever inner content gained or lost an XObject, so a
// `Do` the copy no longer makes stops keeping the original image or form reachable.
if !inner.xobjects.is_empty() || !inner.replaced.is_empty() {
let mut res = res;
let mut xo = res_dict(doc, &res, b"XObject");
if let Some(d) = &new_data {
prune(&mut xo, &inner.replaced, std::slice::from_ref(d));
}
for (n, r) in &inner.xobjects {
xo.set(n.clone(), Object::Ref(*r));
}
res.set(b"XObject".to_vec(), Object::Dict(xo));
dict.set(b"Resources".to_vec(), Object::Dict(res));
}
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));
let n = fresh_name(scope, xobjects);
xobjects.set(n.clone(), Object::Ref(nr));
out.xobjects.push((n.clone(), nr));
out.replaced.push(name);
scope.report.forms_rewritten += 1;
Some(Some(Op::new("Do", vec![Object::Name(n)])))
}
_ => None,
}
}
/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks
/// included). Returns the remaining operators and how many blocks went.
fn strip_hidden_layers(doc: &Document, scope: &Scope<'_>, ops: Vec<Op>, props: &Dict) -> (Vec<Op>, usize) {
let mut out = Vec::with_capacity(ops.len());
// For each open marked-content block: does it hide its contents?
let mut stack: Vec<bool> = Vec::new();
let mut removed = 0;
for op in ops {
let hidden_now = stack.last().copied().unwrap_or(false);
match op.op.as_slice() {
b"BDC" | b"BMC" => {
let this = op.is("BDC")
&& op.name(0) == Some(b"OC")
&& match op.operands.get(1) {
Some(Object::Name(n)) => props.get(n).is_some_and(|o| scope.layer_hidden(doc, o)),
Some(o) => scope.layer_hidden(doc, o),
None => false,
};
if this && !hidden_now {
removed += 1;
}
stack.push(hidden_now || this);
if !(hidden_now || this) {
out.push(op);
}
}
b"EMC" => {
let was = stack.pop().unwrap_or(false);
if !was {
out.push(op);
}
}
_ => {
if !hidden_now {
out.push(op);
}
}
}
}
(out, removed)
}