M8: a test of its own for each operand read the fix changes

Review of the whole change found reads it fixes that no test failed
without: each was put back to main's read alone, and the tests passed.
Each now has a case that fails without it:
- the proof's `Tf` size (text starting outside the region reaches into
  it only at its size) and font (an undecodable font's text leaves the
  region unverifiable), and the strings a `TJ` carries;
- the proof's `l`, `c`, `v` and `y` points after a surplus, from a point
  outside the region, and the walk's `m`, `l`, `c`, `v` and `y` points
  beside a mark, each alone;
- the property list a second page sharing the resources still names;
- a Type 3 font an ExtGState's `/Font` sets, kept by the page and its
  glyph by the font, and one without resources of its own, which keeps
  the page's pattern its glyph may paint with;
- a Type 3 font only a soft mask's group selects, the mask's ExtGState
  named after a surplus;
- the named property lists read once, with each `BDC` naming another
  entry, so that it is the lists, not each name's answer, that are kept.

And the walk's `cs` says why it tells a Pattern space by the name
`/Pattern` alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz Martens
2026-11-10 23:11:09 +01:00
co-authored by Claude Opus 5.5
parent 5e2552dcb0
commit 042371bda9
5 changed files with 163 additions and 4 deletions
+5
View File
@@ -571,6 +571,11 @@ impl Walk {
Some(op)
}
b"cs" | b"CS" => {
// 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;
@@ -190,6 +190,19 @@ fn a_paths_points_after_a_surplus_number_are_where_the_renderer_draws_them() {
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`.
@@ -236,3 +249,92 @@ fn an_mcid_given_by_reference_loses_what_redaction_removed() {
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]
+20
View File
@@ -912,6 +912,8 @@ fn the_proof_reads_operands_from_the_end() {
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.
@@ -960,6 +962,11 @@ fn the_proof_reads_a_paths_points_from_the_end() {
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();
@@ -968,6 +975,19 @@ fn the_proof_reads_a_paths_points_from_the_end() {
}
}
/// 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.
+5 -4
View File
@@ -77,13 +77,14 @@ fn about_as_long(many: Duration, one: Duration) -> bool {
}
/// 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).
/// 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 = b"/P /M0 BDC EMC ".repeat(5_000);
let (one, many) = (redaction(|| properties(1, &ops)), redaction(|| properties(2_000, &ops)));
assert!(about_as_long(many, one), "2,000 entries {many:?}, one {one:?}");
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