mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 02:58:32 -06:00
fix(audit): an applied redaction at the end of a line is not a faux redaction (#1898)
* fix(audit): an applied redaction at the end of a line is not a faux redaction After Apply Redactions removes text at the end of a line, the line keeps a TJ adjustment where the text was, and the box of the line still spans it. The audit compared the redaction box with that box: at the end of a line it covered more than 40% of it and the words left beside it were reported as covered text, on a correctly redacted file. In the middle of a line the gap was usually too small to cross the threshold. Text lines now also give the boxes of the words they draw (`ink`: glyphs, without spaces and TJ adjustments), and the audit measures how much of those words a cover hides. A real cover over text that is still there is found as before. doc_audit_redactions also reports its rectangles in top-left-origin points like comment_list and doc_info; they were in PDF user space. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(audit): leave the rectangles of doc_audit_redactions in user space here Reporting doc_audit_redactions rectangles in top-left-origin points is a separate subject from the false alarm this branch fixes, and on its own it made the tool and `pdfcraft-cli audit` disagree on the same file: the CLI still prints PDF user space. Take it out of this change; the tool and the CLI can move together in their own change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(edit): word boxes span whole words when glyphs run backwards With a negative font size or a negative horizontal scale (Tz), each glyph advances backwards along the text matrix, and the box of a word kept only its first glyph: the end of the word was compared with a start that had already moved past it. A word box is now the union of its glyphs whichever way they go, and a TJ adjustment ends a word past 0.2 em of the scaled size, whatever its sign. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(audit): a cover over a short value after a long label is still found Measuring a cover on the words of a line alone missed faux redactions main found: a long label followed by a short value hidden under the box is less than 40% of the line's words. A cover now also counts, as before, when it hides 40% of the line's box and at least half of one word still drawn; a cover over the gap an applied redaction left, with no word under it, still does not. The audit README states both rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(audit): retain partial-value cover detections Co-Authored-By: OpenAI Codex <noreply@openai.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: OpenAI Codex <noreply@openai.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
OpenAI Codex
parent
f0c877ca23
commit
56ef1f1c4e
@@ -16,6 +16,11 @@ It reports two kinds of finding:
|
||||
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
|
||||
shading and highlights don't either: they're painted before the text, or light against it.
|
||||
|
||||
A rectangle covers a text line when it hides at least 40% of the area of the words the line
|
||||
draws, or at least 40% of the line's box together with a positive-area intersection with one
|
||||
of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
|
||||
holds no word, so a redaction box over it alone does not count.
|
||||
|
||||
Limits, by design and documented rather than hidden:
|
||||
|
||||
- only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose
|
||||
@@ -23,7 +28,9 @@ Limits, by design and documented rather than hidden:
|
||||
translucent one (`/ca` below 1) is not flagged;
|
||||
- covers inside form XObjects are found, but only text drawn directly in the page content is
|
||||
checked under them;
|
||||
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign.
|
||||
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign;
|
||||
- a cover that hides less than 40% of a line's box and less than 40% of its words (a short
|
||||
value in the middle of a long line) is not flagged.
|
||||
|
||||
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
|
||||
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
|
||||
|
||||
+30
-17
@@ -84,7 +84,8 @@ pub enum AuditError {
|
||||
Edit(#[from] pdfcraft_edit::EditError),
|
||||
}
|
||||
|
||||
/// Fraction of a text line's area that must lie under a cover to count as hidden.
|
||||
/// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a
|
||||
/// cover to count as hidden (see [`covers`]).
|
||||
const COVER_FRACTION: f64 = 0.4;
|
||||
/// WCAG contrast ratio below which the text is unreadable against the cover.
|
||||
const COVER_CONTRAST: f64 = 3.0;
|
||||
@@ -114,7 +115,7 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
|
||||
for annot in annots(doc, &page.dict)? {
|
||||
let mut covered = String::new();
|
||||
for line in &lines {
|
||||
if covers(&annot, &line.rect) {
|
||||
if covers(&annot, line) {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
@@ -134,23 +135,35 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
|
||||
/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the
|
||||
/// contrast check and are not flagged.
|
||||
fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
|
||||
rect.pos > line.paint_pos() && covers(&rect.rect, &line.rect) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
}
|
||||
|
||||
/// Does `cover` hide a substantial part of `line`?
|
||||
fn covers(cover: &[f64; 4], line: &[f64; 4]) -> bool {
|
||||
let ix0 = cover[0].max(line[0]);
|
||||
let iy0 = cover[1].max(line[1]);
|
||||
let ix1 = cover[2].min(line[2]);
|
||||
let iy1 = cover[3].min(line[3]);
|
||||
if ix1 <= ix0 || iy1 <= iy0 {
|
||||
return false;
|
||||
}
|
||||
let line_area = (line[2] - line[0]).max(0.0) * (line[3] - line[1]).max(0.0);
|
||||
if line_area <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
(ix1 - ix0) * (iy1 - iy0) / line_area >= COVER_FRACTION
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough:
|
||||
///
|
||||
/// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words,
|
||||
/// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where
|
||||
/// the text was), and the box still spans it, so a redaction box at the end of a line used to
|
||||
/// cover most of the box and flag the words left beside it.
|
||||
/// - 40% of the line's box lies under the cover (the measure used before `ink`) and intersects the ink
|
||||
/// of a word still drawn. This keeps a cover over a short value after a long label
|
||||
/// flagged, while a cover over the gap alone, with no word under it, stops counting.
|
||||
///
|
||||
/// A line without word boxes is measured on its box alone.
|
||||
fn covers(cover: &[f64; 4], line: &TextLine) -> bool {
|
||||
let hidden = |b: &[f64; 4]| area(&[cover[0].max(b[0]), cover[1].max(b[1]), cover[2].min(b[2]), cover[3].min(b[3])]);
|
||||
let fraction = |boxes: &[[f64; 4]]| {
|
||||
let total: f64 = boxes.iter().map(area).sum();
|
||||
if total > 0.0 { boxes.iter().map(hidden).sum::<f64>() / total } else { 0.0 }
|
||||
};
|
||||
let inked = fraction(&line.ink) >= COVER_FRACTION;
|
||||
let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION;
|
||||
let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0);
|
||||
inked || (boxed && (line.ink.is_empty() || word_intersects()))
|
||||
}
|
||||
|
||||
/// The area of a rectangle, 0 when it is empty or inverted.
|
||||
fn area(r: &[f64; 4]) -> f64 {
|
||||
(r[2] - r[0]).max(0.0) * (r[3] - r[1]).max(0.0)
|
||||
}
|
||||
|
||||
/// WCAG 2.x relative luminance of an sRGB triple.
|
||||
|
||||
@@ -228,6 +228,93 @@ fn self_drawing_form_fails_the_audit() {
|
||||
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
|
||||
}
|
||||
|
||||
/// An applied redaction leaves the words around it and a `TJ` adjustment over the gap where the
|
||||
/// text was, then the redaction box. At the end of a line, the box covers most of the line's box
|
||||
/// (which spans the gap) but none of its words: nothing is hidden, so nothing is flagged.
|
||||
#[test]
|
||||
fn applied_redaction_at_the_end_of_a_line_is_not_flagged() {
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Mail the team:) -11354] TJ ET\n0 g\n144.138 739.8 121.814 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
// The same gap between two words, wider than the words beside it.
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -14000 (ok)] TJ ET\n0 g\n101 739.8 150 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
/// The control: the same box over text still drawn under it is a faux redaction, at the end of a
|
||||
/// line as in the middle of a short one.
|
||||
#[test]
|
||||
fn faux_redaction_at_the_end_of_a_line_is_found() {
|
||||
let content = b"BT /F1 11 Tf 72 742 Td (Mail the team: someone@example.com) Tj ET\n0 g\n144.138 739.8 121.814 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com");
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -300 (4111111111111111) -300 (ok)] TJ ET\n0 g\n101 739.8 100 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
}
|
||||
|
||||
/// A cover over a short value after a long label hides less than 40% of the line's words, but the
|
||||
/// whole of the words it covers, and most of the line's box: still a faux redaction. The value
|
||||
/// follows the label in the same `TJ` after a wide gap, or in a second run moved by `Td`.
|
||||
#[test]
|
||||
fn faux_redaction_over_a_short_value_after_a_long_label_is_found() {
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123 456 789)] TJ ET\n0 g\n200 739.8 150 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "Social insurance number: 123 456 789");
|
||||
let content = b"BT /F1 11 Tf 72 742 Td (Account:) Tj 70 0 Td (4111) Tj ET\n0 g\n115 739.8 55 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert!(findings[0].covered_text.contains("4111"), "{findings:?}");
|
||||
}
|
||||
|
||||
/// Text set with a negative font size in a mirrored text matrix runs its glyphs backwards: a
|
||||
/// cover over the address it draws is still found.
|
||||
#[test]
|
||||
fn faux_redaction_over_mirrored_text_is_found() {
|
||||
let content = b"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET\n0 g\n365 733 130 14 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com");
|
||||
}
|
||||
|
||||
/// A cover over less than half a value must retain the historical line-box detection.
|
||||
#[test]
|
||||
fn faux_redaction_over_a_partial_value_after_a_long_label_is_found() {
|
||||
partial_value_is_found(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found() {
|
||||
partial_value_is_found(true);
|
||||
}
|
||||
|
||||
fn partial_value_is_found(rotated: bool) {
|
||||
let bytes = one_page(
|
||||
b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123456789)] TJ ET\n0 g\n193 738 111 16 re f\n",
|
||||
if rotated { "/Rotate 90" } else { "" },
|
||||
);
|
||||
let findings = audit_redactions(&open(bytes)).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "partial value, rotated={rotated}");
|
||||
}
|
||||
|
||||
/// Keep the historic detections with several covers, lines and text-spacing states.
|
||||
#[test]
|
||||
fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() {
|
||||
let cases: [&[u8]; 4] = [
|
||||
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n",
|
||||
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n",
|
||||
b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n",
|
||||
b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n",
|
||||
];
|
||||
for content in cases {
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_drawn_as_a_path_covers_text_like_re() {
|
||||
// The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or
|
||||
|
||||
@@ -3137,7 +3137,11 @@ fn removing_hidden_information_through_tools() {
|
||||
|
||||
/// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle.
|
||||
fn faux_redacted_fixture() -> Vec<u8> {
|
||||
let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f";
|
||||
helvetica_page("BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f")
|
||||
}
|
||||
|
||||
/// A one-page US Letter PDF drawing `body` with Helvetica as /F1.
|
||||
fn helvetica_page(body: &str) -> Vec<u8> {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(),
|
||||
@@ -3179,6 +3183,25 @@ fn auditing_faux_redactions_through_tools() {
|
||||
assert_eq!(ok(&mut a, "doc_audit_redactions", json!({ "doc": clean }))["count"], 0);
|
||||
}
|
||||
|
||||
/// An applied redaction at the end of a line leaves the words before it, then a gap the text
|
||||
/// matrix jumps over and the redaction box: nothing is under the box any more, so the audit must
|
||||
/// not report the words beside it as covered. In the middle of a line it never did.
|
||||
#[test]
|
||||
fn an_applied_redaction_at_the_end_of_a_line_audits_clean() {
|
||||
let dir = workdir("audit-applied-end-of-line");
|
||||
let body = "BT /F1 11 Tf 72 692 Td (Mail the team: someone@example.com) Tj ET\nBT /F1 11 Tf 72 662 Td (Account 0042-7788 is in the middle of this line.) Tj ET";
|
||||
std::fs::write(dir.join("mail.pdf"), helvetica_page(body)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "mail.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "email" }));
|
||||
ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "0042-7788" }));
|
||||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||||
let text = ok(&mut a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap().to_string();
|
||||
assert!(text.contains("Mail the team:") && !text.contains("someone") && !text.contains("0042"), "{text}");
|
||||
let r = ok(&mut a, "doc_audit_redactions", json!({ "doc": doc }));
|
||||
assert_eq!(r["count"], 0, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printing_through_tools() {
|
||||
let dir = workdir("print");
|
||||
|
||||
@@ -1346,6 +1346,28 @@ fn independent_text_objects_with_different_transforms_stay_separate() {
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A negative font size or `Tz` runs the glyphs backwards along the text matrix: each word's
|
||||
/// `ink` box still spans all its glyphs, not only the first.
|
||||
#[test]
|
||||
fn word_boxes_span_whole_words_when_glyphs_run_backwards() {
|
||||
// "someone@example.com" in Helvetica 11 is 121.814 pt wide.
|
||||
for content in [
|
||||
"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET",
|
||||
"BT /F1 11 Tf -100 Tz 300 742 Td (Mail the team: someone@example.com) Tj ET",
|
||||
] {
|
||||
let lines = text::text_lines(&text_page(content), 0).unwrap();
|
||||
assert_eq!(lines.len(), 1, "{content}");
|
||||
let ink = &lines[0].ink;
|
||||
assert_eq!(ink.len(), 4, "{content}: {ink:?}");
|
||||
let width = |r: &[f64; 4]| r[2] - r[0];
|
||||
assert!(ink.iter().all(|r| width(r) > 5.0), "{content}: {ink:?}");
|
||||
let address = ink.iter().map(width).fold(0.0, f64::max);
|
||||
assert!((address - 121.814).abs() < 0.01, "{content}: {ink:?}");
|
||||
let (x0, x1) = ink.iter().fold((f64::MAX, f64::MIN), |(a, b), r| (a.min(r[0]), b.max(r[2])));
|
||||
assert!(x0 >= lines[0].rect[0] - 0.01 && x1 <= lines[0].rect[2] + 0.01, "{content}: {ink:?} {:?}", lines[0].rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_lines_are_found_and_replaced_in_place() {
|
||||
let mut doc =
|
||||
|
||||
+45
-1
@@ -31,6 +31,10 @@ pub struct TextLine {
|
||||
pub text: String,
|
||||
/// Its box in user space.
|
||||
pub rect: [f64; 4],
|
||||
/// The boxes of the words it draws, in user space: from each word's first glyph to the end of
|
||||
/// its last, without the spaces and `TJ` adjustments around it. `rect` also spans gaps that
|
||||
/// draw nothing, such as the adjustment an applied redaction leaves where text was removed.
|
||||
pub ink: Vec<[f64; 4]>,
|
||||
/// The font's resource name and `/BaseFont` (or Type 3 descriptor's `/FontName`), and its size in text space.
|
||||
pub font: String,
|
||||
pub base_font: String,
|
||||
@@ -473,6 +477,8 @@ struct Shown {
|
||||
bt_state: Ts,
|
||||
text: String,
|
||||
rect: [f64; 4],
|
||||
/// The boxes of its words (see [`TextLine::ink`]).
|
||||
ink: Vec<[f64; 4]>,
|
||||
baseline: f64,
|
||||
start_x: f64,
|
||||
end_x: f64,
|
||||
@@ -567,6 +573,7 @@ fn interpret(
|
||||
first.rect[2].max(shown.rect[2]),
|
||||
first.rect[3].max(shown.rect[3]),
|
||||
];
|
||||
first.ink.extend(shown.ink);
|
||||
}
|
||||
first.extra_ops.extend([open, i]);
|
||||
// Only right-to-left ActualText is already in typing order and must stay
|
||||
@@ -667,16 +674,34 @@ fn interpret(
|
||||
let mut text = String::new();
|
||||
let mut decodable = true;
|
||||
let mut x_text = 0.0;
|
||||
// Where each word's glyphs start and end along the run (text space), and whether
|
||||
// the next glyph continues the last word.
|
||||
let mut words: Vec<(f64, f64)> = Vec::new();
|
||||
let mut in_word = false;
|
||||
let mut glyph_count = 0_usize;
|
||||
for p in &pieces {
|
||||
match p {
|
||||
Object::String(s) => {
|
||||
for (code, len) in m.codes(&s.bytes) {
|
||||
glyph_count = glyph_count.saturating_add(1);
|
||||
match m.text_of(code) {
|
||||
let shown = m.text_of(code);
|
||||
let blank =
|
||||
m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
|
||||
match shown {
|
||||
Some(t) => text.push_str(&t),
|
||||
None => decodable = false,
|
||||
}
|
||||
if !blank {
|
||||
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
|
||||
// A negative font size or `Tz` runs the glyphs backwards:
|
||||
// the word is the union of its glyphs, whichever way they go.
|
||||
let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end));
|
||||
match words.last_mut() {
|
||||
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
|
||||
_ => words.push((lo, hi)),
|
||||
}
|
||||
}
|
||||
in_word = !blank;
|
||||
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
|
||||
x_text += w * ts.scale;
|
||||
}
|
||||
@@ -688,6 +713,11 @@ fn interpret(
|
||||
if dx > ts.size * 0.2 && !text.ends_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
// Kerning keeps a word together; a jump either way of more
|
||||
// than 0.2 em ends it (whatever the sign of the size or `Tz`).
|
||||
if dx.abs() > (ts.size * ts.scale).abs() * 0.2 {
|
||||
in_word = false;
|
||||
}
|
||||
x_text += dx;
|
||||
}
|
||||
}
|
||||
@@ -705,6 +735,15 @@ fn interpret(
|
||||
let rect = corners
|
||||
.iter()
|
||||
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
|
||||
let ink = words
|
||||
.iter()
|
||||
.map(|&(a, b)| {
|
||||
let (y0, y1) = (ts.rise + m.descent * ts.size, ts.rise + m.ascent * ts.size);
|
||||
[trm0.apply(a, y0), trm0.apply(b, y0), trm0.apply(a, y1), trm0.apply(b, y1)]
|
||||
.iter()
|
||||
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
|
||||
})
|
||||
.collect();
|
||||
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
|
||||
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
|
||||
// It must not create a phantom editable line or enlarge its neighbours' boxes.
|
||||
@@ -723,6 +762,7 @@ fn interpret(
|
||||
bt_state: at_bt.1.clone(),
|
||||
text,
|
||||
rect,
|
||||
ink,
|
||||
baseline: start.1,
|
||||
start_x: start.0,
|
||||
end_x: end.0,
|
||||
@@ -798,6 +838,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
}
|
||||
l.text.push_str(&s.text);
|
||||
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
|
||||
l.ink.extend(s.ink.iter().copied());
|
||||
l.ops.push(s.op);
|
||||
l.ops.extend(s.extra_ops.iter().copied());
|
||||
l.decodable &= s.decodable;
|
||||
@@ -808,6 +849,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
lines.push(TextLine {
|
||||
text: s.text.clone(),
|
||||
rect: s.rect,
|
||||
ink: s.ink.clone(),
|
||||
font: String::from_utf8_lossy(&s.font).into_owned(),
|
||||
base_font: s.base_font.clone(),
|
||||
size: s.size,
|
||||
@@ -885,6 +927,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)
|
||||
}
|
||||
l.text.push_str(&s.text);
|
||||
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
|
||||
l.ink.extend(s.ink.iter().copied());
|
||||
l.ops.push(s.op);
|
||||
l.ops.extend(s.extra_ops.iter().copied());
|
||||
l.decodable &= s.decodable;
|
||||
@@ -894,6 +937,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)
|
||||
lines.push(TextLine {
|
||||
text: s.text.clone(),
|
||||
rect: s.rect,
|
||||
ink: s.ink.clone(),
|
||||
font: String::from_utf8_lossy(&s.font).into_owned(),
|
||||
base_font: s.base_font.clone(),
|
||||
size: s.size,
|
||||
|
||||
@@ -5800,8 +5800,8 @@ milestone = "M8"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = ["doc_audit_redactions"]
|
||||
evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools"]
|
||||
notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)."
|
||||
evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover"]
|
||||
notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)."
|
||||
|
||||
[[feature]]
|
||||
id = "protect.remove-hidden-on-close"
|
||||
|
||||
Reference in New Issue
Block a user