//! 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; 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, /// 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>>, /// New XObjects the rewritten content refers to: (resource name, object). pub xobjects: Vec<(Vec, 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>, /// 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, /// 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, /// 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, HashSet)>, pub error: Option, } 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, /// 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, /// Cache membership results so repeated blocks cannot repeatedly walk hostile expressions. ocmd_cache: HashMap, /// 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, /// Content removed from hidden layers (blocks and XObjects). pub layer_blocks: usize, fonts: HashMap, Rc>, used_names: Vec>, 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 } /// Is this optional-content reference (an OCG or OCMD) hidden? fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool { if !self.layers_enabled { return false; } let reference = o.as_ref(); if let Some(r) = reference { if let Some(hidden) = self.ocmd_cache.get(&r) { return *hidden; } if self.hidden_layers.contains(&r) { self.ocmd_cache.insert(r, true); return true; } } // Evaluate the policy against the same default configuration used to select hidden // groups. A membership dictionary's default policy is AnyOn. 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 = 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(), }; let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g)); let hidden = if let Some(expression) = d.get(b"VE") { let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096); match visible { Some(visible) => !visible, None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096), } } else if groups.is_empty() { false } else { let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g)); let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g)); let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec())); match (d.get(b"P"), policy.as_deref()) { (None, _) | (Some(_), Some(b"AnyOn")) => !any_on, (Some(_), Some(b"AllOn")) => !all_on, (Some(_), Some(b"AnyOff")) => all_on, (Some(_), Some(b"AllOff")) => any_on, // An invalid policy must not make a candidate hidden block survive sanitization. _ => fail_closed(), } }; if !hidden { self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied()); if let Some(expression) = d.get(b"VE") { let mut referenced = HashSet::new(); if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) { self.retained_off_layers.extend(referenced); } else { // If an expression cannot be fully walked, preserve every off group so that // the content we kept does not change visibility after configuration cleanup. self.retained_off_layers.extend(self.hidden_layers.iter().copied()); } } } if let Some(r) = reference { self.ocmd_cache.insert(r, hidden); } hidden } fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool { if oc.as_ref().is_some() { return self.layer_hidden(doc, oc); } if let Some(hidden) = self.xobject_oc_cache.get(&xobject) { return *hidden; } let hidden = self.layer_hidden(doc, oc); self.xobject_oc_cache.insert(xobject, hidden); hidden } fn collect_hidden_expression_groups( doc: &Document, expression: &Object, hidden_layers: &HashSet, depth: usize, path: &mut HashSet, budget: &mut usize, found: &mut HashSet, ) -> bool { if depth > 64 || *budget == 0 { return false; } *budget -= 1; let reference = expression.as_ref(); if let Some(r) = reference && !path.insert(r) { return false; } let resolved = doc.resolve(expression); let complete = match &*resolved { Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => { if let Some(r) = reference && hidden_layers.contains(&r) { found.insert(r); } reference.is_some() } Object::Array(items) => { let Some(Object::Name(operator)) = items.first() else { return false }; let operands = match operator.as_slice() { b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..), b"Not" if items.len() == 2 => items.get(1..), _ => None, }; operands.is_some_and(|operands| { operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found)) }) } _ => false, }; if let Some(r) = reference { path.remove(&r); } complete } /// Evaluate an optional-content visibility expression (`/VE`) against the default config. /// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed. fn visibility_expression( &self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize, ) -> Option { if depth > 64 || *budget == 0 { return None; } *budget -= 1; let reference = expression.as_ref(); if let Some(r) = reference && !path.insert(r) { return None; } let resolved = doc.resolve(expression); let result = match &*resolved { Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)), Object::Array(items) if (2..=4097).contains(&items.len()) => { let operator = match items.first() { Some(Object::Name(n)) => n.as_slice(), _ => return None, }; match operator { b"And" if items.len() >= 2 => { let mut visible = true; for item in items.iter().skip(1) { visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?; } Some(visible) } b"Or" if items.len() >= 2 => { let mut visible = false; for item in items.iter().skip(1) { visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?; } Some(visible) } b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?), _ => None, } } _ => None, }; if let Some(r) = reference { path.remove(&r); } result } /// Conservatively find hidden groups in an expression that could not be evaluated. Unknown /// syntax, cycles, and exhausted budgets count as a possible hidden reference. fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize) -> bool { if depth > 64 || *budget == 0 { return true; } *budget -= 1; if let Some(r) = expression.as_ref() { if self.hidden_layers.contains(&r) { return true; } if !path.insert(r) { return true; } } let resolved = doc.resolve(expression); let found = match &*resolved { Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)), Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)), Object::Dict(_) => true, _ => false, }; if let Some(r) = expression.as_ref() { path.remove(&r); } found } 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 { 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], content: &[Vec]) { 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); } } } /// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or /// through a form that has no /Resources of its own (such a form draws through its caller's). /// `None` when that cannot be told (a form that will not decode, or nesting past the limit). fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet) -> Option { let joined = Pieces::join(content); let ops = joined.parse().ops; for op in ops.iter().filter(|op| op.is("Do")) { let Some(n) = op.name(0) else { continue }; if n == name { return Some(true); } let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { continue; } if depth >= MAX_DEPTH { return None; } let Ok(data) = s.decoded() else { return None }; match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { Some(false) => {} other => return other, } } Some(false) } /// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource /// dictionary that still names them. The rewritten page has its own pruned /Resources, but the /// originals stay reachable, and so stay in a full-rewrite save, when: /// - the page inherited its /Resources from a /Pages node, which still lists them; /// - several pages share one resource dictionary, and another page still refers to it; /// - a form without /Resources drew them through the page's dictionary. /// /// An entry goes unless a page that draws through that dictionary still draws the name (the same /// image shown on another page, or again outside a mark). A dictionary that loses an entry is /// replaced by a private copy on its node, so a dictionary object shared with other nodes is /// left as it is for them. Retired properties are likewise unlinked only when none of the /// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { if doomed.is_empty() && retired_properties.is_empty() { return Ok(()); } let doomed: HashSet = doomed.iter().copied().collect(); let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) }; // Every page-tree node with a /Resources of its own, and the pages that draw through it // (the nearest such node above a page, or the page itself). Cycles are cut as in // `pdfcraft_model::pages`. let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); let mut seen = HashSet::new(); let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; while let Some((node, mut holder)) = stack.pop() { if !seen.insert(node) || seen.len() > 1_000_000 { continue; } let obj = doc.get(node); let Some(d) = obj.as_dict() else { continue }; if d.contains(b"Resources") { holders.push((node, Vec::new())); holder = Some(holders.len() - 1); } let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); if is_pages { let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { h.1.push(node); } } let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); for (node, users) in holders { let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue; }; let mut xo = res_dict(doc, &res, b"XObject"); let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); let properties = res_dict(doc, &res, b"Properties"); let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); if names.is_empty() && property_names.is_empty() { continue; } let mut contents = Vec::with_capacity(users.len()); for user in &users { let page = doc.get(*user).as_dict().cloned().unwrap_or_default(); // A page whose content cannot be read may still draw the name: fail closed. contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1); } let mut pruned = false; for n in names { let mut drawn = Some(false); for content in &contents { drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new()); if drawn != Some(false) { break; } } match drawn { Some(true) => {} Some(false) => { xo.remove(&n); pruned = true; } None => return Err(RedactError::Residue(1)), } } if pruned { res.set(b"XObject".to_vec(), Object::Dict(xo)); } pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; if pruned { doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; } } // A surviving form can own the same old resource dictionary as a rewritten page. Its // unused descriptions and doomed XObjects remain reachable too, even after every page // holder is cleaned. unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?; Ok(()) } /// Retire only properties removed from a rewritten scope, preserving names still used by any /// content sharing this holder. Different pages' streams must never be concatenated together. fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], contents: &[Vec>]) -> Result { if names.is_empty() { return Ok(false); } let mut used = HashSet::new(); for content in contents { for op in resource_ops(doc, res, content)? { if matches!(op.op.as_slice(), b"BDC" | b"DP") && let Some(name) = op.name(1) { used.insert(name.to_vec()); } } } let mut properties = res_dict(doc, res, b"Properties"); let mut pruned = false; for name in names { if !used.contains(name) { pruned |= properties.remove(name).is_some(); } } if pruned { if properties.is_empty() { res.remove(b"Properties"); } else { res.set(b"Properties".to_vec(), Object::Dict(properties)); } } Ok(pruned) } /// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from /// the page-tree `holders`, each unless the form's own content (or a form inheriting its /// resources) still uses the name. Fails closed when that cannot be determined. fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet, retired: &[Object]) -> Result<(), CosError> { if retired.is_empty() && doomed.is_empty() { return Ok(()); } let mut pending: Vec = holders .iter() .filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned())) .collect(); let mut seen = HashSet::new(); while let Some(resources) = pending.pop() { for (_, value) in res_dict(doc, &resources, b"XObject").iter() { let Some(reference) = value.as_ref() else { continue }; if !seen.insert(reference) { continue; } if seen.len() > 1_000_000 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }); } let object = doc.get(reference); let Object::Stream(form) = &*object else { continue }; if form.dict.name(b"Subtype") != Some(b"Form") { continue; } let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; let names: Vec> = res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); let mut xo = res_dict(doc, &res, b"XObject"); let doomed_names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); if names.is_empty() && doomed_names.is_empty() { pending.push(res); continue; } let data = form.decoded()?; let mut pruned = false; for name in doomed_names { match content_draws(doc, std::slice::from_ref(&data), &xo, &name, 0, &mut HashSet::new()) { Some(true) => {} Some(false) => pruned |= xo.remove(&name).is_some(), None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), } } if pruned { res.set(b"XObject".to_vec(), Object::Dict(xo)); } pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?; if pruned { doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; } pending.push(res); } } Ok(()) } /// 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> { 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) } fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { let parsed = Pieces::join(content).parse(); if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); } let mut ops = parsed.ops; let xobjects = res_dict(doc, res, b"XObject"); let mut seen = HashSet::new(); let mut cursor = 0; while cursor < ops.len() { let op = &ops[cursor]; if op.is("Do") && let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) && seen.insert(r) && let Object::Stream(s) = &*doc.get(r) && s.dict.name(b"Subtype") == Some(b"Form") && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) { let parsed = pdfcraft_content::parse(&s.decoded()?); if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); } ops.extend(parsed.ops); } cursor += 1; } Ok(ops) } pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { let mut properties = res_dict(doc, res, b"Properties"); if properties.is_empty() { return Ok(Vec::new()); } let mut retired = Vec::new(); let ops = resource_ops(doc, res, content)?; let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); for name in names { if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) && let Some(value) = properties.remove(&name) { retired.push(value); } } if properties.is_empty() { res.remove(b"Properties"); } else { res.set(b"Properties".to_vec(), Object::Dict(properties)); } Ok(retired) } fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { let ops = resource_ops(doc, res, content)?; let mut xo = res_dict(doc, res, b"XObject"); let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); for name in names { if !ops.iter().any(|op| op.is("Do") && op.name(0) == Some(&name)) { xo.remove(&name); } } res.set(b"XObject".to_vec(), Object::Dict(xo)); Ok(()) } /// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf` /// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the /// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte). fn extgstate_font( doc: &Document, scope: &mut Scope<'_>, ext_gstates: &Dict, name: &[u8], fallback: &Rc, ) -> Option<(Rc, bool, f64)> { let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?; let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?; let [font, size] = entry.as_slice() else { return None }; let size = doc.resolve(size).as_f64()?; let font_dict = doc.resolve(font).as_dict().cloned(); let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); let key = [b"\0".as_slice(), name].concat(); let metrics = match scope.fonts.get(&key) { Some(m) => m.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(key, m.clone()); m } }; Some((metrics, vertical, size)) } /// One glyph of a shown string. struct Glyph { bytes: std::ops::Range, /// 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], 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], resources: &Dict, mut gs: Gs) -> Output { let mut out = Output::default(); let fallback = Rc::new(Metrics::fallback()); let mut stack: Vec = Vec::new(); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let fonts_res = res_dict(doc, resources, b"Font"); let ext_gstates = res_dict(doc, resources, b"ExtGState"); let mut xobjects = res_dict(doc, resources, b"XObject"); // The current path: its operators, its bounding box in user space, and whether it clips. let mut path: Vec = Vec::new(); let mut path_box: Option<[f64; 4]> = None; let mut clip = false; let mut forced_change = false; // 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> = streams.iter().map(|_| Vec::new()).collect(); let mut rewrite: Vec = 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 = Vec::with_capacity(piece.len()); for op in piece { let o = op.op.as_slice(); // Path construction. if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { let pts: Vec<(f64, f64)> = match o { b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), b"h" | b"W" | b"W*" => Vec::new(), _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), }; for (x, y) in pts { let (u, v) = gs.ctm.apply(x, y); path_box = Some(match path_box { None => [u, v, u, v], Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], }); } clip |= matches!(o, b"W" | b"W*"); path.push(op); continue; } if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { let body = std::mem::take(&mut path); let bbox = path_box.take(); let clips = std::mem::replace(&mut clip, false); let hit = scope.mode == Mode::Apply && o != b"n" && bbox.is_some_and(|b| scope.hits(b)); if !hit { ops.extend(body); ops.push(op); continue; } let b = bbox.unwrap_or_default(); changed = true; 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 = 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"gs" => { if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { (gs.font, gs.vertical, gs.size) = (font, vertical, size); } } b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, b"TL" => gs.leading = op.num(0).unwrap_or(0.0), b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, b"Td" | b"TD" => { if let Some([x, y]) = op.nums::<2>() { if o == b"TD" { gs.leading = -y; } tlm = Matrix::translate(x, y).then(&tlm); tm = tlm; } } b"Tm" => { if let Some(m) = op.nums::<6>() { tlm = Matrix(m); tm = tlm; } } b"T*" => { tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); tm = tlm; } b"Tj" | b"TJ" | b"'" | b"\"" => { if o == b"\"" { gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); } if matches!(o, b"'" | b"\"") { tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); tm = tlm; } let items: Vec = match o { // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still // takes the one immediately before it, as the renderer and every other // reader here do (#3569), so a redaction search matches what is drawn. b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), _ => op.operands.last().cloned().into_iter().collect(), }; // Vertical-writing text is laid out horizontally by `show`, so its glyphs are // never matched against a region. Rather than ship a document whose vertical // text was silently left under a mark, record it so apply can fail closed. // (Full vertical-text removal is a future enhancement.) if gs.vertical && scope.mode == Mode::Apply && scope.hidden_text.is_none() && !scope.rects.is_empty() && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) { out.vertical = true; } let (new_items, removed) = show(scope, &gs, &mut tm, &items); if removed == 0 { ops.push(op); continue; } if scope.mode == Mode::Verify { out.residue += removed; ops.push(op); continue; } changed = true; scope.report.glyphs += removed; match o { b"\"" => { ops.push(Op::new("Tw", vec![num(gs.word_spacing)])); ops.push(Op::new("Tc", vec![num(gs.char_spacing)])); ops.push(Op::new("T*", vec![])); } b"'" => ops.push(Op::new("T*", vec![])), _ => {} } ops.push(Op::new("TJ", vec![Object::Array(new_items)])); continue; } b"BI" => { let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); if scope.hits(b) { if scope.mode == Mode::Verify { out.residue += 1; } else { scope.report.images_removed += 1; changed = true; continue; } } } b"sh" if scope.mode == Mode::Apply => { // A shading fills the current clip: clip the regions out. if let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) { changed = true; ops.push(Op::new("q", vec![])); ops.extend(c); ops.push(op); ops.push(Op::new("Q", vec![])); continue; } } b"Do" => { if let Some(new) = xobject(doc, scope, &op, &gs, resources, &mut xobjects, &mut out) { changed = true; if let Some(n) = new { ops.push(n); } continue; } } _ => {} } ops.push(op); } 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, bool)], report: &mut Report) { let originals: HashMap = original.iter().map(|op| (op.span.start, op)).collect(); let unchanged: HashSet = 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, usize) { let f = &gs.font; let size = gs.size; let mut out: Vec = Vec::new(); let mut removed = 0; let push_num = |out: &mut Vec, 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 = 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> { 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.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 = 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 { // 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 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, } } /// 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: &mut Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { let mut out = Vec::with_capacity(ops.len()); // For each open marked-content block: does it hide its contents? let mut stack: Vec = 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) }