mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
fix(render): keep the text matrix across Q inside a text object
The vendored hayro-interpret kept the text and text line matrices in the saved graphics state, so a `q … Q` inside BT … ET put the pen back where it was before `q`. Text after the `Q` was drawn, extracted and found at the wrong place, and search redaction refused to apply because its find boxes (from the render text layer) disagreed with the redactor's own measurement. Tm and Tlm are not graphics state (ISO 32000-2 §8.4.1 Table 51, §9.4.1); q/Q inside a text object is allowed in PDF 2.0 (§8.2). Patch (67) carries them across the restore; font and the other text parameters are still restored. Forms and annotations save and restore around their own content, and Type 3 glyphs, patterns and soft masks run in a context of their own, so a stream's pen does not reach its caller unless the stream pops past its own saves (two or more stray Q in a form already corrupt the page state on main; unchanged here). Tests: pdfcraft-render it::text_matrix_q (three behaviour tests that fail with the fix reverted, two guards) and pdfcraft-automation it::redact_text_after_q (end to end, fails at redact_apply when reverted). 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
64e0868b84
commit
086339b7d7
@@ -8,3 +8,4 @@ mod button_caption;
|
||||
mod form_bulk;
|
||||
mod hostile_args;
|
||||
mod optimize_content_boundaries;
|
||||
mod redact_text_after_q;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 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.
|
||||
|
||||
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";
|
||||
|
||||
fn pdf() -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \
|
||||
/F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> \
|
||||
/F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> >> >>"
|
||||
.to_owned(),
|
||||
format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()),
|
||||
];
|
||||
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{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
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());
|
||||
out
|
||||
}
|
||||
|
||||
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
match a.call(tool, &args) {
|
||||
Ok(mut c) => match c.remove(0) {
|
||||
Content::Json(v) => v,
|
||||
other => panic!("{tool}: expected JSON, got {other:?}"),
|
||||
},
|
||||
Err(e) => panic!("{tool} {args}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(a: &mut Automation, doc: &Value) -> String {
|
||||
ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ mod named_form_properties;
|
||||
mod search;
|
||||
mod separation_all;
|
||||
mod substitute_widths;
|
||||
mod text_matrix_q;
|
||||
mod tiling_cell_placement;
|
||||
mod type3_text_clip;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 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
+14
-1
@@ -637,7 +637,20 @@ fn interpret_window<'a>(
|
||||
TypedInstruction::ClipEvenOdd(_) => {
|
||||
*(context.clip_mut()) = Some(FillRule::EvenOdd);
|
||||
}
|
||||
TypedInstruction::RestoreState(_) => context.restore_state(device),
|
||||
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::FlatnessTolerance(_) => {
|
||||
// Ignore for now.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user