mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 09:34:47 +00:00
fix(audit): read operands from the end and every rectangle a fill paints
The cover walk read the first operand of g, gs and Do, while the renderer takes operands from the end, so surplus operands could make the audit see a white cover, a translucent state or an empty form where the page paints an opaque black rectangle. It also kept only the last subpath of a path, so a cover followed by another rectangle in the same fill went unseen. g now reads its last operand like rg and k, and gs and Do use last_name. The path keeps every rectangular subpath with its winding; a fill paints them all, and where the even-odd rule or opposite windings can cut holes the painted region is worked out on the rectangles' edge grid, bounded per page.
This commit is contained in:
+165
-54
@@ -1,10 +1,13 @@
|
||||
//! Walk page content streams and collect the opaque filled rectangles they draw.
|
||||
//!
|
||||
//! This is the geometric half of the faux-redaction audit: a rectangle filled with an opaque
|
||||
//! color (`re` + `f`) is the classic way a "redaction" is faked. A single closed `m`/`l` subpath
|
||||
//! whose four corners form an axis-aligned rectangle in user space is the same shape and counts
|
||||
//! too (#1510). Graphics state tracked here is deliberately small: the CTM stack (`q`/`Q`/`cm`),
|
||||
//! the non-stroking color (`g`/`rg`/`k`) and the non-stroking alpha from ExtGState (`gs` reading
|
||||
//! color (`re` + `f`) is the classic way a "redaction" is faked. A closed `m`/`l` subpath whose
|
||||
//! four corners form an axis-aligned rectangle in user space is the same shape and counts too
|
||||
//! (#1510). A fill paints every subpath of its path, so each rectangular subpath counts, with
|
||||
//! the holes the even-odd rule or opposite windings cut out of overlapping ones; other subpaths
|
||||
//! in the same path are not read and cut no holes. Operands are taken from the end of the
|
||||
//! operand list, as the renderer takes them. Graphics state tracked here is deliberately small:
|
||||
//! the CTM stack (`q`/`Q`/`cm`), the non-stroking color (`g`/`rg`/`k`) and the non-stroking alpha from ExtGState (`gs` reading
|
||||
//! `/ca`). Form XObjects are followed read-only with a depth cap. Anything fancier (other filled paths, shadings, patterns) is
|
||||
//! out of scope and documented as such.
|
||||
//!
|
||||
@@ -46,32 +49,52 @@ struct Gs {
|
||||
alpha: f64,
|
||||
}
|
||||
|
||||
/// The path under construction, as far as the audit cares about it.
|
||||
enum Path {
|
||||
Empty,
|
||||
/// A `re` rectangle, in user space.
|
||||
Rect([f64; 4]),
|
||||
/// One `m`/`l` subpath, corners already mapped to user space; `true` once closed by `h`.
|
||||
Lines(Vec<[f64; 2]>, bool),
|
||||
/// Anything else: curves, a second subpath, a CTM change mid-path.
|
||||
Other,
|
||||
/// Most subpaths in one path whose painted region is worked out exactly when the fill rule can
|
||||
/// leave holes (even-odd, or windings that disagree). Past it every rectangular subpath counts
|
||||
/// as painted: the audit may then flag text in a hole, but never misses a cover.
|
||||
const MAX_EXACT_SUBPATHS: usize = 32;
|
||||
/// Bound on the grid cells times subpaths [`painted`] examines per page.
|
||||
const MAX_PATH_WORK: usize = 100_000_000;
|
||||
|
||||
/// The path under construction, as far as the audit cares about it: every closed rectangular
|
||||
/// subpath it holds, so a fill paints all of them (ISO 32000-2 §8.5.3.3), not just the last.
|
||||
#[derive(Default)]
|
||||
struct Path {
|
||||
/// Rectangular subpaths so far, in user space, each with its winding direction (+1
|
||||
/// counterclockwise, -1 clockwise).
|
||||
rects: Vec<([f64; 4], i8)>,
|
||||
/// The `m`/`l` subpath being built, corners already mapped to user space; `true` once closed
|
||||
/// by `h`. `None` when there is none, or when it can no longer be a rectangle.
|
||||
lines: Option<(Vec<[f64; 2]>, bool)>,
|
||||
/// A CTM change mid-path, or a clip: the path is not read at all.
|
||||
unreadable: bool,
|
||||
}
|
||||
|
||||
impl Path {
|
||||
/// The rectangle a fill of this path paints, if the path is one.
|
||||
fn rect(self) -> Option<[f64; 4]> {
|
||||
match self {
|
||||
Path::Rect(r) => Some(r),
|
||||
Path::Lines(pts, _) => lines_rect(pts),
|
||||
Path::Empty | Path::Other => None,
|
||||
fn is_empty(&self) -> bool {
|
||||
self.rects.is_empty() && self.lines.is_none() && !self.unreadable
|
||||
}
|
||||
|
||||
/// Ends the `m`/`l` subpath being built, keeping it when it is a rectangle.
|
||||
fn close_lines(&mut self) {
|
||||
if let Some((pts, _)) = self.lines.take()
|
||||
&& let Some(r) = lines_rect(pts)
|
||||
{
|
||||
self.rects.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rectangular subpaths a fill of this path paints, in user space.
|
||||
fn finish(mut self) -> Vec<([f64; 4], i8)> {
|
||||
self.close_lines();
|
||||
if self.unreadable { Vec::new() } else { self.rects }
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounding box of a closed polygon that is an axis-aligned rectangle, or `None`. A repeated
|
||||
/// start point is dropped first; the remaining four corners must alternate horizontal and
|
||||
/// vertical edges.
|
||||
fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<[f64; 4]> {
|
||||
/// The bounding box of a closed polygon that is an axis-aligned rectangle, with its winding
|
||||
/// direction, or `None`. A repeated start point is dropped first; the remaining four corners
|
||||
/// must alternate horizontal and vertical edges.
|
||||
fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<([f64; 4], i8)> {
|
||||
// CTM products leave rounding noise, so corners compare with a tolerance (in points).
|
||||
const EPS: f64 = 1e-6;
|
||||
let same = |a: f64, b: f64| (a - b).abs() <= EPS;
|
||||
@@ -95,7 +118,81 @@ fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<[f64; 4]> {
|
||||
xs.into_iter().fold(f64::NEG_INFINITY, f64::max),
|
||||
ys.into_iter().fold(f64::NEG_INFINITY, f64::max),
|
||||
];
|
||||
(r[2] - r[0] > EPS && r[3] - r[1] > EPS).then_some(r)
|
||||
// Shoelace: twice the signed area, positive when the corners run counterclockwise.
|
||||
let twice_area: f64 = [(a, b), (b, c), (c, d), (d, a)].iter().map(|(p, q)| p[0] * q[1] - q[0] * p[1]).sum();
|
||||
let winding = if twice_area < 0.0 { -1 } else { 1 };
|
||||
(r[2] - r[0] > EPS && r[3] - r[1] > EPS).then_some((r, winding))
|
||||
}
|
||||
|
||||
/// The region a fill paints from rectangular subpaths `rects`, as rectangles. Under the nonzero
|
||||
/// rule, subpaths that all wind the same way paint their union, so each is painted whole.
|
||||
/// Otherwise overlaps can cancel (an even-odd frame is an outer rectangle with an inner hole), so
|
||||
/// the region is worked out on the grid the rectangles' edges make, charging the cells examined
|
||||
/// to `work`.
|
||||
pub(crate) fn painted(rects: Vec<([f64; 4], i8)>, even_odd: bool, work: &mut usize) -> Result<Vec<[f64; 4]>, AuditError> {
|
||||
let same_winding = rects.windows(2).all(|w| w[0].1 == w[1].1);
|
||||
if rects.len() <= 1 || rects.len() > MAX_EXACT_SUBPATHS {
|
||||
return Ok(rects.into_iter().map(|(r, _)| r).collect());
|
||||
}
|
||||
if !even_odd && same_winding {
|
||||
// The union: a rectangle inside another (or the same one again) adds nothing to it, and
|
||||
// would only report the same cover twice.
|
||||
let inside = |a: &[f64; 4], b: &[f64; 4]| b[0] <= a[0] && b[1] <= a[1] && a[2] <= b[2] && a[3] <= b[3];
|
||||
let kept = rects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, (r, _))| !rects.iter().enumerate().any(|(j, (o, _))| j != *i && inside(r, o) && (!inside(o, r) || j < *i)));
|
||||
return Ok(kept.map(|(_, (r, _))| *r).collect());
|
||||
}
|
||||
let edges = |lo: usize, hi: usize| {
|
||||
let mut v: Vec<f64> = rects.iter().flat_map(|(r, _)| [r[lo], r[hi]]).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
let (xs, ys) = (edges(0, 2), edges(1, 3));
|
||||
let cells = xs.len().saturating_sub(1).saturating_mul(ys.len().saturating_sub(1));
|
||||
*work = work.saturating_add(cells.saturating_mul(rects.len()));
|
||||
if *work > MAX_PATH_WORK {
|
||||
return Err(AuditError::TooManyOps);
|
||||
}
|
||||
let filled = |cx: f64, cy: f64| {
|
||||
let inside = rects.iter().filter(|(r, _)| r[0] < cx && cx < r[2] && r[1] < cy && cy < r[3]);
|
||||
if even_odd { inside.count() % 2 == 1 } else { inside.map(|(_, w)| i64::from(*w)).sum::<i64>() != 0 }
|
||||
};
|
||||
// Filled cells, merged into horizontal runs per grid row, and runs with the same span in
|
||||
// adjacent rows merged into one rectangle.
|
||||
let mut out: Vec<[f64; 4]> = Vec::new();
|
||||
let mut open: Vec<usize> = Vec::new();
|
||||
for row in ys.windows(2) {
|
||||
let (y0, y1) = (row[0], row[1]);
|
||||
let mut runs: Vec<(f64, f64)> = Vec::new();
|
||||
for col in xs.windows(2) {
|
||||
if !filled((col[0] + col[1]) / 2.0, (y0 + y1) / 2.0) {
|
||||
continue;
|
||||
}
|
||||
match runs.last_mut() {
|
||||
Some(run) if run.1 == col[0] => run.1 = col[1],
|
||||
_ => runs.push((col[0], col[1])),
|
||||
}
|
||||
}
|
||||
let mut next_open = Vec::with_capacity(runs.len());
|
||||
for (x0, x1) in runs {
|
||||
let grown = open.iter().copied().find(|&i| out.get(i).is_some_and(|r| r[0] == x0 && r[2] == x1 && r[3] == y0));
|
||||
match grown.and_then(|i| out.get_mut(i).map(|r| (i, r))) {
|
||||
Some((i, r)) => {
|
||||
r[3] = y1;
|
||||
next_open.push(i);
|
||||
}
|
||||
None => {
|
||||
out.push([x0, y0, x1, y1]);
|
||||
next_open.push(out.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
open = next_open;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Default for Gs {
|
||||
@@ -109,6 +206,8 @@ struct Walker<'a> {
|
||||
pieces: &'a Pieces,
|
||||
out: Vec<FilledRect>,
|
||||
ops_seen: usize,
|
||||
/// Grid cells times subpaths examined working out what compound paths paint ([`painted`]).
|
||||
path_work: usize,
|
||||
/// Decoded bytes and form entries left for the page, its forms included: spending either
|
||||
/// fails the audit.
|
||||
budget: DecodeBudget,
|
||||
@@ -135,7 +234,7 @@ impl<'a> Walker<'a> {
|
||||
depth: usize,
|
||||
at_do: Option<(usize, usize)>,
|
||||
) -> Result<(), AuditError> {
|
||||
let mut path = Path::Empty;
|
||||
let mut path = Path::default();
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
self.bump()?;
|
||||
// Paint position of this operator: its own `(piece, joined index)` at page level,
|
||||
@@ -162,12 +261,14 @@ impl<'a> Walker<'a> {
|
||||
}
|
||||
// A `cm` that leaves the CTM unchanged (an identity matrix) cannot move the
|
||||
// pending path, so keep it for the fill (#1333).
|
||||
if gs.ctm != before && !matches!(path, Path::Empty) {
|
||||
path = Path::Other;
|
||||
if gs.ctm != before && !path.is_empty() {
|
||||
path.unreadable = true;
|
||||
}
|
||||
}
|
||||
b"g" => {
|
||||
if let Some(v) = op.num(0).filter(|v| v.is_finite()) {
|
||||
// The renderer takes operands from the end and ignores any surplus before
|
||||
// them, so the gray level is the last operand, as for `rg` and `k`.
|
||||
if let Some([v]) = op.nums::<1>().filter(|v| v[0].is_finite()) {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
gs.fill = [v, v, v];
|
||||
}
|
||||
@@ -189,63 +290,73 @@ impl<'a> Walker<'a> {
|
||||
}
|
||||
}
|
||||
b"gs" => {
|
||||
if let Some(name) = op.name(0) {
|
||||
if let Some(name) = op.last_name() {
|
||||
gs.alpha = ext_alpha(self.doc, resources, name);
|
||||
}
|
||||
}
|
||||
b"re" => {
|
||||
// `re` is a complete subpath of its own: it ends any `m`/`l` subpath and
|
||||
// joins the rectangles already in the path, which one fill paints together.
|
||||
path.close_lines();
|
||||
let rect = op.nums::<4>().and_then(|[x, y, w, h]| {
|
||||
if [x, y, w, h].iter().all(|v| v.is_finite()) && w != 0.0 && h != 0.0 {
|
||||
let r = gs.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]);
|
||||
(r[2] > r[0] && r[3] > r[1]).then_some(r)
|
||||
let [a, b, c, d, ..] = gs.ctm.0;
|
||||
// Counterclockwise in pattern space when `w` and `h` share a sign;
|
||||
// a mirroring CTM turns it around.
|
||||
let winding = if (w * h).signum() * (a * d - b * c).signum() < 0.0 { -1 } else { 1 };
|
||||
(r[2] > r[0] && r[3] > r[1]).then_some((r, winding))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
path = rect.map_or(Path::Other, Path::Rect);
|
||||
if let Some(r) = rect {
|
||||
path.rects.push(r);
|
||||
}
|
||||
}
|
||||
b"m" => {
|
||||
path = match (path, user_point(gs, op)) {
|
||||
(Path::Empty, Some(p)) => Path::Lines(vec![p], false),
|
||||
_ => Path::Other,
|
||||
};
|
||||
path.close_lines();
|
||||
path.lines = user_point(gs, op).map(|p| (vec![p], false));
|
||||
}
|
||||
b"l" => {
|
||||
path = match (path, user_point(gs, op)) {
|
||||
path.lines = match (path.lines.take(), user_point(gs, op)) {
|
||||
// Past a repeated start point it can no longer be a rectangle.
|
||||
(Path::Lines(mut pts, false), Some(p)) if pts.len() < 5 => {
|
||||
(Some((mut pts, false)), Some(p)) if pts.len() < 5 => {
|
||||
pts.push(p);
|
||||
Path::Lines(pts, false)
|
||||
Some((pts, false))
|
||||
}
|
||||
_ => Path::Other,
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
b"h" => {
|
||||
path = match path {
|
||||
Path::Lines(pts, _) => Path::Lines(pts, true),
|
||||
// `re` already closed its subpath, so a further `h` changes nothing.
|
||||
Path::Rect(r) => Path::Rect(r),
|
||||
_ => Path::Other,
|
||||
};
|
||||
// `re` already closed its subpath, so a further `h` changes nothing.
|
||||
if let Some((_, closed)) = &mut path.lines {
|
||||
*closed = true;
|
||||
}
|
||||
}
|
||||
// `b`/`b*` close the path before filling it, which the fill does anyway.
|
||||
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
|
||||
if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect()
|
||||
&& gs.alpha >= 1.0
|
||||
{
|
||||
self.out.push(FilledRect { rect: r, rgb: gs.fill, pos });
|
||||
let even_odd = op.op.ends_with(b"*");
|
||||
let rects = std::mem::take(&mut path).finish();
|
||||
if gs.alpha >= 1.0 {
|
||||
for r in painted(rects, even_odd, &mut self.path_work)? {
|
||||
self.out.push(FilledRect { rect: r, rgb: gs.fill, pos });
|
||||
}
|
||||
}
|
||||
}
|
||||
b"S" | b"s" | b"n" => path = Path::Empty,
|
||||
b"c" | b"v" | b"y" | b"W" | b"W*" => path = Path::Other,
|
||||
b"S" | b"s" | b"n" => path = Path::default(),
|
||||
// A curve leaves the subpath being built no rectangle; other subpaths stand.
|
||||
b"c" | b"v" | b"y" => path.lines = None,
|
||||
// A clipping path is not read: what it lets through is out of scope.
|
||||
b"W" | b"W*" => path.unreadable = true,
|
||||
b"Do" => {
|
||||
if let Some(name) = op.name(0) {
|
||||
if let Some(name) = op.last_name() {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(AuditError::TooDeep);
|
||||
}
|
||||
self.walk_xobject(name, gs, resources, depth, pos)?;
|
||||
}
|
||||
path = Path::Empty;
|
||||
path = Path::default();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -331,7 +442,7 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
|
||||
}
|
||||
let pieces = Pieces::join(&streams);
|
||||
let parsed = pieces.parse();
|
||||
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget };
|
||||
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, path_work: 0, budget };
|
||||
let mut gs = Gs::default();
|
||||
let mut stack = Vec::new();
|
||||
w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
|
||||
|
||||
@@ -820,3 +820,101 @@ fn form_entries_past_the_page_allowance_fail_the_audit() {
|
||||
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
|
||||
assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms)));
|
||||
}
|
||||
|
||||
/// The renderer takes an operator's operands from the end and ignores surplus ones before them:
|
||||
/// `0 1 g` paints white, `1 0 g` black. The audit reads the same operand, so surplus operands do
|
||||
/// not turn a black cover white, or a white one black.
|
||||
#[test]
|
||||
fn surplus_gray_operands_are_read_from_the_end() {
|
||||
for (fill, expected) in [("1 0 g", 1), ("0 1 g", 0)] {
|
||||
let content = format!("BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n{fill}\n100 688 220 32 re f\n");
|
||||
let findings = audit_redactions(&open(one_page(content.as_bytes(), ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), expected, "{fill}: {findings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `Do` and `gs` act on their last operand: a surplus name before it must not make the audit
|
||||
/// inspect another form or graphics state than the one the page draws with.
|
||||
#[test]
|
||||
fn surplus_name_operands_are_read_from_the_end() {
|
||||
let page = |content: &[u8]| {
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> \
|
||||
/XObject << /Empty 6 0 R /Cover 7 0 R >> /ExtGState << /Clear << /ca 0.2 >> /Solid << /ca 1 >> >> >> >>",
|
||||
);
|
||||
b.add_stream(content);
|
||||
b.add(FONT);
|
||||
b.add_stream_with(b"", "/Type /XObject /Subtype /Form /BBox [0 0 612 792]");
|
||||
b.add_stream_with(b"0 g\n100 688 220 32 re f\n", "/Type /XObject /Subtype /Form /BBox [0 0 612 792]");
|
||||
b.build()
|
||||
};
|
||||
let text = "BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
|
||||
for (draw, expected) in [
|
||||
("/Empty /Cover Do", 1),
|
||||
("/Cover /Empty Do", 0),
|
||||
("/Clear /Solid gs 0 g 100 688 220 32 re f", 1),
|
||||
("/Solid /Clear gs 0 g 100 688 220 32 re f", 0),
|
||||
] {
|
||||
let findings = audit_redactions(&open(page(format!("{text}{draw}\n").as_bytes()))).expect("audit must run");
|
||||
assert_eq!(findings.len(), expected, "{draw}: {findings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One fill paints every subpath of its path: a cover drawn before another rectangle in the
|
||||
/// same path is still a cover, however the rectangles are drawn.
|
||||
#[test]
|
||||
fn every_rectangle_in_a_compound_path_is_a_cover() {
|
||||
let covers: [&[u8]; 4] = [
|
||||
b"0 g\n100 688 220 32 re 400 100 10 10 re f\n",
|
||||
b"0 g\n100 688 220 32 re 400 100 10 10 re f*\n",
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h 400 100 10 10 re f\n",
|
||||
b"0 g\n100 688 220 32 re 400 100 m 410 100 l 410 110 l 400 110 l h f\n",
|
||||
];
|
||||
for cover in covers {
|
||||
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
|
||||
content.extend_from_slice(cover);
|
||||
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover));
|
||||
assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0], "{:?}", String::from_utf8_lossy(cover));
|
||||
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
|
||||
}
|
||||
}
|
||||
|
||||
/// Where compound subpaths overlap, the fill rule decides what is painted: a hole the even-odd
|
||||
/// rule, or a subpath winding the other way under the nonzero rule, cuts out of a cover leaves the
|
||||
/// text in it visible, while the same rectangles winding one way under the nonzero rule paint
|
||||
/// their union.
|
||||
#[test]
|
||||
fn holes_in_a_compound_path_are_not_covers() {
|
||||
let outer = "90 680 240 50 re";
|
||||
let cases: [(String, usize); 4] = [
|
||||
// Even-odd: the inner rectangle is a hole.
|
||||
(format!("{outer} 100 688 220 32 re f*"), 0),
|
||||
// Nonzero, inner drawn clockwise (negative width): a hole.
|
||||
(format!("{outer} 320 688 -220 32 re f"), 0),
|
||||
// Nonzero, both counterclockwise: the union, the outer frame and all, is painted.
|
||||
(format!("{outer} 100 688 220 32 re f"), 1),
|
||||
// A cover beside the hole is still found.
|
||||
(format!("{outer} 100 688 220 32 re 100 600 220 32 re f*"), 1),
|
||||
];
|
||||
for (path, expected) in cases {
|
||||
let content = format!("BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\nBT /F1 24 Tf 100 610 Td (OTHER) Tj ET\n0 g\n{path}\n");
|
||||
let findings = audit_redactions(&open(one_page(content.as_bytes(), ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), expected, "{path}: {findings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The painted region of a compound path, rectangle by rectangle: an even-odd frame is four
|
||||
/// strips around its hole.
|
||||
#[test]
|
||||
fn an_even_odd_frame_paints_four_strips() {
|
||||
let mut work = 0;
|
||||
let frame = vec![([0.0, 0.0, 10.0, 10.0], 1), ([2.0, 2.0, 8.0, 8.0], 1)];
|
||||
let mut strips = crate::cover::painted(frame, true, &mut work).expect("within the bound");
|
||||
strips.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
|
||||
assert_eq!(strips, [[0.0, 0.0, 10.0, 2.0], [0.0, 2.0, 2.0, 8.0], [0.0, 8.0, 10.0, 10.0], [8.0, 2.0, 10.0, 8.0]]);
|
||||
assert!(work > 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user