redact: treat unusable /QuadPoints as absent in every reader

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.
This commit is contained in:
Josias Eli Rodriguez committed 2026-11-07 03:04:51 -06:00
1 parent f42fda7342
commit 6e0d0fde3f
13 files changed
+363 -16

No files matched your search

Generated
+3
View File
@@ -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",
+2
View File
@@ -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 }
+12 -4
View File
@@ -166,6 +166,14 @@ fn nums(d: &Dict, key: &[u8]) -> Option<Vec<f64>> {
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<Vec<[f64; 8]>> {
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<Option<Rgb>> {
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]),
+10 -1
View File
@@ -117,7 +117,16 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
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(),
+65
View File
@@ -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<Object> {
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<Object>) -> Option<String> {
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)]);
}
+1
View File
@@ -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)]
+74
View File
@@ -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<I: IntoIterator<Item = Option<f64>>>(entries: I) -> Vec<[f64; 8]> {
let entries: Vec<Option<f64>> = 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<Option<f64>> {
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]);
}
}
+2
View File
@@ -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 }
+11 -8
View File
@@ -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<Rgb> {
let v: Vec<f64> = 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<Mark>, Vec<usize>) {
if d.name(b"Subtype") != Some(b"Redact") {
continue;
}
let quads: Vec<f64> = 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<Mark>, Vec<usize>) {
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"))
{
+156
View File
@@ -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<Object>, 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<Object> {
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<Object>, 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";
+3 -1
View File
@@ -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"
+4 -2
View File
@@ -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<f32> = 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(),
};
+20
View File
@@ -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]