diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 1e69cb32e2..9cfc981381 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -1071,7 +1071,32 @@ fn xobject( } } Some(b"Form") => { - let m = s.dict.get(b"Matrix").and_then(|m| Matrix::from_operands(doc.resolve(m).as_array()?)).unwrap_or_default(); + // Read as the proof reads it, entries by reference included: read as the identity, a + // form moved onto a mark would be tested against the wrong place and never walked. + let m = match crate::verify::matrix_of(doc, &s.dict) { + Ok(m) => m, + // Not six numbers given directly or through one reference. Removing hidden layers alone never asks where content lands, so + // the form is walked as it stands. + Err(()) if scope.everywhere() && scope.hidden_text.is_none() => Matrix::IDENTITY, + // Otherwise where the form draws, and so whether it reaches a mark or leaves the + // page, can't be told. Apply refuses the page, and the scans count it, as for a + // form past the page's budget below. + Err(()) => { + if scope.mode == Mode::Apply { + out.error = Some(CosError::Syntax { + offset: 0, + detail: + "a form XObject's /Matrix isn't six numbers given directly or through one reference, so where it draws can't be told" + .into(), + }); + } else if scope.everywhere() { + scope.layer_blocks += 1; + } else { + out.residue += 1; + } + return None; + } + }; let fctm = m.then(&gs.ctm); let bbox = s.dict.get(b"BBox").and_then(|b| { let v: Vec = doc.resolve(b).as_array()?.iter().filter_map(Object::as_f64).collect(); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 99e808139b..0e611c6678 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -6231,6 +6231,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { mod descriptions; mod elsewhere; +mod form_matrix; mod hidden_scope; mod identification; mod inline_images; diff --git a/crates/redact/src/tests/form_matrix.rs b/crates/redact/src/tests/form_matrix.rs new file mode 100644 index 0000000000..e4bb33608e --- /dev/null +++ b/crates/redact/src/tests/form_matrix.rs @@ -0,0 +1,131 @@ +//! A form's `/Matrix` places what it draws. The redaction walk reads it as the proof does, the +//! array and each entry direct or by reference: read as the identity, a form moved onto a mark +//! was tested against the wrong place, never walked, and its text stayed. + +use super::*; + +/// One page drawing a form with resources of its own, whose content shows SECRET at (20, 60) in a +/// `/BBox` of [0 0 300 100], with `matrix` in its dictionary; object 8 is `extra`. +fn moved_form(matrix: &str, extra: &[u8]) -> Document { + one_page( + b"/Fm Do", + "/XObject << /Fm 7 0 R >>", + vec![ + stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 100] {matrix} /Resources << /Font << /F1 5 0 R >> >>"), + b"BT /F1 10 Tf 20 60 Td (SECRET) Tj ET", + ), + extra.to_vec(), + ], + ) +} + +/// Over the top third of the 300 × 300 page, where a move of 200 puts the text. +const MARK: [f64; 4] = [0.0, 200.0, 300.0, 300.0]; + +#[test] +fn a_form_moved_onto_a_mark_is_redacted_however_its_matrix_is_given() { + for (matrix, extra) in [ + ("/Matrix [1 0 0 1 0 200]", &b"null"[..]), + ("/Matrix 8 0 R", b"[1 0 0 1 0 200]"), + ("/Matrix [1 0 0 1 0 8 0 R]", b"200"), + ("/Matrix [8 0 R 0 0 1 0 200]", b"1"), + ] { + let mut doc = moved_form(matrix, extra); + mark(&mut doc, 0, &[MARK], ""); + assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(6), "{matrix} with 8 0 obj {}", String::from_utf8_lossy(extra)); + } +} + +/// A `/Matrix` of null, given directly or by a reference to null, is no `/Matrix` (ISO 32000-2 +/// §7.3.9): the identity, under which the text lies below the mark and stays. +#[test] +fn a_form_whose_matrix_is_null_draws_where_it_stands() { + for (matrix, extra) in [("/Matrix null", &b"null"[..]), ("/Matrix 8 0 R", b"null")] { + let mut doc = moved_form(matrix, extra); + mark(&mut doc, 0, &[MARK], ""); + assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(0), "{matrix}"); + let mut doc = moved_form(matrix, extra); + mark(&mut doc, 0, &[[0.0, 0.0, 300.0, 100.0]], ""); + assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(6), "{matrix}, marked where it stands"); + } +} + +/// A `/Matrix` that isn't six numbers: where the form draws can't be told, so neither can whether +/// it reaches the mark, and the page is refused. +#[test] +fn a_form_whose_matrix_is_not_six_numbers_is_refused() { + for (matrix, extra) in [ + ("/Matrix [1 0 0 1 0 (up)]", &b"null"[..]), + ("/Matrix [1 0 0 1 0]", b"null"), + ("/Matrix [1 0 0 1 0 8 0 R]", b"(up)"), + ("/Matrix 8 0 R", b"(up)"), + ] { + let mut doc = moved_form(matrix, extra); + mark(&mut doc, 0, &[MARK], ""); + let result = apply(&mut doc, None); + assert!( + matches!(&result, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail, .. })) if detail.contains("/Matrix")), + "{matrix} with 8 0 obj {}: {result:?}", + String::from_utf8_lossy(extra) + ); + } +} + +/// A reference to a reference, for the matrix or one of its entries: Poppler doesn't follow it (it +/// reads such a matrix as the identity, such an entry as 0), the app's renderer follows it and +/// reads the move, so where the form draws can't be told, and the page is refused, as for a matrix that isn't six numbers. Read as either, +/// a mark where the other reader shows the text would leave it in the file. +#[test] +fn a_form_whose_matrix_goes_through_a_reference_to_a_reference_is_refused() { + for (matrix, target) in [("/Matrix [1 0 0 1 0 8 0 R]", &b"200"[..]), ("/Matrix 8 0 R", b"[1 0 0 1 0 200]")] { + for mark_at in [MARK, [0.0, 0.0, 300.0, 100.0]] { + let form = stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 100] {matrix} /Resources << /Font << /F1 5 0 R >> >>"), + b"BT /F1 10 Tf 20 60 Td (SECRET) Tj ET", + ); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form, b"9 0 R".to_vec(), target.to_vec()]); + mark(&mut doc, 0, &[mark_at], ""); + let result = apply(&mut doc, None); + assert!( + matches!(&result, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail, .. })) if detail.contains("/Matrix")), + "{matrix}, 8 0 obj 9 0 R, 9 0 obj {}, mark at {mark_at:?}: {result:?}", + String::from_utf8_lossy(target) + ); + } + } +} + +/// The scans count such a form as content that may remain: the verify pass under a mark, and +/// Remove Hidden Information's scan, whose removal then refuses the page. +#[test] +fn a_form_whose_matrix_is_not_six_numbers_counts_in_the_scans() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let mut doc = moved_form("/Matrix [1 0 0 1 0 (up)]", b"null"); + assert_eq!(under(&mut doc, 0, &[MARK]), 1); + let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::HiddenText], 1); + let removed = remove_hidden(&mut doc, &[Hidden::HiddenText]); + assert!(matches!(&removed, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail, .. })) if detail.contains("/Matrix")), "{removed:?}"); +} + +/// Removing hidden layers alone never asks where content lands, so such a form is walked as it +/// stands: nothing is counted or refused over it in a document without layers. +#[test] +fn a_form_whose_matrix_is_not_six_numbers_leaves_removing_hidden_layers_alone() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let mut doc = moved_form("/Matrix [1 0 0 1 0 (up)]", b"null"); + let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::HiddenLayers], 0); + assert_eq!(remove_hidden(&mut doc, &[Hidden::HiddenLayers]), Err(RedactError::NothingToApply)); +} + +/// Remove Hidden Information reads it the same way: text a matrix entry by reference moves off the +/// page is hidden text, and goes. +#[test] +fn text_a_matrix_entry_by_reference_moves_off_the_page_is_hidden_text() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = moved_form("/Matrix [1 0 0 1 0 8 0 R]", b"1000"); + let removed = remove_hidden(&mut doc, &[Hidden::HiddenText]); + assert!(removed.as_ref().is_ok_and(|r| r.iter().any(|(h, n)| *h == Hidden::HiddenText && *n > 0)), "{removed:?}"); +} diff --git a/crates/redact/src/verify/extract.rs b/crates/redact/src/verify/extract.rs index fe92e4e78d..63dbeff342 100644 --- a/crates/redact/src/verify/extract.rs +++ b/crates/redact/src/verify/extract.rs @@ -396,17 +396,38 @@ pub(super) fn push_squashed(into: &mut String, s: &str) { into.extend(s.chars().filter(|c| !c.is_whitespace() && *c != '\u{feff}')); } -/// A form's or pattern's `/Matrix`: absent is the identity, present but malformed is an error -/// (guessing the identity would put its content in the wrong place). -pub(super) fn matrix_of(doc: &Document, dict: &Dict) -> Result { - match dict.get(b"Matrix") { - None => Ok(Matrix::IDENTITY), - Some(m) => { - let m = doc.resolve(m); - let items: Vec = m.as_array().ok_or(())?.iter().map(|x| (*doc.resolve(x)).clone()).collect(); - Matrix::from_operands(&items).filter(|m| m.0.iter().all(|v| v.is_finite())).ok_or(()) +/// A form's or an annotation appearance's `/Matrix`, the array and each entry direct or by reference: absent or null +/// is the identity, present but malformed is an error (guessing the identity would put its content in the +/// wrong place). A reference to a reference is malformed here: readers disagree on following it +/// (Poppler doesn't, the app's renderer does), so where the content draws can't be told. The +/// redaction walk reads a form's with it too. +pub(crate) fn matrix_of(doc: &Document, dict: &Dict) -> Result { + let Some(m) = dict.get(b"Matrix") else { return Ok(Matrix::IDENTITY) }; + // Each reference is followed one hop. The array is read in place when written directly, and + // its length checked before any entry is read: the walk reads it at every `Do`, and an array of + // any length would be copied each time. + let held; + let m = match m { + Object::Ref(r) => { + held = doc.get(*r); + &*held } + direct => direct, + }; + // A null value is no entry (ISO 32000-2 §7.3.9), whether given directly or by reference. + if matches!(m, Object::Null) { + return Ok(Matrix::IDENTITY); } + let items = m.as_array().filter(|a| a.len() == 6).ok_or(())?; + let mut values = [0.0; 6]; + for (value, item) in values.iter_mut().zip(items) { + let n = match item { + Object::Ref(r) => doc.get(*r).as_f64(), + direct => direct.as_f64(), + }; + *value = n.filter(|n| n.is_finite()).ok_or(())?; + } + Ok(Matrix(values)) } /// A form's `/BBox` as `[x0 y0 x1 y1]`: absent is `None`, present but malformed is an error. diff --git a/crates/redact/src/verify/mod.rs b/crates/redact/src/verify/mod.rs index 3a36c9e08e..d58d4c307c 100644 --- a/crates/redact/src/verify/mod.rs +++ b/crates/redact/src/verify/mod.rs @@ -70,6 +70,7 @@ mod snapshot; mod sweep; mod util; +pub(crate) use extract::matrix_of; pub use snapshot::Snapshot; /// What the proof established for one region. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/redact/tests/matrix_work.rs b/crates/redact/tests/matrix_work.rs new file mode 100644 index 0000000000..d96447ff71 --- /dev/null +++ b/crates/redact/tests/matrix_work.rs @@ -0,0 +1,59 @@ +//! Work regression for a form's `/Matrix`, which the redaction walk reads at every `Do` of the +//! form: an array of any length costs no more than one of seven entries, written directly or by +//! reference. A test binary of its own, so that none of the crate's other tests shares its process. +//! The test compares two timings of its own, the least of three runs each, not a fixed time. + +use std::sync::Arc; +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 drawing a form `draws` times under a mark, the form's `/Matrix` an array of `entries` +/// zeros, written in its dictionary or, `by_reference`, as object 6. +fn document(draws: usize, entries: usize, by_reference: bool) -> Document { + let zeros = format!("[{}]", vec!["0"; entries].join(" ")); + let matrix = if by_reference { "6 0 R".to_string() } else { zeros.clone() }; + let objs = 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 << /XObject << /Fm 5 0 R >> >> >>".to_vec(), + stream_obj("", "/Fm Do ".repeat(draws).as_bytes()), + stream_obj(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Matrix {matrix}"), b"0 0 10 10 re f"), + zeros.into_bytes(), + ]; + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap(); + let shape = Shape::Redact { quads: vec![rect_quad([0.0, 0.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 +} + +/// How long redacting `document(draws, entries, by_reference)` takes, the least of three runs. A +/// matrix that isn't six numbers is refused. +fn redaction(draws: usize, entries: usize, by_reference: bool) -> Duration { + (0..3) + .map(|_| { + let mut doc = document(draws, entries, by_reference); + let start = Instant::now(); + let refused = apply(&mut doc, None).is_err(); + let took = start.elapsed(); + assert!(refused, "a /Matrix of {entries} entries is refused"); + took + }) + .min() + .unwrap_or_default() +} + +/// Written when the walk resolved and copied every entry before checking there were six: 4,000 +/// draws of a form whose `/Matrix` is 4,000 entries long read sixteen million entries. +#[test] +fn a_long_form_matrix_costs_no_more_than_a_short_one() { + for by_reference in [false, true] { + let (short, long) = (redaction(4_000, 7, by_reference), redaction(4_000, 4_000, by_reference)); + assert!(long < short * 3 + Duration::from_millis(50), "by reference: {by_reference}: long {long:?}, short {short:?}"); + } +}