diff --git a/crates/redact/README.md b/crates/redact/README.md index 34c9f4fe1f6..e2f5c3b422f 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -15,8 +15,11 @@ clear_marks(&mut doc, None)?; // remove marks without app same advance, so the rest of the line keeps its exact position. Widths come from `/Widths`, `/W`/`/DW` (composite fonts, embedded CMaps with codespace and CID ranges), Type 3 font matrices, and approximations for the standard 14. A glyph goes when it overlaps an area by - more than a fifth of its size (or a point) both ways; zero-size text goes when its origin is - inside; + more than a fifth of its size (or a point) both ways, its height taken from descent to ascent + but at most one em: lines set solid in fonts whose one-em boxes don't overlap keep the glyphs + next to a mark (mixed fonts can still overlap within one em). Glyphs kept although their full + box reaches a mark are counted in `Report::glyphs_beside_marks`, since any ink they draw + outside one em stays in the file; zero-size text goes when its origin is inside; - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx). Codecs PdfCraft can't re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index ac69c73acbc..78f4e0aa64c 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -541,7 +541,10 @@ impl Walk { { self.out.vertical = true; } - let (new_items, removed) = show(scope, &self.gs, &mut self.tm, &items); + let (new_items, removed, beside) = show(scope, &self.gs, &mut self.tm, &items); + if scope.mode == Mode::Apply { + scope.report.glyphs_beside_marks += beside; + } if removed == 0 { return Some(op); } @@ -639,13 +642,26 @@ fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], process } } +/// The glyph box a mark is measured against, across the line: the font's descent to ascent, +/// scaled down to one em when it is taller. A font's ascent and descent include the gap between +/// lines, so with lines set solid (spacing = font size) each line's box would reach a few points +/// into the marks on the next line and lose glyphs nobody marked. One em is also the box text +/// selection uses, so a mark made from a selection covers its own line only. +fn em_box(descent: f64, ascent: f64) -> (f64, f64) { + let h = ascent - descent; + if h > 1.0 { (descent / h, ascent / h) } else { (descent, ascent) } +} + /// Lay out a shown string (or `TJ` array) glyph by glyph, advancing `tm`. Returns the `TJ` -/// items with the glyphs under a region replaced by displacements, and how many were removed. -fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, usize) { +/// items with the glyphs under a region replaced by displacements, how many were removed, and +/// how many were kept although their full font box reaches a region (see [`em_box`]). +fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, usize, usize) { let f = &gs.font; let size = gs.size; + let (descent, ascent) = em_box(f.descent, f.ascent); let mut out: Vec = Vec::new(); let mut removed = 0; + let mut beside = 0; let push_num = |out: &mut Vec, v: f64| { if v == 0.0 { return; @@ -677,7 +693,13 @@ fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec scope.glyph_hit(b, origin), + None => { + let hit = scope.glyph_hit(trm.bbox([0.0, descent, w0, ascent]), origin); + if !hit && scope.glyph_hit(b, origin) { + beside += 1; + } + hit + } }; glyphs.push(Glyph { bytes: pos..pos + len, advance, hit }); *tm = Matrix::translate(advance * gs.scale, 0.0).then(tm); @@ -710,7 +732,7 @@ fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, + /// Glyphs kept although their full font box (descent to ascent) reaches into a mark, because + /// their one-em box does not: the overlap is the space font metrics leave between lines, as + /// on the line below a mark when lines are set solid. Ink a glyph draws outside its one-em box + /// stays in the file, so a caller can report these for a check. + pub glyphs_beside_marks: usize, } fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c072d19db7a..1080ba161cc 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -60,6 +60,85 @@ fn reopen(doc: &Document) -> Document { Document::open(Arc::new(write_incremental(doc, &SaveOptions::default()).unwrap())).unwrap() } +/// #5255: a font whose ascent and descent span more than one em (Monaco: 1000 and −250), with +/// lines set solid. A mark made from a text selection covers the selected line's em box, which +/// the line below's glyph box reached into by 2 pt: marking "1234" also removed "CRET" below it. +/// One page like [`one_page`], with font /F1's descriptor at `ascent` and `descent`. +fn page_with_metrics(content: &[u8], ascent: i32, descent: i32) -> Document { + let font = FONT.replace("95 0 R", "6 0 R").replace("/Ascent 800 /Descent -200", &format!("/Ascent {ascent} /Descent {descent}")); + pdf(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 << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", content), + font.into_bytes(), + widths(), + ]) +} + +#[test] +fn a_mark_on_one_line_leaves_the_line_below_when_lines_are_set_solid() { + let mut doc = page_with_metrics(b"BT /F1 10 Tf 10 120 Td (ABOVE123) Tj 0 -10 Td (AB1234CD) Tj 0 -10 Td (SECRET) Tj ET", 1000, -250); + // "1234" on the baseline at y 110 spans x 20–40; its em box is y 108–118. + mark(&mut doc, 0, &[[20.0, 108.0, 40.0, 118.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, 4, "{r:?}"); + assert_eq!(r.glyphs_beside_marks, 4, "CRET below reaches the mark only with its font box: kept and counted"); + let c = content(&doc, 0); + assert!(!c.contains("1234"), "{c}"); + assert!(c.contains("(SECRET) Tj") && c.contains("(ABOVE123) Tj"), "the lines above and below are whole: {c}"); + // A rectangle that reaches into the line below still takes the glyphs it covers. + mark(&mut doc, 0, &[[20.0, 98.0, 40.0, 108.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 4); + assert!(!content(&doc, 0).contains("SECRET")); +} + +/// #5255 at a small size: 9 pt lines set solid in a 1000 / −200 font (a 10.8 pt glyph box). The +/// line below reached 1.8 pt into the selection's mark. +#[test] +fn small_text_set_solid_keeps_the_line_below() { + let mut doc = page_with_metrics(b"BT /F1 9 Tf 10 120 Td (ABOVE123) Tj 0 -9 Td (AB1234CD) Tj 0 -9 Td (SECRET) Tj ET", 1000, -200); + // "1234" on the baseline at y 111 spans x 19–37; its em box is y 109.2–118.2. + mark(&mut doc, 0, &[[19.0, 109.2, 37.0, 118.2]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.glyphs_beside_marks), (4, 4), "{r:?}"); + let c = content(&doc, 0); + assert!(c.contains("(SECRET) Tj") && c.contains("(ABOVE123) Tj"), "{c}"); +} + +/// The edge of the em box (#5255): a mark that stops half a point above the em box of the line +/// "ABOVE123" (baseline 120, em box 118–128 for a 1000 / −250 font at 10 pt) keeps its glyphs and +/// counts them as left beside the mark; the same mark three points lower, past the fifth of the box a +/// glyph needs covered, takes them. The mark is one em (10 pt) tall in both. +#[test] +fn a_mark_just_outside_the_em_box_keeps_the_line_and_one_just_inside_takes_it() { + let text: &[u8] = b"BT /F1 10 Tf 10 120 Td (ABOVE123) Tj 0 -10 Td (AB1234CD) Tj 0 -10 Td (SECRET) Tj ET"; + // O, V, E and 1 of "ABOVE123" span x 20-40. + let mut doc = page_with_metrics(text, 1000, -250); + mark(&mut doc, 0, &[[20.0, 128.5, 40.0, 138.5]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.glyphs_beside_marks), (0, 4), "half a point above the em box: kept and counted, {r:?}"); + assert!(content(&doc, 0).contains("(ABOVE123) Tj"), "{}", content(&doc, 0)); + let mut doc = page_with_metrics(text, 1000, -250); + mark(&mut doc, 0, &[[20.0, 125.0, 40.0, 135.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, 4, "three points inside the em box: removed, {r:?}"); + let c = content(&doc, 0); + assert!(!c.contains("ABOVE123") && c.contains("(AB1234CD) Tj") && c.contains("(SECRET) Tj"), "{c}"); +} + +/// A mark over the bottom of large letters still removes them: a glyph left in the file under a +/// partial black box stays extractable. 24 pt "SECRET", a mark over the lower 3.8 pt of "SE". +#[test] +fn a_mark_over_part_of_large_text_still_removes_it() { + let mut doc = one_page(b"BT /F1 24 Tf 10 100 Td (SECRET) Tj ET", "", vec![]); + // Glyph boxes span y 95.2–119.2; S and E span x 10–34. + mark(&mut doc, 0, &[[10.0, 90.0, 34.0, 99.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.glyphs_beside_marks), (2, 0), "{r:?}"); + assert!(!content(&doc, 0).contains("SECRET")); +} + #[test] fn glyphs_under_a_mark_go_and_the_rest_stays_put() { // "AB1234CD" from x = 10 at 10 pt: each glyph 5 pt wide; 1234 spans x 20–40.