mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 05:42:52 +00:00
M8: redaction reads an operator's operands from the end, as the renderer does
An operator takes its operands from the end, and the renderer ignores a surplus before them: `(x) /OC /MC0 BDC` marks a layer's block, and `(x) /GS1 gs` selects `/GS1`. Redaction read some of them from the front, so a surplus stood in for them and what they named went unseen. Found in review of #6882, where Remove Hidden Information left the text of a hidden layer in `(x) /OC /MC0 BDC (LAYERSECRET) Tj EMC`, which both the app's renderer and Poppler hide. In what Apply Redactions and Remove Hidden Information decide: - the layers walk read a block's tag and property list from the front, and kept a hidden layer's block; - the cleanup of `/ActualText`, `/Alt`, `/E` and `/T` in a block whose paint was removed read (and wrote) the property list from the front, and kept the name that still read the removed word; - the tags cleanup read a block's `MCID` from the front, and left the removed text in its structure element's `/ActualText` and `/Alt`; and a `TJ`'s array, so a block that still drew was taken for empty and its structure element lost the content the page still shows; - the property cleanups read the name of a `BDC` or `DP` property list from the front, and retired a property list the page still draws with; - the walk read the ExtGState a `gs` selects from the front, so a font set only that way was not set, and `cs`/`CS` the same for a pattern space; - the Type 3 cleanup's reads of `cs`, `gs`, `Tf`, `BDC` and `DP`, which decide which resources and fonts are still used. The redaction proof read `Tc`, `Tw`, `Tz`, `TL`, `Ts`, `Tf`, `"`, `TJ` and `Do` from the front too. `pdfcraft_content::Op` gains `back`, `name_back` and `num_back`: operand `k` counted from the end. Tests (`crates/redact/src/tests/operands_from_the_end.rs`): a hidden layer's block, `/ActualText` on removed paint, a tag's `MCID`, a tagged `TJ` that still draws, a property list still drawn with, a font from an ExtGState, the pattern a `/Pattern cs` keeps, and a Type 3 font with the ExtGState, colour space, property list and font its glyph uses, each after a surplus operand. In `tests_verify.rs`, `the_proof_reads_operands_from_the_end` (`Tf`, `TJ`, and `Do` of a form and of images), `the_proof_reads_the_text_states_operands_from_the_end` (`Tc`, `Tw`, `Tz`, `TL`, `Ts` and `"`) and `an_image_a_type3_glyph_draws_after_a_surplus_operand_is_found`. And `operands_are_counted_from_the_end` in the content crate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
1 parent
919db9bd51
commit
b16f0c0a77
12 files changed
+310
-36
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -571,7 +571,7 @@ impl Walk {
|
||||
Some(op)
|
||||
}
|
||||
b"cs" | b"CS" => {
|
||||
if op.name(0) != Some(b"Pattern") {
|
||||
if op.last_name() != Some(b"Pattern") {
|
||||
if o == b"cs" {
|
||||
self.gs.fill_pattern = None;
|
||||
} else {
|
||||
@@ -656,10 +656,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 +864,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 +875,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -357,7 +357,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());
|
||||
}
|
||||
@@ -520,7 +520,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);
|
||||
|
||||
@@ -52,7 +52,8 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> Marked {
|
||||
for op in pdfcraft_content::parse(data).ops {
|
||||
match op.op.as_slice() {
|
||||
b"BDC" => {
|
||||
let id = match op.operands.get(1) {
|
||||
// The property list is the last operand, as the renderer reads it.
|
||||
let id = match op.back(0) {
|
||||
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)?);
|
||||
@@ -86,9 +87,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,
|
||||
};
|
||||
|
||||
@@ -6149,6 +6149,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,170 @@
|
||||
//! 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:?}");
|
||||
}
|
||||
@@ -897,6 +897,78 @@ 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()),
|
||||
(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());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
@@ -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))),
|
||||
@@ -584,11 +584,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)) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -552,7 +552,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;
|
||||
@@ -637,14 +637,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>() {
|
||||
@@ -671,7 +672,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);
|
||||
}
|
||||
@@ -686,7 +687,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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user