Merge pull request #6927 from Piohrun/fix/redact-marked-content-last-operands

M8: redaction reads an operator's operands from the end, as the renderer does
This commit is contained in:
FredApps authored and GitHub committed 2026-11-11 14:38:05 +01:00
commit 203c4fd8fa
14 files changed
+693 -48

No files matched your search

+17
View File
@@ -58,6 +58,23 @@ impl Op {
pub fn last_name(&self) -> Option<&[u8]> {
self.operands.last().and_then(Object::as_name)
}
/// Operand `k` counted from the end (0 is the last), as the renderer reads an operator's
/// operands: `(x) /OC /MC0 BDC` marks with `/OC /MC0`, and `(x) /F1 10 Tf` selects `/F1`.
pub fn back(&self, k: usize) -> Option<&Object> {
let i = self.operands.len().checked_sub(k)?.checked_sub(1)?;
self.operands.get(i)
}
/// [`Op::back`] as a name.
pub fn name_back(&self, k: usize) -> Option<&[u8]> {
self.back(k).and_then(Object::as_name)
}
/// [`Op::back`] as a number.
pub fn num_back(&self, k: usize) -> Option<f64> {
self.back(k).and_then(Object::as_f64)
}
}
/// A parsed stream.
+11
View File
@@ -10,6 +10,17 @@ thread_local! {
pub(crate) static PIECE_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[test]
fn operands_are_counted_from_the_end() {
let op = parse(b"(x) /OC /MC0 BDC").ops.remove(0);
assert_eq!((op.name_back(1), op.name_back(0)), (Some(&b"OC"[..]), Some(&b"MC0"[..])));
assert_eq!(op.back(2).and_then(Object::as_string).map(|s| s.bytes.clone()), Some(b"x".to_vec()));
assert_eq!((op.back(3), op.back(usize::MAX)), (None, None));
let op = parse(b"/F1 10 Tf").ops.remove(0);
assert_eq!((op.name_back(1), op.num_back(0), op.num_back(1)), (Some(&b"F1"[..]), Some(10.0), None));
assert_eq!(Op::new("Q", vec![]).back(0), None);
}
#[test]
fn splicing_visits_each_piece_a_few_times() {
let n = 5_000;
+17 -7
View File
@@ -571,7 +571,12 @@ impl Walk {
Some(op)
}
b"cs" | b"CS" => {
if op.name(0) != Some(b"Pattern") {
// A Pattern space is told by the name `/Pattern` alone: one named through the
// resources' `/ColorSpace` clears the pattern, which the app's renderer keeps and
// Poppler doesn't (`cs` resets the colour, ISO 32000-2 §8.6.8). Looking the name up
// at each `cs` wants a cache for the page: a `/ColorSpace` too large for the
// document to keep is parsed again at each read.
if op.last_name() != Some(b"Pattern") {
if o == b"cs" {
self.gs.fill_pattern = None;
} else {
@@ -598,8 +603,12 @@ impl Walk {
let o = op.op.as_slice();
let pts: Vec<(f64, f64)> = match o {
b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(),
b"h" | b"W" | b"W*" => Vec::new(),
_ => op.operands.iter().filter_map(Object::as_f64).collect::<Vec<_>>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(),
// Each takes its points from the end, as the renderer reads them: a surplus before
// them is ignored, not read as a point.
b"m" | b"l" => op.nums::<2>().map(|[x, y]| vec![(x, y)]).unwrap_or_default(),
b"c" => op.nums::<6>().map(|[x1, y1, x2, y2, x3, y3]| vec![(x1, y1), (x2, y2), (x3, y3)]).unwrap_or_default(),
b"v" | b"y" => op.nums::<4>().map(|[x1, y1, x2, y2]| vec![(x1, y1), (x2, y2)]).unwrap_or_default(),
_ => Vec::new(),
};
for (x, y) in pts {
let (u, v) = self.gs.ctm.apply(x, y);
@@ -656,10 +665,10 @@ impl Walk {
}
b"Tf" => self.select_font(doc, scope, op),
_ => {
if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) {
if let Some((font, vertical, size)) = op.last_name().and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) {
(self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size);
// As for `Tf`: a Type 3 font without /Resources paints through this scope's.
self.gs.type3_inherits = op.name(0).is_some_and(|n| extgstate_type3_inherits(doc, &self.ext_gstates, n));
self.gs.type3_inherits = op.last_name().is_some_and(|n| extgstate_type3_inherits(doc, &self.ext_gstates, n));
}
}
}
@@ -864,7 +873,8 @@ fn clean_replacements(
if !op.is("BDC") || !affected.contains(&op.span.start) {
continue;
}
let property = match op.operands.get(1) {
// The property list is the last operand, as the renderer reads it.
let property = match op.back(0) {
Some(Object::Name(name)) => properties.get(name).map(|p| doc.resolve(p)),
Some(p) => Some(doc.resolve(p)),
None => None,
@@ -874,7 +884,7 @@ fn clean_replacements(
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) {
if stripped && let Some(property) = op.operands.last_mut() {
*property = Object::Dict(dict);
*changed = true;
report.replacement_text += 1;
+4 -2
View File
@@ -225,8 +225,10 @@ pub(super) fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Ve
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) {
// The tag and the property list are the last two operands, as the renderer
// reads them: `(x) /OC /MC0 BDC` is a layer's block too.
&& op.name_back(1) == Some(b"OC")
&& match op.back(0) {
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,
+2 -2
View File
@@ -358,7 +358,7 @@ fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec<u8>], c
// Each is one page's content or one form's, with the allowance of a page.
for op in resource_ops(doc, &mut DecodeBudget::page(), res, content)? {
if matches!(op.op.as_slice(), b"BDC" | b"DP")
&& let Some(name) = op.name(1)
&& let Some(name) = op.last_name()
{
used.insert(name.to_vec());
}
@@ -522,7 +522,7 @@ pub(crate) fn prune_properties(doc: &Document, budget: &mut DecodeBudget, res: &
let ops = resource_ops(doc, budget, res, content)?;
let names: Vec<Vec<u8>> = 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))
if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.last_name() == Some(&name))
&& let Some(value) = properties.remove(&name)
{
retired.push(value);
+23 -9
View File
@@ -35,6 +35,14 @@ impl Marked {
/// left are taken as changed (their descriptions go) rather than compared.
const MAX_COMPARED: usize = 1 << 28;
/// `f` of `o`, or of the object it refers to, without copying a direct one.
fn with_held<R>(doc: &Document, o: &Object, f: impl FnOnce(&Object) -> R) -> R {
match o {
Object::Ref(_) => f(&doc.resolve(o)),
o => f(o),
}
}
fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> Marked {
let mut out = Marked::default();
// Open sequences: the MCID with where its content starts in `ops` and the drawing operators
@@ -49,14 +57,22 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> Marked {
e.1 |= drawn > drawn_before;
}
};
// The named property lists, read once, and each name's MCID, read once: a stream can name one
// at every `BDC` of thousands, and reading an object again can parse it again, all of it (the
// document keeps an object larger than its cache only until it reads another). A direct one
// is read where it is.
let held = resources.get(b"Properties").filter(|p| matches!(p, Object::Ref(_))).map(|p| doc.resolve(p));
let named = held.as_deref().or_else(|| resources.get(b"Properties")).and_then(Object::as_dict);
let mut mcids: HashMap<Vec<u8>, Option<i64>> = HashMap::new();
for op in pdfcraft_content::parse(data).ops {
match op.op.as_slice() {
b"BDC" => {
let id = match op.operands.get(1) {
Some(Object::Name(n)) => resources.get(b"Properties").and_then(|p| {
let props = doc.resolve(p);
let prop = doc.resolve(props.as_dict()?.get(n)?);
prop.as_dict()?.int(b"MCID")
// The property list is the last operand, as the renderer reads it.
let id = match op.back(0) {
Some(Object::Name(n)) => *mcids.entry(n.to_vec()).or_insert_with(|| {
let prop = named?.get(n)?;
// A named property list may give its MCID by reference.
with_held(doc, prop, |prop| prop.as_dict()?.get(b"MCID").and_then(|id| with_held(doc, id, Object::as_int)))
}),
Some(p) => doc.resolve(p).as_dict().and_then(|d| d.int(b"MCID")),
_ => None,
@@ -86,9 +102,7 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> Marked {
}
let draws = match op.op.as_slice() {
b"Tj" | b"'" | b"\"" => op.operands.last().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty()),
b"TJ" => {
op.operands.first().and_then(Object::as_array).is_some_and(|a| a.iter().any(|x| x.as_string().is_some_and(|s| !s.bytes.is_empty())))
}
b"TJ" => op.back(0).and_then(Object::as_array).is_some_and(|a| a.iter().any(|x| x.as_string().is_some_and(|s| !s.bytes.is_empty()))),
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"sh" | b"Do" | b"BI" => true,
_ => false,
};
@@ -139,7 +153,7 @@ fn mcid_of(doc: &Document, o: &Object, page: Option<ObjRef>, target: ObjRef, str
Object::Dict(m) if m.name(b"Type") == Some(b"MCR") => {
let pg = m.get(b"Pg").and_then(Object::as_ref).or(page);
let stm = m.get(b"Stm").and_then(Object::as_ref);
(pg == Some(target) && stm == stream.map(|s| s.0)).then(|| m.int(b"MCID")).flatten()
(pg == Some(target) && stm == stream.map(|s| s.0)).then(|| m.get(b"MCID").and_then(|id| doc.resolve(id).as_int())).flatten()
}
_ => None,
}
+1
View File
@@ -6238,6 +6238,7 @@ mod inline_images;
mod limits;
mod metadata_fail_closed;
mod metadata_matching;
mod operands_from_the_end;
mod property_retirement;
mod surviving_references;
mod text_state;
@@ -0,0 +1,340 @@
//! An operator takes its operands from the end, as the renderer reads them, and a surplus before
//! them is ignored: `(x) /OC /MC0 BDC` marks a layer's block, `(x) /GS1 gs` selects `/GS1`. Read
//! from the front, the surplus stood in for the operands, and what they named went unseen.
use super::*;
/// Hidden-layer text in a block whose tag and property list follow a string: Remove Hidden
/// Information found nothing to remove, and the text stayed.
#[test]
fn a_hidden_layers_block_after_a_surplus_operand_is_removed() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = one_page(
b"(x) /OC /MC0 BDC BT /F1 10 Tf 20 260 Td (LAYERSECRET) Tj ET EMC BT /F1 10 Tf 20 20 Td (PUBLIC) Tj ET",
"/Properties << /MC0 7 0 R >>",
vec![b"<< /Type /OCG /Name (Hidden) >>".to_vec()],
);
doc.update_dict(ObjRef::new(1, 0), |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])),
(b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))]))]))),
])),
);
})
.unwrap();
let removed = remove_hidden(&mut doc, &[Hidden::HiddenLayers]);
assert!(removed.as_ref().is_ok_and(|r| r.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0)), "{removed:?}");
let c = content(&doc, 0);
assert!(!c.contains("LAYERSECRET") && c.contains("PUBLIC"), "{c}");
}
/// Paint under a mark named by `/ActualText` in a block after a surplus operand: the name, which
/// would still read the word, kept it.
#[test]
fn actual_text_after_a_surplus_operand_goes_with_the_paint_it_names() {
let mut doc = one_page(b"(x) /Span <</ActualText (SECRET)>> BDC 100 100 m 160 100 l 160 120 l 100 120 l h f EMC", "", vec![]);
mark(&mut doc, 0, &[[90.0, 95.0, 130.0, 125.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.paths_removed, 1);
let c = content(&doc, 0);
assert!(!c.contains("SECRET"), "{c}");
}
/// A tagged paragraph whose `MCID` follows a surplus operand: its structure element kept the
/// removed text in `/ActualText` and `/Alt`.
#[test]
fn a_tag_after_a_surplus_operand_loses_what_redaction_removed() {
let content = b"(x) /P <</MCID 0>> BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC";
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R /MarkInfo << /Marked true >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
stream("", content),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Type /StructTreeRoot /K 8 0 R >>".to_vec(),
b"<< /S /Document /P 7 0 R /K [9 0 R] >>".to_vec(),
b"<< /S /P /P 8 0 R /Pg 3 0 R /ActualText (SECRET) /Alt (the secret) /K 0 >>".to_vec(),
]);
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().tags, 1);
let secret = doc.get(ObjRef::new(9, 0)).as_dict().cloned().unwrap();
assert!(secret.get(b"ActualText").is_none() && secret.get(b"Alt").is_none(), "{secret:?}");
}
/// A property list still drawn with, named after a surplus operand, stays in the page's resources
/// when the redaction cleans them; read from the front, it was taken for unused and retired.
#[test]
fn a_property_list_named_after_a_surplus_operand_is_kept() {
let mut doc = one_page(
b"BT /F1 10 Tf 20 260 Td (SECRET) Tj ET (x) /Span /Pr1 BDC BT /F1 10 Tf 20 20 Td (KEPT) Tj ET EMC",
"/Properties << /Pr1 << /Lang (en-GB) >> >>",
vec![],
);
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
let page = pdfcraft_model::pages(&doc).swap_remove(0);
let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let props = res.get(b"Properties").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()).unwrap_or_default();
assert!(props.contains(b"Pr1"), "{res:?}");
}
/// A font set only by an ExtGState selected after a surplus operand: read from the front, no font
/// was set, every glyph stood at the start of the line with no size, and a mark over the end of
/// the word removed nothing.
#[test]
fn a_font_from_an_extgstate_selected_after_a_surplus_operand_places_the_text() {
let mut doc = one_page(b"BT (x) /GS1 gs 20 260 Td (SECRET) Tj ET", "/ExtGState << /GS1 << /Font [5 0 R 10] >> >>", vec![]);
// Over the last two glyphs, E and T at x = 40 to 50.
mark(&mut doc, 0, &[[42.0, 255.0, 55.0, 275.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 2);
}
/// `/Pattern cs` after a surplus operand, once the pattern's paint under the mark went: it selects
/// the Pattern space and keeps the pattern, so the fill after it still counts as painting with it
/// and the pattern stays. Read from the front, the surplus cleared the pattern, which was pruned.
/// A grey space clears it.
#[test]
fn a_pattern_space_selected_after_a_surplus_operand_keeps_the_pattern() {
for (space, pruned) in [("(x) /Pattern", 0), ("/Pattern", 0), ("(x) /DeviceGray", 1)] {
let mut doc = one_page(
format!("/Pattern cs /P0 scn 10 260 20 20 re f {space} cs 200 20 50 50 re f").as_bytes(),
"/Pattern << /P0 7 0 R >>",
vec![stream("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", b"0 0 5 5 re f")],
);
mark(&mut doc, 0, &[[0.0, 250.0, 40.0, 290.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!((r.paths_removed, r.patterns_removed), (1, pruned), "{space}");
}
}
/// A Type 3 font selected after a surplus operand, outside the mark, whose glyph selects an
/// ExtGState, a colour space, a property list and a font after one too: the cleanups after the
/// walk took the font for undrawn and those for unused. All stay, with the glyph.
#[test]
fn a_type3_font_and_its_glyphs_resources_named_after_a_surplus_operand_are_kept() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /T3 7 0 R >> >> >>".to_vec(),
stream("", b"BT /F1 10 Tf 20 260 Td (SECRET) Tj ET BT (x) /T3 12 Tf 20 20 Td (a) Tj ET"),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /a 8 0 R >> \
/Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] /Resources << /ExtGState << /GS0 << /CA 0.5 >> >> \
/ColorSpace << /CS0 /DeviceGray >> /Properties << /P0 << >> >> /Font << /F0 5 0 R >> >> >>"
.to_vec(),
stream("", b"10 0 d0 (x) /GS0 gs (x) /CS0 cs (x) /Span /P0 BDC 0 0 10 10 re f EMC BT (x) /F0 1 Tf ET"),
]);
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
let page = pdfcraft_model::pages(&doc).swap_remove(0);
let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let font = fonts.get(b"T3").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned());
let font = font.unwrap_or_else(|| panic!("the drawn Type 3 font stays: {fonts:?}"));
let procs = font.get(b"CharProcs").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()).unwrap_or_default();
assert!(procs.contains(b"a"), "its glyph stays: {font:?}");
let font_res = font.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let states = font_res.get(b"ExtGState").map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default();
assert!(states.contains(b"GS0"), "the ExtGState its glyph selects stays: {font_res:?}");
for (kind, name) in [(&b"ColorSpace"[..], &b"CS0"[..]), (b"Properties", b"P0"), (b"Font", b"F0")] {
let held = font_res.get(kind).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default();
assert!(held.contains(name), "the {} its glyph uses stays: {font_res:?}", String::from_utf8_lossy(name));
}
}
/// A tagged paragraph whose `TJ` after a surplus operand still draws once the mark has removed the
/// word beside it: read from the front, that `TJ` drew nothing, the paragraph was taken for empty,
/// and its structure element lost the content the page still shows.
#[test]
fn a_tagged_tj_after_a_surplus_operand_still_draws() {
let content = b"/P <</MCID 0>> BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj 10 -100 Td (x) [(KEPT)] TJ ET EMC";
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R /MarkInfo << /Marked true >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
stream("", content),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Type /StructTreeRoot /K 8 0 R >>".to_vec(),
b"<< /S /Document /P 7 0 R /K [9 0 R] >>".to_vec(),
b"<< /S /P /P 8 0 R /Pg 3 0 R /K 0 >>".to_vec(),
]);
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
let paragraph = doc.get(ObjRef::new(9, 0)).as_dict().cloned().unwrap();
assert_eq!(paragraph.get(b"K").and_then(Object::as_int), Some(0), "it still tags what the page shows: {paragraph:?}");
}
/// A path's points follow a surplus number: read from the front, each point was shifted by one,
/// so a square under the mark was placed beside it and kept, and one beside the mark was placed
/// under it and removed.
#[test]
fn a_paths_points_after_a_surplus_number_are_where_the_renderer_draws_them() {
let under = b"0 0 1 rg 9 100 100 m 9 140 100 l 9 140 140 l 9 100 140 l h f 9 100 100 m 9 140 140 l S".as_slice();
let mut doc = one_page(under, "", vec![]);
mark(&mut doc, 0, &[[90.0, 90.0, 150.0, 150.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 2, "both paths under the mark go");
let beside =
b"0 0 1 rg 0 200 200 m 250 200 l 250 250 l 200 250 l h f 0 200 200 m 210 210 220 220 230 230 c 240 240 250 250 v 200 250 200 260 y h f"
.as_slice();
let mut doc = one_page(beside, "", vec![]);
mark(&mut doc, 0, &[[0.0, 0.0, 20.0, 20.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 0, "the paths beside the mark stay");
// Curves whose points, after a surplus, reach under the mark.
for curve in [&b"200 200 m 9 10 10 12 12 15 15 c f"[..], b"200 200 m 9 10 10 15 15 v f", b"200 200 m 9 10 10 15 15 y f"] {
let mut doc = one_page(curve, "", vec![]);
mark(&mut doc, 0, &[[0.0, 0.0, 20.0, 20.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 1, "{}", String::from_utf8_lossy(curve));
}
// A line, and curves, from a point beside the mark whose points, after a surplus, stay beside
// it: read from the front, the surplus was a point under it.
for curve in [
&b"100 100 500 m 510 510 l S"[..],
b"500 500 m 100 100 510 l S",
b"100 500 m 100 100 500 110 500 120 500 c f",
b"100 500 m 100 110 500 120 500 v f",
b"100 500 m 100 110 500 120 500 y f",
] {
let mut doc = one_page(curve, "", vec![]);
mark(&mut doc, 0, &[[90.0, 90.0, 150.0, 150.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 0, "{}", String::from_utf8_lossy(curve));
}
}
/// An `MCID` given by reference: in a named property list, and in a structure element's `/MCR`.
/// Read directly, it was missed, and the element kept the removed text.
#[test]
fn an_mcid_given_by_reference_loses_what_redaction_removed() {
for (content, properties, kid) in [
(b"/P /MC0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC".as_slice(), "/Properties << /MC0 << /MCID 10 0 R >> >>", "0"),
(b"/P <</MCID 0>> BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC", "", "<< /Type /MCR /Pg 3 0 R /MCID 10 0 R >>"),
] {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R /MarkInfo << /Marked true >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> {properties} >> >>").into_bytes(),
stream("", content),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Type /StructTreeRoot /K 8 0 R >>".to_vec(),
b"<< /S /Document /P 7 0 R /K [9 0 R] >>".to_vec(),
format!("<< /S /P /P 8 0 R /Pg 3 0 R /ActualText (SECRET) /Alt (the SECRET) /K {kid} >>").into_bytes(),
b"0".to_vec(),
]);
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().tags, 1, "{properties}{kid}");
let secret = doc.get(ObjRef::new(9, 0)).as_dict().cloned().unwrap();
assert!(secret.get(b"ActualText").is_none() && secret.get(b"Alt").is_none(), "{secret:?}");
}
// An `/MCR` naming the same MCID in a form's stream (`/Stm`) is other content: it stays.
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R /MarkInfo << /Marked true >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
stream("", b"/P <</MCID 0>> BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC"),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Type /StructTreeRoot /K 8 0 R >>".to_vec(),
b"<< /S /Document /P 7 0 R /K [9 0 R] >>".to_vec(),
b"<< /S /P /P 8 0 R /Pg 3 0 R /ActualText (FORMTEXT) /K << /Type /MCR /Pg 3 0 R /Stm 11 0 R /MCID 10 0 R >> >>".to_vec(),
b"0".to_vec(),
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"/P <</MCID 0>> BDC EMC"),
]);
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
apply(&mut doc, None).unwrap();
let other = doc.get(ObjRef::new(9, 0)).as_dict().cloned().unwrap();
assert!(other.get(b"ActualText").is_some(), "{other:?}");
}
/// The resources of page `page`, resolved.
fn resources_of(doc: &Document, page: usize) -> Dict {
let page = pdfcraft_model::pages(doc).swap_remove(page);
page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default()
}
/// `dict`'s dictionary `key`, resolved.
fn dict_in(doc: &Document, dict: &Dict, key: &[u8]) -> Dict {
dict.get(key).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default()
}
/// A property list a second page sharing the resources names after a surplus operand, once the
/// first page's block that named it was redacted: read from the front, the second page's name went
/// unseen and the list was retired from under it.
#[test]
fn a_property_list_another_page_names_after_a_surplus_operand_is_kept() {
use pdfcraft_cos::Stream;
let mut doc = one_page(
b"/Span /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC",
"/Properties << /P0 7 0 R >>",
vec![b"<< /ActualText (SECRET) >>".to_vec()],
);
let shared = doc.add(Object::Dict(resources_of(&doc, 0)));
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap();
let mut second = doc.get(ObjRef::new(3, 0)).as_dict().cloned().unwrap();
let contents = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"(x) /Span /P0 BDC BT /F1 10 Tf 10 100 Td (Other) Tj ET EMC".to_vec())));
second.set(b"Contents".to_vec(), Object::Ref(contents));
let second = doc.add(Object::Dict(second));
doc.update_dict(ObjRef::new(2, 0), |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)]));
d.set(b"Count".to_vec(), Object::Int(2));
})
.unwrap();
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
apply(&mut doc, Some(&[0])).unwrap();
let properties = dict_in(&doc, &resources_of(&doc, 1), b"Properties");
assert!(properties.contains(b"P0"), "the second page's property list stays: {properties:?}");
}
/// A Type 3 font set only by an ExtGState's `/Font`, selected after a surplus operand: read from the
/// front, the cleanups took it for undrawn, dropped it from the page and unlinked its glyph.
#[test]
fn a_type3_font_an_extgstate_selects_after_a_surplus_operand_is_kept() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /T3 7 0 R >> /ExtGState << /GS1 << /Font [7 0 R 12] >> >> >> >>".to_vec(),
stream("", b"BT /F1 10 Tf 20 260 Td (SECRET) Tj ET BT (x) /GS1 gs 20 20 Td (a) Tj ET"),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /a 8 0 R /b 9 0 R >> \
/Encoding << /Differences [97 /a /b] >> /FirstChar 97 /LastChar 98 /Widths [10 10] >>"
.to_vec(),
stream("", b"10 0 d0 0 0 10 10 re f"),
stream("", b"10 0 d0 0 0 5 5 re f"),
]);
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
let fonts = dict_in(&doc, &resources_of(&doc, 0), b"Font");
assert!(fonts.contains(b"T3"), "the page still lists the font: {fonts:?}");
let font = doc.get(ObjRef::new(7, 0)).as_dict().cloned().unwrap_or_default();
let procs = dict_in(&doc, &font, b"CharProcs");
assert!(procs.contains(b"a"), "the glyph the page draws stays: {procs:?}");
}
/// A Type 3 font without resources of its own, set by an ExtGState selected after a surplus operand:
/// its glyph paints through the page's resources, so it may paint with the page's pattern, which
/// stays once the page's own fill with it was redacted (the text itself fills with grey). Read from the front, the font was taken
/// for one with resources, and the pattern was pruned.
#[test]
fn a_type3_font_without_resources_an_extgstate_selects_after_a_surplus_operand_keeps_the_pattern() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /Pattern << /P0 9 0 R >> /ExtGState << /GS1 << /Font [7 0 R 12] >> >> >> >>".to_vec(),
stream("", b"/Pattern cs /P0 scn 10 260 20 20 re f 0 g BT (x) /GS1 gs 150 20 Td (a) Tj ET"),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /a 8 0 R >> \
/Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] >>"
.to_vec(),
stream("", b"10 0 d0 /Pattern cs /P0 scn 0 0 10 10 re f"),
stream("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", b"0 0 5 5 re f"),
]);
mark(&mut doc, 0, &[[0.0, 250.0, 40.0, 290.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!((r.paths_removed, r.patterns_removed), (1, 0));
}
@@ -783,6 +783,37 @@ fn a_type3_font_only_a_soft_mask_without_resources_selects_stays_on_the_page() {
assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the soft mask selects");
}
/// The same soft mask, its ExtGState selected after a surplus operand (`(x) /GS1 gs`), which the
/// renderer ignores: read from the front, the cleanups never read the group, and dropped the font
/// it selects and its glyph.
#[test]
fn a_type3_font_only_a_soft_mask_selected_after_a_surplus_operand_selects_stays_on_the_page() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(),
stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET (x) /GS1 gs 150 150 100 100 re f"),
b"<< /Font << /T3 6 0 R /T4 7 0 R >> /ExtGState << /GS1 << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 8 0 R >> >> >> >>"
.to_vec(),
type3_abc(9),
type3_abc(12),
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Group << /S /Transparency /CS /DeviceGray >>",
b"1 g BT /T4 40 Tf 160 180 Td (B) Tj ET",
),
stream("", b"% REDACTED_GLYPH_A"),
stream("", b"% UNUSED_GLYPH_B"),
stream("", b"% PAGE_GLYPH_C"),
stream("", b"% UNUSED_T4_GLYPH_A"),
stream("", b"% MASK_GLYPH_B"),
stream("", b"% UNUSED_T4_GLYPH_C"),
]);
mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1);
assert!(full_save_holds(&doc, b"MASK_GLYPH_B"), "the glyph the soft mask shows was unlinked");
assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the soft mask selects");
}
/// The same group, its content behind a predictor whose `/DecodeParms` are given by reference:
/// the page's cleanup reads it through them, and keeps the font it selects.
#[test]
+113
View File
@@ -897,6 +897,119 @@ fn glyphs_are_placed_with_the_text_matrix_and_the_form_matrix() {
assert!(proof.entries[0].detail.contains("glyph(s) still show"));
}
/// The proof reads an operator's operands from the end, as the renderer does: a surplus before
/// them doesn't hide the text a `Tf`, `TJ` or `Do` after it draws, or the image a `Do` draws.
#[test]
fn the_proof_reads_operands_from_the_end() {
let blank = marked_content(b"BT /F1 10 Tf 10 200 Td (Public) Tj ET", Spec::default());
let snap = Snapshot::capture(&blank, None).unwrap();
let form = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>",
b"BT /F1 10 Tf 10 100 Td (SECRETVALUE) Tj ET",
);
let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"o");
let image_form =
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Im1 8 0 R >> >>", b"q 50 0 0 12 10 98 cm (x) /Im1 Do Q");
for (content, spec) in [
(&b"BT (x) /F1 10 Tf 10 100 Td (SECRETVALUE) Tj ET"[..], Spec::default()),
// The size: text starting outside the region reaches into it only at its size.
(b"BT (x) /F1 10 Tf -60 100 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET", Spec::default()),
(b"BT /F1 10 Tf 10 100 Td (x) [(SECRETVALUE)] TJ ET", Spec::default()),
(b"(x) /Fm1 Do", Spec { resources: "/XObject << /Fm1 7 0 R >>", extra: vec![form.clone()], ..Spec::default() }),
// An image under the mark, and one in a form under it.
(b"q 50 0 0 12 10 98 cm (x) /Im1 Do Q", Spec { resources: "/XObject << /Im1 7 0 R >>", extra: vec![image.clone()], ..Spec::default() }),
(b"(x) /Fm1 Do", Spec { resources: "/XObject << /Fm1 7 0 R >>", extra: vec![image_form.clone(), image.clone()], ..Spec::default() }),
] {
let mut doc = marked_content(content, spec);
doc.require_full_save();
let proof = snap.prove(&doc, None, &ProofOptions::default());
assert_eq!(proof.entries[0].status, Status::Failed, "{}: {}", String::from_utf8_lossy(content), proof.to_text());
}
}
/// The proof reads the text state's operands from the end too. In each case the text starts outside
/// the region and only the operand after the surplus brings a glyph into it: char spacing, word
/// spacing, horizontal scaling, leading with `T*`, rise, and the spacing `"` sets.
#[test]
fn the_proof_reads_the_text_states_operands_from_the_end() {
let blank = marked_content(b"BT /F1 10 Tf 10 200 Td (Public) Tj ET", Spec::default());
let snap = Snapshot::capture(&blank, None).unwrap();
for content in [
&b"BT /F1 10 Tf -60 100 Td (x) 70 Tc (AB) Tj ET"[..],
b"BT /F1 10 Tf -60 100 Td (x) 70 Tw (A B) Tj ET",
b"BT /F1 10 Tf -60 100 Td (x) 1500 Tz (AB) Tj ET",
b"BT /F1 10 Tf 10 200 Td (x) 100 TL T* (A) Tj ET",
b"BT /F1 10 Tf 10 200 Td (x) -100 Ts (A) Tj ET",
b"BT /F1 10 Tf -60 100 Td (x) 0 70 (AB) \" ET",
] {
let mut doc = marked_content(content, Spec::default());
doc.require_full_save();
let proof = snap.prove(&doc, None, &ProofOptions::default());
assert_eq!(proof.entries[0].status, Status::Failed, "{}: {}", String::from_utf8_lossy(content), proof.to_text());
}
}
/// The proof reads a path's points from the end too: a square under the region, its points after
/// a surplus number, still paints there.
#[test]
fn the_proof_reads_a_paths_points_from_the_end() {
let blank = marked_content(b"BT /F1 10 Tf 10 200 Td (Public) Tj ET", Spec::default());
let snap = Snapshot::capture(&blank, None).unwrap();
for content in [
&b"0 0 1 rg 9 20 98 m 9 60 98 l 9 60 108 l 9 20 108 l h f"[..],
b"0 0 1 rg 9 20 98 40 10 re f",
// Curves from a point outside the region whose own points reach into it.
b"0 0 1 rg 9 200 200 m 9 20 98 40 100 60 108 c h f",
b"0 0 1 rg 9 200 200 m 9 20 98 60 108 v h f",
b"0 0 1 rg 9 200 200 m 9 20 98 60 108 y h f",
// A segment after a surplus, from a point outside the region.
b"0 0 1 rg 200 200 m 200 20 100 l h f",
b"0 0 1 rg 200 200 m 200 20 100 30 100 40 100 c h f",
b"0 0 1 rg 200 200 m 200 20 100 40 100 v h f",
b"0 0 1 rg 200 200 m 200 20 100 40 100 y h f",
] {
let mut doc = marked_content(content, Spec::default());
doc.require_full_save();
let proof = snap.prove(&doc, None, &ProofOptions::default());
assert_eq!(proof.entries[0].status, Status::Failed, "{}: {}", String::from_utf8_lossy(content), proof.to_text());
}
}
/// Which font a `Tf` selects, and which strings a `TJ` shows, are read from the end too: text an
/// undecodable font shows after a surplus operand, away from the mark, leaves the region
/// unverifiable. Read from the front, no font was set, or no string was shown, and it passed.
#[test]
fn the_proof_reads_the_font_and_the_strings_from_the_end() {
for content in [&b"BT (x) /F2 10 Tf 10 200 Td <00410042> Tj ET"[..], b"BT /F2 10 Tf 10 200 Td (x) [<00410042>] TJ ET"] {
let mut doc = open(build_with(content, opaque_spec()));
mark(&mut doc, &[[150.0, 20.0, 200.0, 40.0]]);
let (_, proof) = run(&mut doc);
assert_eq!(proof.entries[0].status, Status::Unverifiable, "{}: {}", String::from_utf8_lossy(content), proof.to_text());
}
}
/// An image a Type 3 glyph (one without resources of its own, drawing through the page's) draws
/// after a surplus operand can't be placed, as one it draws without one can't: the region is
/// unverifiable.
#[test]
fn an_image_a_type3_glyph_draws_after_a_surplus_operand_is_found() {
let spec = Spec {
resources: "/Font << /F1 5 0 R /T3 7 0 R >> /XObject << /Im0 9 0 R >>",
extra: vec![
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /a 8 0 R >> \
/Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] >>"
.to_vec(),
stream("", b"10 0 d0 (x) /Im0 Do"),
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"o"),
],
..Spec::default()
};
let mut doc = open(build_with(b"BT /F1 10 Tf 10 100 Td (SECRETVALUE) Tj ET BT /T3 12 Tf 150 20 Td (a) Tj ET", spec));
mark(&mut doc, &[REGION]);
let (_, proof) = run(&mut doc);
assert_eq!(proof.entries[0].status, Status::Unverifiable, "{}", proof.to_text());
}
#[test]
fn an_ambiguous_needle_is_still_swept_outside_page_content() {
// The word also stays on the page legitimately (so page text, the raw file and decoded
+10 -10
View File
@@ -151,7 +151,7 @@ fn record_used(op: &pdfcraft_content::Op, used: &mut UsedInGlyphs) {
}
}
b"cs" | b"CS" => {
if let Some(n) = op.name(0) {
if let Some(n) = op.last_name() {
used.colorspaces.insert(n.to_vec());
}
}
@@ -166,17 +166,17 @@ fn record_used(op: &pdfcraft_content::Op, used: &mut UsedInGlyphs) {
}
}
b"gs" => {
if let Some(n) = op.name(0) {
if let Some(n) = op.last_name() {
used.extgstates.insert(n.to_vec());
}
}
b"Tf" => {
if let Some(n) = op.name(0) {
if let Some(n) = op.name_back(1) {
used.fonts.insert(n.to_vec());
}
}
b"BDC" | b"DP" => {
if let Some(n) = op.name(1) {
if let Some(n) = op.last_name() {
used.properties.insert(n.to_vec());
}
}
@@ -315,7 +315,7 @@ fn scan_content_stream(
}
b"gs" => {
apply_text_op(ctx, res, &op, &mut current_font);
if let Some(name) = op.name(0) {
if let Some(name) = op.last_name() {
scan_soft_mask(ctx, res, holder_ref, name, depth)?;
}
}
@@ -338,12 +338,12 @@ fn scan_content_stream(
fn apply_text_op(ctx: &mut ScanContext<'_>, res: &Dict, op: &pdfcraft_content::Op, current_font: &mut Option<FontKey>) {
match op.op.as_slice() {
b"Tf" => {
if let Some(name) = op.name(0) {
if let Some(name) = op.name_back(1) {
*current_font = resolve_font_in_res(ctx.doc, res, name).map(|(k, _)| k);
}
}
b"gs" => {
if let Some(name) = op.name(0)
if let Some(name) = op.last_name()
&& let Some((k, _)) = resolve_extgstate_font(ctx.doc, res, name)
{
*current_font = Some(k);
@@ -477,7 +477,7 @@ fn add_soft_mask_ops(doc: &Document, res: &Dict, ops: &mut Vec<pdfcraft_content:
while let Some(op) = ops.get(cursor) {
let selected = match op.op.as_slice() {
b"gs" => op
.name(0)
.last_name()
.and_then(|n| states.get(n))
.and_then(|s| doc.resolve(s).as_dict().and_then(|s| s.get(b"SMask").cloned()))
.and_then(|m| doc.resolve(&m).as_dict().and_then(|m| m.get(b"G").and_then(Object::as_ref))),
@@ -601,11 +601,11 @@ pub(crate) fn prune_type3_from_resources(
let mut drawn_font_names = HashSet::new();
for op in &ops {
if op.is("Tf") {
if let Some(name) = op.name(0) {
if let Some(name) = op.name_back(1) {
drawn_font_names.insert(name.to_vec());
}
} else if op.is("gs")
&& let Some(name) = op.name(0)
&& let Some(name) = op.last_name()
{
let extg = res_dict(doc, res, b"ExtGState");
if let Some(font_obj) = extg.get(name).and_then(|state_obj| gs_font_object(doc, state_obj)) {
+12 -11
View File
@@ -16,7 +16,7 @@ pub(super) fn carries_glyphs(op: &pdfcraft_content::Op) -> bool {
let nonempty = |o: &Object| o.as_string().is_some_and(|s| !s.bytes.is_empty());
match op.op.as_slice() {
b"Tj" | b"'" | b"\"" => op.operands.last().is_some_and(nonempty),
b"TJ" => op.operands.first().and_then(Object::as_array).is_some_and(|a| a.iter().any(nonempty)),
b"TJ" => op.back(0).and_then(Object::as_array).is_some_and(|a| a.iter().any(nonempty)),
_ => false,
}
}
@@ -573,7 +573,7 @@ impl Extractor<'_> {
let o = op.op.as_slice();
// Strings and TJ adjustments, in order.
let items: Vec<&Object> = match o {
b"TJ" => op.operands.first().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(),
b"TJ" => op.back(0).and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(),
_ => op.operands.last().into_iter().collect(),
};
let mut any_text = false;
@@ -658,14 +658,15 @@ impl Extractor<'_> {
tm = Matrix::IDENTITY;
tlm = Matrix::IDENTITY;
}
b"Tc" => gs.tc = op.num(0).unwrap_or(0.0),
b"Tw" => gs.tw = op.num(0).unwrap_or(0.0),
b"Tz" => gs.th = op.num(0).unwrap_or(100.0) / 100.0,
b"TL" => gs.tl = op.num(0).unwrap_or(0.0),
b"Ts" => gs.rise = op.num(0).unwrap_or(0.0),
// Operands are read from the end, as the renderer reads them.
b"Tc" => gs.tc = op.num_back(0).unwrap_or(0.0),
b"Tw" => gs.tw = op.num_back(0).unwrap_or(0.0),
b"Tz" => gs.th = op.num_back(0).unwrap_or(100.0) / 100.0,
b"TL" => gs.tl = op.num_back(0).unwrap_or(0.0),
b"Ts" => gs.rise = op.num_back(0).unwrap_or(0.0),
b"Tf" => {
gs.font = op.name(0).map(<[u8]>::to_vec).unwrap_or_default();
gs.size = op.num(1).unwrap_or(0.0);
gs.font = op.name_back(1).map(<[u8]>::to_vec).unwrap_or_default();
gs.size = op.num_back(0).unwrap_or(0.0);
}
b"Td" | b"TD" => {
if let Some([x, y]) = op.nums::<2>() {
@@ -692,7 +693,7 @@ impl Extractor<'_> {
tm = tlm;
}
if o == b"\""
&& let (Some(aw), Some(ac)) = (op.num(0), op.num(1))
&& let (Some(aw), Some(ac)) = (op.num_back(2), op.num_back(1))
{
(gs.tw, gs.tc) = (aw, ac);
}
@@ -707,7 +708,7 @@ impl Extractor<'_> {
self.show_text(font, &op, &gs, &mut tm, placed);
}
b"Do" => {
let Some(name) = op.name(0) else { continue };
let Some(name) = op.last_name() else { continue };
let Some(entry) = xobjects.get(name) else { continue };
if let Object::Stream(s) = &*doc.resolve(entry)
&& s.dict.name(b"Subtype") == Some(b"Form")
+14 -7
View File
@@ -188,7 +188,7 @@ impl Geo<'_> {
}
}
b"Do" => {
let Some(entry) = op.name(0).and_then(|n| xobjects.get(n)) else { continue };
let Some(entry) = op.last_name().and_then(|n| xobjects.get(n)) else { continue };
let obj = self.doc.resolve(entry);
let Object::Stream(s) = &*obj else { continue };
match s.dict.name(b"Subtype") {
@@ -344,7 +344,7 @@ impl Geo<'_> {
match op.op.as_slice() {
b"BI" => return Ok(true),
b"Do" => {
let Some(entry) = op.name(0).and_then(|n| xobjects.get(n)) else { continue };
let Some(entry) = op.last_name().and_then(|n| xobjects.get(n)) else { continue };
let Object::Stream(x) = &*self.doc.resolve(entry) else { continue };
match x.dict.name(b"Subtype") {
Some(b"Image") => return Ok(true),
@@ -372,21 +372,28 @@ impl Geo<'_> {
/// carries) by one construction operator.
fn extend_path(op: &pdfcraft_content::Op, ctm: &Matrix, sub: &mut Vec<Vec<(f64, f64)>>, clip: &mut Option<bool>) {
let o = op.op.as_slice();
let nums: Vec<f64> = op.operands.iter().filter_map(Object::as_f64).collect();
let at = |x: f64, y: f64| ctm.apply(x, y);
// Each takes its numbers from the end, as the renderer reads them: a surplus before them is
// ignored, not read as a point.
let points: Option<Vec<(f64, f64)>> = match o {
b"l" => op.nums::<2>().map(|[x, y]| vec![at(x, y)]),
b"c" => op.nums::<6>().map(|[x1, y1, x2, y2, x3, y3]| vec![at(x1, y1), at(x2, y2), at(x3, y3)]),
b"v" | b"y" => op.nums::<4>().map(|[x1, y1, x2, y2]| vec![at(x1, y1), at(x2, y2)]),
_ => None,
};
match o {
b"m" => {
if let [x, y] = nums[..] {
if let Some([x, y]) = op.nums::<2>() {
sub.push(vec![at(x, y)]);
}
}
b"l" | b"c" | b"v" | b"y" => {
if let Some(cur) = sub.last_mut() {
cur.extend(nums.as_chunks::<2>().0.iter().map(|p| at(p[0], p[1])));
if let (Some(cur), Some(points)) = (sub.last_mut(), points) {
cur.extend(points);
}
}
b"re" => {
if let [x, y, w, h] = nums[..] {
if let Some([x, y, w, h]) = op.nums::<4>() {
sub.push(vec![at(x, y), at(x + w, y), at(x + w, y + h), at(x, y + h)]);
}
}
+98
View File
@@ -0,0 +1,98 @@
//! Work regressions for what a redaction looks up at operators a stream can hold thousands of: the
//! tags cleanup's named property lists at each `BDC` are not read whole each time. A test binary of
//! its own, so that none of the crate's other tests shares its process, and its tests run one at a
//! time. Each test compares two timings of its own, the least of three runs each, with many
//! entries or operators and with one, not a fixed time. The bound leaves room for slower machines:
//! each regression took seconds against milliseconds.
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style, add_annotation, rect_quad};
use pdfcraft_cos::Document;
use pdfcraft_redact::apply;
use pdfcraft_testkit::{Pdf, stream_obj};
/// One page with `resources` and `extra` objects (from object 5 on), drawing a word under a mark
/// (so the redaction rewrites the page) and then `ops`.
fn page(resources: &str, ops: &[u8], extra: Vec<Vec<u8>>) -> Document {
let content = [b"BT /F1 10 Tf 20 260 Td (SECRET) Tj ET ".as_slice(), ops].concat();
let mut objs = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> {resources} >> >>"
)
.into_bytes(),
stream_obj("", content),
];
objs.extend(extra);
let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap();
let shape = Shape::Redact { quads: vec![rect_quad([0.0, 200.0, 300.0, 300.0])], overlay: String::new(), look: Default::default() };
let style = Style::default_for(&shape);
add_annotation(&mut doc, &NewAnnotation { page: 0, shape, style, contents: String::new(), author: "Synthetic test".into() }, &Meta::default())
.unwrap();
doc
}
/// One page whose `/Properties` holds `entries` entries, drawing `ops`.
fn properties(entries: usize, ops: &[u8]) -> Document {
let lists: String = (0..entries).map(|i| format!("/M{i} << /Lang (en) >> ")).collect();
page(&format!("/Properties << {lists}>>"), ops, Vec::new())
}
/// One page whose property list `/M0` is object 5, which gives its `MCID` as object 6 and holds
/// 400,000 more entries: larger than the document keeps, so that reading it again parses it again,
/// all of it, once another object has been read since. The page draws `ops`.
fn oversized(ops: &[u8]) -> Document {
let more: String = (0..400_000).map(|i| format!("/J{i} /DeviceGray ")).collect();
page("/Properties << /M0 5 0 R >>", ops, vec![format!("<< /MCID 6 0 R {more}>>").into_bytes(), b"0".to_vec()])
}
/// The tests' lock: they run one at a time, so that none is timed while another works.
static SERIAL: Mutex<()> = Mutex::new(());
fn one_at_a_time() -> std::sync::MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(PoisonError::into_inner)
}
/// How long redacting a document `make` builds takes, the least of three runs.
fn redaction(make: impl Fn() -> Document) -> Duration {
(0..3)
.map(|_| {
let mut doc = make();
let start = Instant::now();
let report = apply(&mut doc, None).unwrap();
let took = start.elapsed();
assert_eq!(report.glyphs, 6, "the word under the mark goes");
took
})
.min()
.unwrap_or_default()
}
/// Whether `many` took about as long as `one`.
fn about_as_long(many: Duration, one: Duration) -> bool {
many < one * 10 + Duration::from_secs(1)
}
/// Written when the tags cleanup resolved the page's `/Properties` at every named `BDC`: 20,000 of
/// them with 5,000 entries took 28 s against 0.07 s with one entry (optimized). Each `BDC` here
/// names another entry, so that it is the lists, not each name's answer, that are read once.
#[test]
fn named_property_lists_are_read_once_for_a_page() {
let _serial = one_at_a_time();
let ops: Vec<u8> = (0..5_000).map(|i| format!("/P /M{i} BDC EMC ")).collect::<String>().into_bytes();
let (one, many) = (redaction(|| properties(1, &ops)), redaction(|| properties(5_000, &ops)));
assert!(about_as_long(many, one), "5,000 entries {many:?}, one {one:?}");
}
/// Written when the tags cleanup read a named property list's `MCID` by reference at each `BDC`: a
/// list larger than the document keeps was parsed again at each, 50 of them taking 12.3 s against
/// 0.44 s with the `MCID` given directly (optimized).
#[test]
fn an_oversized_property_list_is_read_once_for_a_stream() {
let _serial = one_at_a_time();
let (one, many) = (redaction(|| oversized(b"/P /M0 BDC EMC ")), redaction(|| oversized(&b"/P /M0 BDC EMC ".repeat(100))));
assert!(about_as_long(many, one), "100 operators {many:?}, one {one:?}");
}