mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 21:48:09 -06:00
fix(redact): Q inside a text object restores the text matrices; revert the render change
Review on #4831 pointed out the PDF Association's erratum to ISO 32000-2 (pdf-association/pdf-issues #368): inside a text object, q/Q also save and restore Tm and Tlm (§9.4.1, referenced from §8.4.2). Acrobat 26.2 agrees on a synthetic PDF 2.0 page. The renderer on main already did this, so the previous commit's hayro-interpret patch (67) was wrong: it is reverted here, with its vendor/README row and the render tests. The real disagreement was in the redactor's own interpreter, which kept Tm and Tlm across Q. Find took its boxes from the renderer's text, the redactor measured the glyphs elsewhere, and redact_apply refused on residue. Its state stack now holds (Gs, Tm, Tlm) and Q restores all three. Test: it::redact_text_after_q, end to end on %PDF-2.0 fixtures, three cases (font change inside q/Q; q 100 0 Td Q; the same followed by T*). With the redactor change reverted, each case fails on its own at redact_apply. With only Tm restored, the T* case fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
086339b7d7
commit
0457aa88f9
@@ -1,14 +1,23 @@
|
||||
//! Search-and-redact of text that follows a `q`/`Q` inside a text object. Find's boxes come from
|
||||
//! the renderer's text and redaction measures the content itself, so the two must agree on where
|
||||
//! the text after `Q` is (vendored hayro-interpret patch 67); when they disagreed, `redact_apply`
|
||||
//! refused to change anything.
|
||||
|
||||
//! Search-and-redact of text that follows a `q`/`Q` inside a text object. `Q` restores the text
|
||||
//! matrices there (ISO 32000-2 §9.4.1 as corrected by the PDF Association's errata, issue 368), so
|
||||
//! "SECRET" is drawn from the pen position before `q`, over the "B". Find's boxes come from the
|
||||
//! renderer's text, which places it there; redaction measures the content itself, and while it
|
||||
//! carried the pen across `Q` the two disagreed and `redact_apply` refused to change anything.
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const CONTENT: &str = "BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET";
|
||||
/// The content, the text that stays after SECRET is redacted, and the text that goes with it.
|
||||
const CASES: [(&str, &str, &str); 3] = [
|
||||
// A larger font inside `q`/`Q`: the font and the pen are both restored, so SECRET lies over
|
||||
// the B and the B goes with it.
|
||||
("BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET", "AAAA", "B"),
|
||||
// A move inside `q`/`Q` is undone.
|
||||
("BT /F1 12 Tf 72 700 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET", "AA", "SECRET"),
|
||||
// The text line matrix is restored too: `T*` starts the next line from the line before `q`.
|
||||
("BT /F1 12 Tf 14 TL 72 700 Td (AA) Tj q 100 0 Td Q T* (SECRET) Tj ET", "AA", "SECRET"),
|
||||
];
|
||||
|
||||
fn pdf() -> Vec<u8> {
|
||||
fn pdf(content: &str) -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
|
||||
@@ -16,9 +25,9 @@ fn pdf() -> Vec<u8> {
|
||||
/F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> \
|
||||
/F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> >> >>"
|
||||
.to_owned(),
|
||||
format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut out = b"%PDF-2.0\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
@@ -49,18 +58,20 @@ fn text(a: &mut Automation, doc: &Value) -> String {
|
||||
|
||||
#[test]
|
||||
fn find_and_redact_removes_text_after_a_q_q_inside_its_text_object() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("in.pdf"), pdf()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
|
||||
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1);
|
||||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
|
||||
let after = text(&mut a, &saved);
|
||||
assert!(!after.contains("SECRET"), "{after:?}");
|
||||
assert!(after.contains("AAAAB"), "the text before the mark stays: {after:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
for (case, (content, kept, gone)) in CASES.iter().enumerate() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}-{case}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("in.pdf"), pdf(content)).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
|
||||
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1, "{content}");
|
||||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
|
||||
let after = text(&mut a, &saved);
|
||||
assert!(!after.contains("SECRET") && !after.contains(gone), "{content}: {after:?}");
|
||||
assert!(after.contains(kept), "{content}: the text before the mark stays: {after:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,7 +801,10 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
|
||||
fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<u8>], resources: &Dict, mut gs: Gs) -> Output {
|
||||
let mut out = Output::default();
|
||||
let fallback = Rc::new(Metrics::fallback());
|
||||
let mut stack: Vec<Gs> = Vec::new();
|
||||
// With each saved state, the text and text line matrices: inside a text object `q`/`Q`
|
||||
// save and restore them too (ISO 32000-2 §9.4.1 as corrected by the PDF Association's
|
||||
// errata, issue 368), as the renderer does.
|
||||
let mut stack: Vec<(Gs, Matrix, Matrix)> = Vec::new();
|
||||
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
|
||||
let fonts_res = res_dict(doc, resources, b"Font");
|
||||
let ext_gstates = res_dict(doc, resources, b"ExtGState");
|
||||
@@ -901,10 +904,10 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<u8>],
|
||||
clip = false;
|
||||
}
|
||||
match o {
|
||||
b"q" => stack.push(gs.clone()),
|
||||
b"q" => stack.push((gs.clone(), tm, tlm)),
|
||||
b"Q" => {
|
||||
if let Some(g) = stack.pop() {
|
||||
gs = g;
|
||||
if let Some((g, m, lm)) = stack.pop() {
|
||||
(gs, tm, tlm) = (g, m, lm);
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
|
||||
@@ -8,6 +8,5 @@ mod named_form_properties;
|
||||
mod search;
|
||||
mod separation_all;
|
||||
mod substitute_widths;
|
||||
mod text_matrix_q;
|
||||
mod tiling_cell_placement;
|
||||
mod type3_text_clip;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
//! Contributor-original synthetic pages: `q`/`Q` inside a text object. The text matrices are not
|
||||
//! part of the graphics state (ISO 32000-2 §8.4.1 Table 51, §9.4.1), so a `Q` restores the font
|
||||
//! but not the pen (vendored hayro-interpret patch 67). Each case is paired with a page that
|
||||
//! changes the font back by hand instead, which must come out the same.
|
||||
use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `q`/`Q` around a larger font: the text after `Q` continues from the end of the "B".
|
||||
const SAVED: &str = "BT /F1 12 Tf 72 300 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET";
|
||||
/// The same text with the font set back by `Tf`.
|
||||
const BY_HAND: &str = "BT /F1 12 Tf 72 300 Td (AAAA) Tj /F2 24 Tf (B) Tj /F1 12 Tf (SECRET) Tj ET";
|
||||
/// A move inside `q`/`Q` moves the pen for good: `Td` is not undone by `Q`.
|
||||
const MOVED_IN_SAVE: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET";
|
||||
const MOVED: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj 100 0 Td (SECRET) Tj ET";
|
||||
/// A form that moves its own pen and ends with an unbalanced `Q`, shown inside a text object
|
||||
/// (`Do` is not allowed there, but readers draw it).
|
||||
const FORM_INSIDE: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj /X Do (SECRET) Tj ET";
|
||||
const FORM_BODY: &[u8] = b"BT /F1 12 Tf 200 0 Td ET Q";
|
||||
const NO_FORM: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj (SECRET) Tj ET";
|
||||
|
||||
/// One page per entry of `contents`, 400 × 400, with Helvetica as /F1, Courier as /F2 and the
|
||||
/// form /X.
|
||||
fn pdf(contents: &[&str]) -> Arc<Vec<u8>> {
|
||||
let n = contents.len();
|
||||
let kids: String = (0..n).map(|i| format!("{} 0 R ", 5 + 2 * i)).collect();
|
||||
let mut objects: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").into_bytes(),
|
||||
b"<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> /F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> /XObject << /X 4 0 R >> >>".to_vec(),
|
||||
stream(b"/Type /XObject /Subtype /Form /BBox [0 0 400 400] /Resources 3 0 R", FORM_BODY),
|
||||
];
|
||||
for (i, content) in contents.iter().enumerate() {
|
||||
objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources 3 0 R /Contents {} 0 R >>", 6 + 2 * i).into_bytes());
|
||||
objects.push(stream(b"", content.as_bytes()));
|
||||
}
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(object);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
Arc::new(out)
|
||||
}
|
||||
|
||||
fn stream(dict: &[u8], body: &[u8]) -> Vec<u8> {
|
||||
let mut s = b"<< ".to_vec();
|
||||
s.extend_from_slice(dict);
|
||||
s.extend_from_slice(format!(" /Length {} >>\nstream\n", body.len()).as_bytes());
|
||||
s.extend_from_slice(body);
|
||||
s.extend_from_slice(b"\nendstream");
|
||||
s
|
||||
}
|
||||
|
||||
fn request(page: usize, kind: RequestKind) -> RenderRequest {
|
||||
RenderRequest { page, kind, tile: None, scale: 1.0, tag: 0, preview: false }
|
||||
}
|
||||
|
||||
fn texts(contents: &[&str]) -> Vec<Arc<PageText>> {
|
||||
let mut renderer = PageRenderer::new(pdf(contents), RenderConfig::default());
|
||||
(0..contents.len())
|
||||
.map(|page| {
|
||||
let out = renderer.render(request(page, RequestKind::Text));
|
||||
assert!(out.error.is_none(), "page {} text error: {:?}", page + 1, out.error);
|
||||
out.text.expect("page text")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The boxes of the glyphs of `word`'s one match on the page.
|
||||
fn boxes(text: &PageText, word: &str) -> Vec<[f32; 4]> {
|
||||
let hits = text.find(word);
|
||||
assert_eq!(hits.len(), 1, "one {word} in {:?}", text.plain_text());
|
||||
hits[0].clone().map(|i| text.glyphs[i].rect).collect()
|
||||
}
|
||||
|
||||
fn assert_same_boxes(a: &[[f32; 4]], b: &[[f32; 4]]) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
assert!(x.iter().zip(y).all(|(p, q)| (p - q).abs() < 0.01), "{x:?} != {y:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_after_q_q_continues_where_the_text_inside_left_off() {
|
||||
let pages = texts(&[SAVED, BY_HAND]);
|
||||
let secret = boxes(&pages[0], "SECRET");
|
||||
assert_same_boxes(&secret, &boxes(&pages[1], "SECRET"));
|
||||
let b = boxes(&pages[0], "B");
|
||||
assert!(secret[0][0] >= b[0][2] - 0.01, "SECRET starts at {} inside the B ending at {}", secret[0][0], b[0][2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q_q_still_restores_the_font() {
|
||||
let pages = texts(&[SAVED, BY_HAND]);
|
||||
let height = |r: [f32; 4]| r[3] - r[1];
|
||||
let secret = boxes(&pages[0], "SECRET");
|
||||
let b = boxes(&pages[0], "B");
|
||||
assert!(height(secret[0]) < height(b[0]) * 0.75, "SECRET is drawn at the 12 pt font, not the B's 24 pt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_inside_q_q_is_kept() {
|
||||
let pages = texts(&[MOVED_IN_SAVE, MOVED]);
|
||||
assert_same_boxes(&boxes(&pages[0], "SECRET"), &boxes(&pages[1], "SECRET"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forms_unbalanced_q_does_not_move_the_page_text() {
|
||||
let pages = texts(&[FORM_INSIDE, NO_FORM]);
|
||||
assert_same_boxes(&boxes(&pages[0], "SECRET"), &boxes(&pages[1], "SECRET"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q_q_inside_a_text_object_draws_like_the_font_set_back_by_hand() {
|
||||
let mut renderer = PageRenderer::new(pdf(&[SAVED, BY_HAND]), RenderConfig::default());
|
||||
let saved = renderer.render(request(0, RequestKind::Pixels));
|
||||
let by_hand = renderer.render(request(1, RequestKind::Pixels));
|
||||
assert!(saved.error.is_none() && by_hand.error.is_none());
|
||||
assert_eq!((saved.width, saved.height), (by_hand.width, by_hand.height));
|
||||
assert!(saved.rgba == by_hand.rgba, "the two pages draw differently");
|
||||
}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-14
@@ -637,20 +637,7 @@ fn interpret_window<'a>(
|
||||
TypedInstruction::ClipEvenOdd(_) => {
|
||||
*(context.clip_mut()) = Some(FillRule::EvenOdd);
|
||||
}
|
||||
TypedInstruction::RestoreState(_) => {
|
||||
// PdfCraft patch (67): the text and text line matrices are not part of the
|
||||
// graphics state (ISO 32000-2 §8.4.1 Table 51, §9.4.1), so a `Q` inside a text
|
||||
// object keeps the pen where the text before it left it. Outside one, the next
|
||||
// `BT` resets both. Forms and annotations run between a save and restore of their
|
||||
// own, and Type 3 glyphs, patterns and soft masks in a context of their own, so a
|
||||
// stream's pen does not reach its caller unless the stream pops past its own
|
||||
// saves, which already hands the caller a wrong state.
|
||||
let text = &context.get().text_state;
|
||||
let pen = (text.text_matrix, text.text_line_matrix);
|
||||
context.restore_state(device);
|
||||
let text = &mut context.get_mut().text_state;
|
||||
(text.text_matrix, text.text_line_matrix) = pen;
|
||||
}
|
||||
TypedInstruction::RestoreState(_) => context.restore_state(device),
|
||||
TypedInstruction::FlatnessTolerance(_) => {
|
||||
// Ignore for now.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user