From 6e0d0fde3f7592567bf05fd78e34321aed1209df Mon Sep 17 00:00:00 2001 From: Josias Eli Rodriguez <339499413+chamber074@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:04:51 -0600 Subject: [PATCH] redact: treat unusable /QuadPoints as absent in every reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quad counts only when all 8 entries are present, numeric and finite. Anything else contributes nothing โ€” never folded, shifted or zero-filled into a box the document does not describe. With no usable quad the existing /Rect fallback runs, and with neither the mark stays unreadable. The rule lives in one helper, geom::quad_points::usable_quads, and redact, render-inspect, annot summaries and annot appearance drawing all read through it, so the outline shown is the area apply removes. Redact also follows indirect numbers in /QuadPoints now, which it used to drop. --- Cargo.lock | 3 + crates/annot/Cargo.toml | 2 + crates/annot/src/appearance.rs | 16 ++- crates/annot/src/info.rs | 11 +- crates/annot/src/tests.rs | 65 ++++++++++ crates/geom/src/lib.rs | 1 + crates/geom/src/quad_points.rs | 74 +++++++++++ crates/redact/Cargo.toml | 2 + crates/redact/src/lib.rs | 19 +-- crates/redact/src/tests.rs | 156 +++++++++++++++++++++++ crates/render/Cargo.toml | 4 +- crates/render/src/inspect/annotations.rs | 6 +- crates/render/src/inspect/tests.rs | 20 +++ 13 files changed, 363 insertions(+), 16 deletions(-) create mode 100644 crates/geom/src/quad_points.rs diff --git a/Cargo.lock b/Cargo.lock index 8617c45522..78fb437353 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4267,6 +4267,7 @@ dependencies = [ "hayro-syntax", "pdfcraft-cos", "pdfcraft-fonts", + "pdfcraft-geom", "pdfcraft-model", "thiserror 2.0.21", ] @@ -4627,6 +4628,7 @@ dependencies = [ "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-forms", + "pdfcraft-geom", "pdfcraft-model", "pdfcraft-render", "pdfcraft-testkit", @@ -4649,6 +4651,7 @@ dependencies = [ "lopdf", "pdfcraft-cos", "pdfcraft-fonts", + "pdfcraft-geom", "serde", "skrifa 0.44.0", "thiserror 2.0.21", diff --git a/crates/annot/Cargo.toml b/crates/annot/Cargo.toml index 8a31381493..e91c9ed896 100644 --- a/crates/annot/Cargo.toml +++ b/crates/annot/Cargo.toml @@ -11,6 +11,8 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } +# The usable-quad rule `/QuadPoints` readers share. +pdfcraft-geom = { workspace = true } pdfcraft-model = { workspace = true } thiserror = { workspace = true } diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 7148e9fc2e..c2b61eeba3 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -166,6 +166,14 @@ fn nums(d: &Dict, key: &[u8]) -> Option> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() } +/// The usable `/QuadPoints` quads, by the rule redaction apply reads them with +/// ([`pdfcraft_geom::quad_points::usable_quads`]): a malformed or non-finite quad is not drawn (it +/// would write `NaN` into the stream) and the others still are. `None` when none is usable. +fn quads(d: &Dict) -> Option> { + let q = pdfcraft_geom::quad_points::usable_quads(d.get(b"QuadPoints")?.as_array()?.iter().map(Object::as_f64)); + (!q.is_empty()).then_some(q) +} + /// `/C`-style colour arrays: none (transparent), gray, RGB or CMYK. fn color(d: &Dict, key: &[u8]) -> Option> { let c = match d.get(key) { @@ -565,9 +573,9 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) let subtype = context.subtype; let stroke = context.stroke; let c = &mut drawing.content; - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let q = quads(d)?; let col = stroke?; - for quad in q.as_chunks::<8>().0.iter() { + for quad in q.iter() { let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); @@ -612,10 +620,10 @@ fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawi let stroke = context.stroke; let c = &mut drawing.content; // While marked: the outline of each area (the fill comes when applied). - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let q = quads(d)?; let col = stroke.unwrap_or([0.89, 0.13, 0.13]); c.push_str(&format!("{}1 w\n", rg_stroke(col))); - for quad in q.as_chunks::<8>().0.iter() { + for quad in q.iter() { c.push_str(&format!( "{} {} m {} {} l {} {} l {} {} l h S\n", n(quad[0]), diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index ba181d7504..6696ad4b0f 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -117,7 +117,16 @@ pub fn summaries(doc: &Document) -> Vec { color = Some([f(i - 3), f(i - 2), f(i - 1)]); } } - let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec(); + // Only usable quads, by the rule redaction apply reads them with, so a mark's outline + // is the area apply removes (and with none, the `/Rect` both fall back to). + let quads: Vec<[f32; 8]> = d + .get(b"QuadPoints") + .map(|o| doc.resolve(o)) + .map(|a| { + let entries = a.as_array().into_iter().flatten().map(|x| doc.resolve(x).as_f64()); + pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|q| q.map(|v| v as f32)).collect() + }) + .unwrap_or_default(); // Keep in step with `pdfcraft_render::inspect`. let points = match subtype.as_str() { "Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(), diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index ff7f8925f0..d9983b8b41 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2779,3 +2779,68 @@ fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() { check(&doc); check(&reopen(&doc)); } + +const QUAD: [f64; 8] = [20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0]; + +fn reals(v: &[f64]) -> Vec { + v.iter().map(|x| Object::Real(*x)).collect() +} + +/// The appearance a `subtype` with `/QuadPoints` `quads` draws, as text. +fn quad_appearance(subtype: &str, quads: Vec) -> Option { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"Rect".to_vec(), Object::Array([20, 95, 40, 110].map(Object::Int).to_vec())); + d.set(b"C".to_vec(), Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0)])); + d.set(b"QuadPoints".to_vec(), Object::Array(quads)); + appearance::build(&d).map(|s| String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()) +} + +/// Redaction outlines and text markup draw the quads redaction apply would use, no more: a NaN +/// quad wrote `NaN` into the stream, and one non-number or a trailing partial quad left the other, +/// usable quads undrawn. +#[test] +fn appearances_draw_only_usable_quads() { + let mut one_nan = QUAD; + one_nan[3] = f64::NAN; + for subtype in ["Redact", "Highlight"] { + let clean = quad_appearance(subtype, reals(&QUAD)).unwrap(); + assert!(!clean.contains("NaN"), "{clean}"); + let mut name_first = reals(&[QUAD, QUAD].concat()); + name_first[2] = Object::name("oops"); + let same = [ + reals(&[one_nan, QUAD].concat()), + name_first, + reals(&[&QUAD[..], &QUAD[..4]].concat()), + reals(&[QUAD, [f64::INFINITY; 8]].concat()), + ]; + for (i, quads) in same.into_iter().enumerate() { + assert_eq!(quad_appearance(subtype, quads).as_deref(), Some(clean.as_str()), "{subtype} case {i}"); + } + // No usable quad draws nothing, as with no /QuadPoints. + assert_eq!(quad_appearance(subtype, reals(&[f64::NAN; 8])), None, "{subtype}"); + } +} + +/// The comment list reads `/QuadPoints` by the rule redaction apply does: unusable quads are left +/// out, not read as zeros, and an indirect number is followed. +#[test] +fn summaries_list_only_usable_quads() { + let mut doc = fixture(); + let forty = Object::Ref(doc.add(Object::Int(40))); + let page = page_refs(&doc).unwrap()[0]; + let mut entries = annots(&doc, page); + let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") }; + // A non-number in the first quad, an indirect 40 in the second, NaN in the third, and four + // entries of a fourth. + let mut quads = reals(&[QUAD, QUAD, QUAD, QUAD].concat()); + quads[1] = Object::name("oops"); + quads[10] = forty; + quads[17] = Object::Real(f64::NAN); + quads.truncate(28); + annotation.set(b"Subtype".to_vec(), Object::name("Redact")); + annotation.set(b"QuadPoints".to_vec(), Object::Array(quads)); + set_annots(&mut doc, page, entries).unwrap(); + let summary = summaries(&doc).into_iter().find(|a| a.page == 0 && a.index == 0).unwrap(); + assert_eq!(summary.quads, vec![QUAD.map(|v| v as f32)]); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 40f38498af..94ac7cdcc6 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod perspective; +pub mod quad_points; /// An axis-aligned rectangle in PDF user space (points). #[derive(Clone, Copy, Debug, PartialEq, Default)] diff --git a/crates/geom/src/quad_points.rs b/crates/geom/src/quad_points.rs new file mode 100644 index 0000000000..5acba5d0dc --- /dev/null +++ b/crates/geom/src/quad_points.rs @@ -0,0 +1,74 @@ +//! The quadrilaterals of a `/QuadPoints` array (ISO 32000-2 ยง12.5.6.10), read by one rule. +//! +//! Redaction apply, the comment summaries and outlines, the inspector and generated appearances +//! all read `/QuadPoints`; they go through [`usable_quads`] so the area a user sees marked is the +//! area apply removes. + +/// The usable quadrilaterals of a `/QuadPoints` array, given its entries in order (`None` for an +/// entry that is not a number). +/// +/// A quadrilateral is usable only when all eight of its entries are present, numbers, and within +/// the range of a PDF real (finite as an `f32`, Annex C), so the readers that keep `f32` see the +/// same set as those that keep `f64`. An unusable one is skipped whole: the ones after it keep +/// their coordinates, and it never becomes a folded box or zeros. A trailing partial group is +/// dropped. +pub fn usable_quads>>(entries: I) -> Vec<[f64; 8]> { + let entries: Vec> = entries.into_iter().collect(); + entries + .as_chunks::<8>() + .0 + .iter() + .filter_map(|q| { + let mut quad = [0.0; 8]; + for (slot, v) in quad.iter_mut().zip(q) { + // NaN and infinities stay non-finite as `f32`; so does a value past its range. + *slot = v.filter(|v| (*v as f32).is_finite())?; + } + Some(quad) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::usable_quads; + + const A: [f64; 8] = [10.0, 20.0, 50.0, 20.0, 10.0, 40.0, 50.0, 40.0]; + const B: [f64; 8] = [60.0, 70.0, 90.0, 70.0, 60.0, 80.0, 90.0, 80.0]; + + fn some(v: &[f64]) -> Vec> { + v.iter().copied().map(Some).collect() + } + + #[test] + fn finite_quads_are_all_usable() { + assert_eq!(usable_quads(some(&[A, B].concat())), vec![A, B]); + assert!(usable_quads(Vec::new()).is_empty()); + } + + #[test] + fn an_all_nan_quad_is_unusable() { + assert!(usable_quads(some(&[f64::NAN; 8])).is_empty()); + } + + #[test] + fn one_non_finite_entry_makes_its_quad_unusable() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300] { + let mut q = A; + q[3] = bad; + assert_eq!(usable_quads(some(&[q, B].concat())), vec![B], "{bad}"); + } + } + + #[test] + fn a_non_number_drops_its_quad_without_shifting_the_next() { + let mut entries = some(&[A, B].concat()); + entries[2] = None; + assert_eq!(usable_quads(entries), vec![B]); + } + + #[test] + fn a_trailing_partial_group_is_dropped() { + assert_eq!(usable_quads(some(&[&A[..], &B[..4]].concat())), vec![A]); + } +} diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 149d8d8295..c55d108440 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -13,6 +13,8 @@ aho-corasick = "1" pdfcraft-cos = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-fonts = { workspace = true } +# The usable-quad rule `/QuadPoints` readers share. +pdfcraft-geom = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-forms = { workspace = true } pdfcraft-edit = { workspace = true } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6d323004ad..e3b8e4f354 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -184,6 +184,14 @@ pub(crate) fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { (v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) } +/// A mark's usable `/QuadPoints` quads ([`pdfcraft_geom::quad_points::usable_quads`]), the set the +/// comment outlines show. Each one is finite, so the box it folds to is a real one. +fn quad_points(doc: &Document, d: &Dict) -> Vec<[f64; 8]> { + let Some(q) = d.get(b"QuadPoints").map(|q| doc.resolve(q)) else { return Vec::new() }; + let Some(a) = q.as_array() else { return Vec::new() }; + pdfcraft_geom::quad_points::usable_quads(a.iter().map(|x| doc.resolve(x).as_f64())) +} + fn color(doc: &Document, d: &Dict, key: &[u8]) -> Option { let v: Vec = doc.resolve(d.get(key)?).as_array()?.iter().filter_map(Object::as_f64).collect(); match v.len() { @@ -228,13 +236,7 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { if d.name(b"Subtype") != Some(b"Redact") { continue; } - let quads: Vec = d - .get(b"QuadPoints") - .and_then(|q| doc.resolve(q).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())) - .unwrap_or_default(); - let mut rects: Vec<[f64; 4]> = quads - .as_chunks::<8>() - .0 + let mut rects: Vec<[f64; 4]> = quad_points(doc, d) .iter() .map(|q| { let xs = [q[0], q[2], q[4], q[6]]; @@ -246,8 +248,9 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { ys.iter().copied().fold(f64::MIN, f64::max), ] }) - .filter(|r| r.iter().all(|v| v.is_finite())) .collect(); + // No usable quad (all absent, malformed or non-finite): the mark's `/Rect`, else it + // is unreadable and apply refuses it. if rects.is_empty() && let Some(r) = rect_of(doc, d.get(b"Rect")) { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 8978e0e7d6..51c51eca62 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2258,6 +2258,162 @@ fn an_unreadable_mark_on_another_page_does_not_stop_the_selected_page() { assert!(!content(&doc, 0).contains("1234")); } +/// The quad over "1234" in `AB1234CD` (x 20โ€“40) and the one over "CD" (x 40โ€“50). +const OVER_1234: [f64; 8] = [20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0]; +const OVER_CD: [f64; 8] = [40.0, 110.0, 50.0, 110.0, 40.0, 95.0, 50.0, 95.0]; + +/// `AB1234CD` with one mark whose `/QuadPoints` is `quads`, whose `/Rect` is `rect` (removed when +/// `None`) and which has a fill (`/IC`) only when `fill` is set. +fn quad_mark(quads: Vec, rect: Option<[i64; 4]>, fill: bool) -> (Document, ObjRef) { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", "", vec![]); + mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); + let m = marks(&doc).swap_remove(0); + doc.update_dict(m.obj, |d| { + d.set(b"QuadPoints".to_vec(), Object::Array(quads)); + if let Some(r) = rect { + d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Int(*v)).collect())); + } else { + d.remove(b"Rect"); + } + if !fill { + d.remove(b"IC"); + } + }) + .unwrap(); + (doc, m.obj) +} + +fn reals(v: &[f64]) -> Vec { + v.iter().map(|x| Object::Real(*x)).collect() +} + +/// An all-NaN quad folded to `[MAX, MAX, MIN, MIN]`: a finite, inverted box that passed as a +/// readable area. Without a fill the mark was applied and removed nothing; with one the overlay +/// failed. With no `/Rect` either, the mark has no area and apply refuses it. +#[test] +fn an_all_nan_quad_without_a_rect_is_refused_not_applied_as_nothing() { + for fill in [false, true] { + let (mut doc, _) = quad_mark(reals(&[f64::NAN; 8]), None, fill); + assert!(marks(&doc).is_empty(), "fill {fill}: the NaN quad is no area"); + assert_eq!(apply(&mut doc, None), Err(RedactError::UnreadableMark(1)), "fill {fill}"); + assert!(content(&doc, 0).contains("(AB1234CD) Tj"), "fill {fill}: nothing changed"); + } +} + +/// With a usable `/Rect`, an all-NaN mark redacts exactly the `/Rect`. +#[test] +fn an_all_nan_quad_falls_back_to_the_rect() { + for fill in [false, true] { + let (mut doc, _) = quad_mark(reals(&[f64::NAN; 8]), Some([20, 95, 40, 110]), fill); + assert_eq!(marks(&doc)[0].rects, vec![[20.0, 95.0, 40.0, 110.0]], "fill {fill}"); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.marks, r.glyphs), (1, 4), "fill {fill}"); + assert!(!content(&doc, 0).contains("1234"), "fill {fill}"); + } +} + +/// One NaN among finite entries made `f64::min`/`max` ignore it and fold the other three corners: +/// a quad the document never described. Such a quad is unusable. +#[test] +fn a_quad_with_one_nan_entry_is_unusable() { + let mut q = OVER_1234; + q[0] = f64::NAN; + let (mut doc, _) = quad_mark(reals(&q), None, false); + assert!(marks(&doc).is_empty()); + assert_eq!(apply(&mut doc, None), Err(RedactError::UnreadableMark(1))); + // Next to a usable quad, only that one applies. + let (mut doc, _) = quad_mark(reals(&[q, OVER_CD].concat()), None, false); + assert_eq!(marks(&doc)[0].rects, vec![[40.0, 95.0, 50.0, 110.0]]); + assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(2)); +} + +/// A name in the middle of `/QuadPoints` was dropped, which moved every later coordinate into the +/// wrong slot and folded the first quad to a box over most of the page. +#[test] +fn a_non_number_entry_drops_its_quad_without_shifting_the_rest() { + let mut quads = reals(&[OVER_1234, OVER_CD].concat()); + quads[2] = Object::name("oops"); + let (mut doc, _) = quad_mark(quads, None, false); + assert_eq!(marks(&doc)[0].rects, vec![[40.0, 95.0, 50.0, 110.0]]); + assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(2)); +} + +/// Twelve entries are one quad and half of another: only the whole one applies. +#[test] +fn a_trailing_partial_quad_is_ignored() { + let (mut doc, _) = quad_mark(reals(&[&OVER_1234[..], &OVER_CD[..4]].concat()), None, false); + assert_eq!(marks(&doc)[0].rects, vec![[20.0, 95.0, 40.0, 110.0]]); + assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(4)); +} + +/// Finite quads redact what they always did, one box each, and the `/Rect` is not consulted. +#[test] +fn finite_quads_redact_their_areas_as_before() { + let (mut doc, _) = quad_mark(reals(&[OVER_1234, OVER_CD].concat()), Some([0, 0, 1, 1]), true); + assert_eq!(marks(&doc)[0].rects, vec![[20.0, 95.0, 40.0, 110.0], [40.0, 95.0, 50.0, 110.0]]); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.marks, r.glyphs), (1, 6)); + assert!(!content(&doc, 0).contains("1234")); +} + +/// The boxes a reader's quads outline, or its `/Rect` when it has none (as the comment outlines +/// fall back to it). +fn outline(quads: &[[f32; 8]], rect: [f32; 4]) -> Vec<[f32; 4]> { + if quads.is_empty() { + return vec![rect]; + } + quads + .iter() + .map(|q| { + let (xs, ys) = ([q[0], q[2], q[4], q[6]], [q[1], q[3], q[5], q[7]]); + let (lo, hi) = (|v: [f32; 4]| v.into_iter().fold(f32::MAX, f32::min), |v: [f32; 4]| v.into_iter().fold(f32::MIN, f32::max)); + [lo(xs), lo(ys), hi(xs), hi(ys)] + }) + .collect() +} + +/// The outline the comment list draws (`pdfcraft_annot::summaries`, and the inspector's listing +/// of the saved file) is the area apply removes, for malformed `/QuadPoints` too. The readers +/// used to disagree: the summaries and the inspector read non-numbers as zeros, apply dropped +/// them. +#[test] +fn the_outline_shown_is_the_area_apply_removes() { + // (entries, whether a saved file can say them: PDF syntax has no NaN). `forty` is an indirect + // number, which every reader follows. + let cases = |forty: ObjRef| -> Vec<(Vec, bool)> { + let mut name_inside = reals(&[OVER_1234, OVER_CD].concat()); + name_inside[2] = Object::name("oops"); + let mut by_reference = reals(&OVER_1234); + by_reference[2] = Object::Ref(forty); + let mut one_nan = OVER_1234; + one_nan[5] = f64::NAN; + vec![ + (reals(&[OVER_1234, OVER_CD].concat()), true), + (name_inside, true), + (reals(&[&OVER_1234[..], &OVER_CD[..4]].concat()), true), + (by_reference, true), + (vec![Object::name("oops"); 8], true), + (reals(&[one_nan, OVER_CD].concat()), false), + (reals(&[f64::NAN; 8]), false), + ] + }; + for i in 0.. { + let (mut doc, obj) = quad_mark(Vec::new(), Some([20, 95, 40, 110]), true); + let forty = doc.add(Object::Int(40)); + let Some((quads, saved)) = cases(forty).into_iter().nth(i) else { break }; + doc.update_dict(obj, |d| d.set(b"QuadPoints".to_vec(), Object::Array(quads))).unwrap(); + let applied: Vec<[f32; 4]> = marks(&doc)[0].rects.iter().map(|r| r.map(|v| v as f32)).collect(); + let summary = pdfcraft_annot::summaries(&doc).into_iter().find(|s| s.subtype == "Redact").unwrap(); + assert_eq!(outline(&summary.quads, summary.rect), applied, "case {i}: comment summaries"); + if saved { + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let info = pdfcraft_render::inspect(Arc::new(bytes), None).unwrap(); + let listed = info.annotations.iter().find(|a| a.subtype == "Redact").unwrap(); + assert_eq!(outline(&listed.quads, listed.rect), applied, "case {i}: inspector"); + } + } +} + /// A document with something in every hidden-information category. fn hidden_fixture() -> Document { let content = b"BT /F1 10 Tf 10 100 Td (Visible) Tj 3 Tr (Hidden) Tj 0 Tr ET BT /F1 10 Tf 500 500 Td (Offpage) Tj ET /OC /L1 BDC BT /F1 10 Tf 10 50 Td (Layer) Tj ET EMC"; diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 71467dfeb2..65c94852e6 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -11,7 +11,9 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } -hayro = { workspace = true } +# The usable-quad rule `/QuadPoints` readers share. +pdfcraft-geom = { workspace = true } +hayro ={ workspace = true } hayro-syntax = { workspace = true } # Picks the face of an installed font collection (the version pdfcraft-fonts already uses). skrifa = "0.44" diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 603aa88110..7c4543b733 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -138,10 +138,12 @@ impl<'a> Inspector<'a> { color = Some([f(i - 3), f(i - 2), f(i - 1)]); } } + // Only usable quads, by the rule redaction apply reads them with (keep in step with + // `pdfcraft_annot::summaries`): a malformed one is not drawn as zeros. let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { Ok(Object::Array(q)) => { - let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - v.as_chunks::<8>().0.to_vec() + let entries = q.iter().map(|o| self.resolve(o).as_float().ok().map(f64::from)); + pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|quad| quad.map(|v| v as f32)).collect() } _ => Vec::new(), }; diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 7221e97d5a..907401f8bb 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -805,6 +805,26 @@ impl Structure for CountingReads<'_> { } } +/// `/QuadPoints` is read by the rule redaction apply uses (`pdfcraft_geom::quad_points`): a quad +/// with a non-number is left out rather than drawn with a zero, an indirect number is followed, +/// and a trailing partial quad is dropped. +#[test] +fn only_usable_quads_are_listed() { + let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >> endobj\n\ + 4 0 obj 40 endobj\n\ + 5 0 obj << /Type /Annot /Subtype /Redact /Rect [20 95 40 110] /QuadPoints \ + [20 /oops 40 110 20 95 40 95 20 110 4 0 R 110 20 95 40 95 20 110 40 110] >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF"; + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + let mut info = DocInfo { pages: vec![page], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + let mark = info.annotations.iter().find(|a| a.subtype == "Redact").expect("listed"); + assert_eq!(mark.quads, vec![[20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0]]); +} + /// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many /// small comments sharing one large `/Contents` string stop at the retained-bytes budget. #[test]