From 6ce5b5fe4312e67283812e3224d1df9d8a7315c6 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:16:34 +0800 Subject: [PATCH] M8: a removed document XMP packet's PDF/A and PDF/UA identification is written back, or reported lost (#2448) #2539 review: the scrub removes the catalog's XMP packet whole when it holds the removed text, and with it the file's PDF/A and PDF/UA claims, which Report didn't say. The identification (pdfaid part, conformance, amd, rev; pdfuaid part, rev) is now read by namespace (roxmltree, already in the build through pdfcraft-xfa), each value kept only when it is the short code it must be and doesn't hold the removed text (one-character codes always), and written back in a packet holding nothing else, with a fixed pdfuaid extension schema under PDF/A-1 to -3 (left out, with the PDF/UA claim, if the redacted words are among its words). Report::identification says, per standard, None / Kept / Lost; the engine passes it on as EditNotice::RedactionIdentification, the Apply notification says it after the count, and redact_apply returns identification { pdfa, pdfua }. Tests (redact): the_identification_of_a_removed_packet_is_written_back, a_packet_without_identification_is_only_removed, an_identification_that_cannot_be_kept_is_reported_lost, unsearched_text_keeps_only_one_character_codes, the_extension_schema_is_left_out_when_it_uses_a_redacted_word; engine applied_redactions_report_the_pdfa_identification_written_back; automation redact_apply_reports_the_identification_written_back; ui-egui applying_says_the_pdfa_identification_was_written_back. With the write-back replaced by "nothing kept", the redact tests fail (Kept expected, None found); with the notice not pushed, the engine, automation and UI tests fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- Cargo.lock | 1 + crates/automation/src/redact.rs | 8 + crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 34 ++++ crates/engine/src/document.rs | 31 +++ crates/engine/src/edit/run.rs | 5 + crates/engine/src/lib.rs | 1 + crates/engine/src/session/editing.rs | 6 +- crates/engine/src/tests.rs | 32 +++ crates/redact/Cargo.toml | 2 + crates/redact/README.md | 12 +- crates/redact/src/identification.rs | 204 +++++++++++++++++++ crates/redact/src/lib.rs | 9 +- crates/redact/src/metadata.rs | 46 ++++- crates/redact/src/tests.rs | 5 +- crates/redact/src/tests/identification.rs | 123 +++++++++++ crates/redact/src/tests/metadata_matching.rs | 6 +- crates/ui-egui/src/dialogs.rs | 42 +++- crates/ui-egui/src/editing.rs | 3 + crates/ui-egui/tests/it/redact.rs | 53 +++++ 20 files changed, 601 insertions(+), 24 deletions(-) create mode 100644 crates/redact/src/identification.rs create mode 100644 crates/redact/src/tests/identification.rs diff --git a/Cargo.lock b/Cargo.lock index 3cc808ab1c..846b85418e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4585,6 +4585,7 @@ dependencies = [ "pdfcraft-model", "pdfcraft-testkit", "pdfcraft-xfa", + "roxmltree 0.21.2", "thiserror 2.0.21", "unicode-normalization", ] diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 4b95736613..70e4cb73b2 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -175,6 +175,14 @@ impl Automation { "total": left.total(), "unsearched": left.unsearched, }); + // What became of a PDF/A or PDF/UA claim in the document XMP the scrub removed. + let claim = |c: pdfcraft_engine::RedactionClaim| match c { + pdfcraft_engine::RedactionClaim::None => "none", + pdfcraft_engine::RedactionClaim::Kept => "kept", + pdfcraft_engine::RedactionClaim::Lost => "lost", + }; + let id = self.doc(a)?.redaction_identification().unwrap_or_default(); + out["identification"] = json!({ "pdfa": claim(id.pdfa), "pdfua": claim(id.pdfua) }); Ok(out) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0f1b14b9a3..aa30801bfe 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -545,7 +545,7 @@ pub fn tools() -> Vec { t( "redact_apply", "Apply redactions", - "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Document metadata (Info entries, the document's and pages' XMP) that repeats the removed text is removed too. still_holding counts the places applying leaves as they are that still hold the removed text: bookmarks (titles), attachments (names, file names, descriptions), comments (text, rich text, subject or author of those that remain) and xmp (XMP on images, fonts, forms, annotations and other objects), with their total; doc_remove_hidden removes them. unsearched: true means the removed text couldn't be searched for, so every such place holding any text is counted. Undoable until saved; the saved file no longer contains the content.", + "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Document metadata (Info entries, the document's and pages' XMP) that repeats the removed text is removed too; when the document XMP goes, identification says what became of its PDF/A and PDF/UA claims (pdfa, pdfua: none, kept when written back in a packet holding only the identification, or lost). still_holding counts the places applying leaves as they are that still hold the removed text: bookmarks (titles), attachments (names, file names, descriptions), comments (text, rich text, subject or author of those that remain) and xmp (XMP on images, fonts, forms, annotations and other objects), with their total; doc_remove_hidden removes them. unsearched: true means the removed text couldn't be searched for, so every such place holding any text is counted. Undoable until saved; the saved file no longer contains the content.", ) .destructive() .with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 653c18c4bf..133e31022b 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4381,6 +4381,40 @@ fn redact_apply_reports_places_that_still_hold_the_removed_text() { .map(|b| b["title"].as_str().unwrap().to_string()) .collect(); assert_eq!(titles, ["Bluefin plans", "Annual figures"], "reported, not changed"); + assert_eq!(r["identification"], json!({ "pdfa": "none", "pdfua": "none" }), "{r}"); +} + +/// #2539 review: when `redact_apply` removes the document's XMP packet because it names the +/// redacted word, `identification` says the PDF/A-2b and PDF/UA-1 identification it held was +/// written back, and the warnings say it too. +#[test] +fn redact_apply_reports_the_identification_written_back() { + let dir = workdir("redact-identification"); + let xmp = r#"Bluefin report"#; + let body = "BT /F1 18 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let content = format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()); + let metadata = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + &content, + &metadata, + ]; + std::fs::write(dir.join("pdfa.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "pdfa.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "BLUEFIN" })); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!(r["identification"], json!({ "pdfa": "kept", "pdfua": "kept" }), "{r}"); + assert_eq!( + r["warnings"], + json!([ + "the document's XMP metadata held the redacted text and was removed; its PDF/A and PDF/UA identification was written back on its own" + ]), + "{r}" + ); } #[test] diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 863f370a72..da921f7ccc 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -82,6 +82,16 @@ pub enum EditNotice { /// [`RedactionElsewhere`](crate::RedactionElsewhere)). Only when one is counted, or the text /// couldn't be searched for. RedactionElsewhere(pdfcraft_redact::Elsewhere), + /// Applying redactions removed the document's XMP packet, which held the removed text and + /// said the file conforms to PDF/A or PDF/UA: each claim was written back in a packet of its + /// own, or lost (see [`RedactionIdentification`](crate::RedactionIdentification)). + RedactionIdentification(pdfcraft_redact::Identification), +} + +/// The standards whose identification `id` has as `claim`: "PDF/A", "PDF/UA" or both, joined. +pub(crate) fn standards_with(id: &pdfcraft_redact::Identification, claim: pdfcraft_redact::Claim) -> Option { + let names: Vec<&str> = [("PDF/A", id.pdfa), ("PDF/UA", id.pdfua)].into_iter().filter(|(_, c)| *c == claim).map(|(n, _)| n).collect(); + (!names.is_empty()).then(|| names.join(" and ")) } impl std::fmt::Display for EditNotice { @@ -97,6 +107,17 @@ impl std::fmt::Display for EditNotice { ) } EditNotice::RedactionElsewhere(e) => elsewhere_text(e, f), + EditNotice::RedactionIdentification(id) => { + use pdfcraft_redact::Claim; + write!(f, "the document's XMP metadata held the redacted text and was removed")?; + if let Some(kept) = standards_with(id, Claim::Kept) { + write!(f, "; its {kept} identification was written back on its own")?; + } + match standards_with(id, Claim::Lost) { + Some(lost) => write!(f, "; its {lost} identification couldn't be kept, so the file no longer says it conforms to {lost}"), + None => Ok(()), + } + } } } } @@ -225,6 +246,16 @@ impl Document { }) } + /// What became of the PDF/A and PDF/UA identification when the last edit's applied + /// redactions removed the document's XMP packet ([`EditNotice::RedactionIdentification`]), + /// or `None` when no packet that made such a claim was removed. + pub fn redaction_identification(&self) -> Option { + self.edit_notices.iter().find_map(|n| match n { + EditNotice::RedactionIdentification(id) => Some(*id), + _ => None, + }) + } + /// How the document opens (Document Properties ▸ Initial View). /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be /// read for editing). diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 89e3e7b176..17841128d7 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -187,6 +187,9 @@ pub(crate) struct EditCtx { pub(crate) redaction_overlays: Vec, /// What the edit's applied redactions left as it was that still holds the removed text. pub(crate) redaction_elsewhere: Option, + /// What became of the PDF/A and PDF/UA identification when the edit's applied redactions + /// removed the catalog's XMP packet. + pub(crate) redaction_identification: pdfcraft_redact::Identification, } impl EditCtx { @@ -217,6 +220,7 @@ impl EditCtx { renamed_fields: Vec::new(), redaction_overlays: Vec::new(), redaction_elsewhere: None, + redaction_identification: pdfcraft_redact::Identification::default(), } } @@ -653,6 +657,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E let report = pdfcraft_redact::apply(doc, pages.as_deref())?; cx.redaction_overlays = report.overlays; cx.redaction_elsewhere = Some(report.still_holding); + cx.redaction_identification = report.identification; } Edit::ClearRedactions => { pdfcraft_redact::clear_marks(doc, None)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 200a79dc9a..69036d9d52 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -119,6 +119,7 @@ pub use pdfcraft_redact::patterns::{ MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, }; pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; +pub use pdfcraft_redact::{Claim as RedactionClaim, Identification as RedactionIdentification}; pub use pdfcraft_sign as sign; pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; pub use pdfcraft_xfdf::Format as DataFormat; diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 023ce138ab..a8b07c73e1 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -182,8 +182,12 @@ impl Session { return Err(EditError::SignedRewrite(edit.label().to_string())); } let mut notices = edit_notices(&edit, &editor.cos, &next); - // Places applied redactions left that still hold the removed text: said, not silent. + // Places applied redactions left that still hold the removed text, and what became of + // the PDF/A or PDF/UA identification in the XMP they removed: said, not silent. notices.extend(cx.redaction_elsewhere.filter(|e| e.total() > 0 || e.unsearched).map(EditNotice::RedactionElsewhere)); + if cx.redaction_identification != pdfcraft_redact::Identification::default() { + notices.push(EditNotice::RedactionIdentification(cx.redaction_identification)); + } let previous = std::mem::replace(&mut editor.cos, next); let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); let previous_keys = std::mem::replace(&mut editor.keys, keys); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 91ad63e783..71a212671c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2903,6 +2903,11 @@ fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec< "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()), ]; + pdf_of(&objs) +} + +/// A file of `objs`, numbered from 1, the first the catalog. +fn pdf_of(objs: &[String]) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -3004,6 +3009,33 @@ fn applied_redactions_report_a_bookmark_that_still_holds_the_removed_text() { assert_eq!(s.get(id).unwrap().redaction_elsewhere(), None, "an undo forgets it"); } +/// #2539 review: applying redactions that remove the document's XMP packet, which named the +/// redacted word and said the file conforms to PDF/A-2b, writes the identification back and +/// says so; tools list it with the other edit notices, and an undo forgets it. +#[test] +fn applied_redactions_report_the_pdfa_identification_written_back() { + let xmp = r#"Bluefin report"#; + let body = "BT /F1 10 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()), + ]; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("pdfa.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + mark_shown_text(&mut s, id, "BLUEFIN"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + let kept = crate::RedactionIdentification { pdfa: crate::RedactionClaim::Kept, pdfua: crate::RedactionClaim::None }; + assert_eq!(s.get(id).unwrap().redaction_identification(), Some(kept)); + let said: Vec = s.get(id).unwrap().edit_notices.iter().map(ToString::to_string).collect(); + assert_eq!(said, ["the document's XMP metadata held the redacted text and was removed; its PDF/A identification was written back on its own"]); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_identification(), None, "an undo forgets it"); +} + /// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer /// draws it, so a mark drawn over it as displayed takes it out of the file. #[test] diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 10cb97ccde..984e0ea34e 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -20,6 +20,8 @@ pdfcraft-xfa = { workspace = true } thiserror = { workspace = true } # NFKC for the metadata check; already in the build through pdfcraft-crypt (stringprep). unicode-normalization = "0.1" +# Reads the PDF/A and PDF/UA identification by namespace; already in the build through pdfcraft-xfa. +roxmltree = "0.21" [dev-dependencies] pdfcraft-filters = { workspace = true } diff --git a/crates/redact/README.md b/crates/redact/README.md index 2cba145390..8063f1a9f4 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -39,7 +39,17 @@ clear_marks(&mut doc, None)?; // remove marks without app part of it as whole words (three letters or digits at least), and the catalog's and pages' XMP streams whose text (element content, attribute values other than `xmlns` declarations, comments and processing instructions, entities decoded) contains it or has a value wholly - part of it, removed whole, together with any PDF/A identification in that packet. The removed + part of it, removed whole. When the catalog's packet goes, its PDF/A (`pdfaid`) and PDF/UA + (`pdfuaid`) identification, read by namespace, is written back in a packet holding nothing + else: the part number, the PDF/A conformance letter, amendment and revision, and PDF/UA's + part and revision, each kept only if it is the short code it must be and doesn't itself hold + the removed text (a one-character code can't, so it is kept even when the text couldn't be + searched for), plus a fixed extension schema for `pdfuaid` under PDF/A-1 to -3, left out + with the PDF/UA claim if the redacted words are among its words. `Report::identification` + says, for each, whether the claim was kept or lost (a value that isn't a valid code or holds + the text, or a packet that can't be read); the notification and `redact_apply` say it too. + `/Info` entries that remain aren't mirrored into the new packet, so a PDF/A validator may + still report them. The removed glyphs' text is collected in runs by position (character and word spacing included, so letter-spaced text stays one word), and each run (two or more letters or digits) and each of its words of three or more are matched without regard to case or spacing, and also by their diff --git a/crates/redact/src/identification.rs b/crates/redact/src/identification.rs new file mode 100644 index 0000000000..b509481adf --- /dev/null +++ b/crates/redact/src/identification.rs @@ -0,0 +1,204 @@ +//! The PDF/A and PDF/UA identification of a document XMP packet the metadata check removes +//! (#2539 review). +//! +//! The scrub removes the catalog's XMP packet whole when it holds the removed text, and the +//! packet is where a file says it conforms to PDF/A (ISO 19005, the `pdfaid` schema) or PDF/UA +//! (ISO 14289, `pdfuaid`). Rather than drop that claim silently, the scrub writes back a packet +//! holding only those identification properties, read by namespace (not by prefix), each +//! checked to be the short code it must be (a part number, a conformance letter, a year), and +//! none of them holding the removed text. Nothing else of the old packet is copied. What became +//! of each claim is reported ([`Identification`]). + +use pdfcraft_cos::{Dict, Document, Object, Stream}; + +use crate::metadata::Search; + +const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/"; +const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/"; +/// Packets with more `=` than this (attributes, roughly) aren't parsed: the parser checks each +/// element's attributes against each other, so a huge attribute list would be slow. +const MAX_EQUALS: usize = 4096; +/// The most XML nodes parsed. +const MAX_NODES: u32 = 100_000; +/// The PDF/A extension schema describing `pdfuaid:part`, fixed text. +const PDFUA_EXTENSION: &str = concat!( + r#""#, + r#""#, + r#"PDF/UA identification schemahttp://www.aiim.org/pdfua/ns/id/pdfuaid"#, + r#""#, + r#"partIntegerinternalThe part of ISO 14289 the file conforms to"#, + r#""#, +); + +/// What became of a conformance claim when the packet holding it was removed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Claim { + /// No removed packet made the claim. + #[default] + None, + /// The packet written in its place makes it again. + Kept, + /// The removed packet made it (or, when it couldn't be read, seems to have), and it couldn't + /// be written back: a property wasn't a valid code, held the removed text, or the packet + /// couldn't be read. + Lost, +} + +/// What became of the document's PDF/A and PDF/UA identification when the metadata check +/// removed the catalog's XMP packet. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Identification { + /// PDF/A (`pdfaid`: part, conformance, and amendment and revision when present). + pub pdfa: Claim, + /// PDF/UA (`pdfuaid`: part, and revision when present). + pub pdfua: Claim, +} + +impl Identification { + /// A claim was lost. + pub fn lost(&self) -> bool { + self.pdfa == Claim::Lost || self.pdfua == Claim::Lost + } +} + +/// The identification of the removed catalog packet `xmp`, and the packet to write in its place +/// (`None` when nothing is kept). +pub(crate) fn keep(doc: &Document, xmp: &Object, search: &Search) -> (Identification, Option) { + let Some(text) = crate::metadata::xmp_text(doc, xmp) else { + // Unreadable: a claim it seems to make, by its namespace or usual prefix in the raw + // bytes, is lost. + let seen = |raw: &[u8], ns: &str, prefix: &[u8]| { + let has = |n: &[u8]| raw.windows(n.len()).any(|w| w == n); + if has(ns.as_bytes()) || has(prefix) { Claim::Lost } else { Claim::None } + }; + let id = match &*doc.resolve(xmp) { + Object::Stream(s) => Identification { pdfa: seen(&s.raw, PDFA_ID, b"pdfaid:"), pdfua: seen(&s.raw, PDFUA_ID, b"pdfuaid:") }, + _ => Identification::default(), + }; + return (id, None); + }; + let Some(found) = read(&text) else { + let seen = |ns: &str, prefix: &str| if text.contains(ns) || text.contains(prefix) { Claim::Lost } else { Claim::None }; + return (Identification { pdfa: seen(PDFA_ID, "pdfaid:"), pdfua: seen(PDFUA_ID, "pdfuaid:") }, None); + }; + // A value is kept when it is a valid code and doesn't hold the removed text. One letter or + // digit can't be a term, so it is kept even when everything counts as holding the text. + let ok = |v: &Option, valid: fn(&str) -> bool| -> Option { + let v = v.as_deref()?; + let short = v.chars().filter(|c| c.is_alphanumeric()).count() < 2; + (valid(v) && (short || !search.text(v))).then(|| v.to_string()) + }; + let mut id = Identification::default(); + let mut body = String::new(); + + let a = &found.pdfa; + if a.any() { + let part = ok(&a.part, part); + let conformance = ok(&a.conformance, |v| v.len() == 1 && v.bytes().all(|b| b.is_ascii_alphabetic())); + // PDF/A-1 to -3 require a conformance level; PDF/A-4 has one only for -4e and -4f. + if part.is_some() && (conformance.is_some() || a.conformance.is_none()) { + id.pdfa = Claim::Kept; + body.push_str(r#""#); + let props = [("part", part), ("conformance", conformance), ("amd", ok(&a.amd, amendment)), ("rev", ok(&a.rev, year))]; + for (name, v) in props { + if let Some(v) = v { + body.push_str(&format!("{v}")); + } + } + body.push_str(""); + } else { + id.pdfa = Claim::Lost; + } + } + if found.pdfua.any() { + // PDF/A-1 to -3 accept only the schemas they define or that the packet describes in an + // extension schema, which pdfuaid needs there. Its fixed text is ours, but a file whose + // redacted words it happens to use doesn't get it (nor, then, the PDF/UA claim). + let extension = id.pdfa == Claim::Kept && a.part.as_deref().is_some_and(|p| matches!(p, "1" | "2" | "3")); + let extension_ok = !extension || !crate::metadata::xml_values(PDFUA_EXTENSION).iter().any(|v| search.text(v)); + match ok(&found.pdfua.part, part).filter(|_| extension_ok) { + Some(p) => { + id.pdfua = Claim::Kept; + // PDF/UA-2 adds a revision year (PDF 2.0, so never beside PDF/A-1 to -3). + let rev = ok(&found.pdfua.rev, year).map(|r| format!("{r}")).unwrap_or_default(); + body.push_str(&format!(r#"{p}{rev}"#)); + if extension { + body.push_str(PDFUA_EXTENSION); + } + } + None => id.pdfua = Claim::Lost, + } + } + if body.is_empty() { + return (id, None); + } + let packet = format!( + "\n{body}\n" + ); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("Metadata")); + dict.set(b"Subtype".to_vec(), Object::name("XML")); + // Unfiltered, as PDF/A requires of metadata streams. + (id, Some(Stream::from_raw(dict, packet.into_bytes()))) +} + +/// A part number: one or two digits. +fn part(v: &str) -> bool { + (1..=2).contains(&v.len()) && v.bytes().all(|b| b.is_ascii_digit()) +} + +/// An amendment identifier, such as "2005" or "1:2005". +fn amendment(v: &str) -> bool { + (1..=16).contains(&v.len()) && v.bytes().all(|b| b.is_ascii_digit() || b == b':' || b == b'.' || b == b'-') +} + +/// A revision year. +fn year(v: &str) -> bool { + v.len() == 4 && v.bytes().all(|b| b.is_ascii_digit()) +} + +#[derive(Default)] +struct PdfaId { + part: Option, + conformance: Option, + amd: Option, + rev: Option, +} + +impl PdfaId { + fn any(&self) -> bool { + self.part.is_some() || self.conformance.is_some() || self.amd.is_some() || self.rev.is_some() + } +} + +#[derive(Default)] +struct Found { + pdfa: PdfaId, + pdfua: PdfaId, +} + +/// The identification properties of a packet, by namespace, written as attributes or as +/// elements; the first of each wins. `None` when the packet can't be parsed. +fn read(text: &str) -> Option { + if text.bytes().filter(|&b| b == b'=').count() > MAX_EQUALS { + return None; + } + let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES, ..Default::default() }; + let xml = roxmltree::Document::parse_with_options(text, options).ok()?; + let mut found = Found::default(); + for node in xml.descendants().filter(roxmltree::Node::is_element) { + for (ns, id) in [(PDFA_ID, &mut found.pdfa), (PDFUA_ID, &mut found.pdfua)] { + for (local, slot) in [("part", &mut id.part), ("conformance", &mut id.conformance), ("amd", &mut id.amd), ("rev", &mut id.rev)] { + if slot.is_some() { + continue; + } + if let Some(v) = node.attribute((ns, local)) { + *slot = Some(v.trim().to_string()); + } else if node.tag_name().namespace() == Some(ns) && node.tag_name().name() == local { + *slot = Some(node.text().unwrap_or("").trim().to_string()); + } + } + } + } + Some(found) +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 4d6bd676e0..5925878430 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -23,6 +23,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; mod elsewhere; +mod identification; mod image; mod interp; mod metadata; @@ -36,6 +37,7 @@ pub(crate) mod type3; pub(crate) mod type3_tables; pub use elsewhere::Elsewhere; +pub use identification::{Claim, Identification}; use interp::{Mode, Scope, process}; pub type Rgb = [f64; 3]; @@ -157,6 +159,9 @@ pub struct Report { pub overlays: Vec, /// Document information entries and XMP streams removed because they held redacted text. pub metadata: usize, + /// What became of the PDF/A and PDF/UA identification when the catalog's XMP packet was + /// removed: written back in a packet of its own, or lost (see [`Identification`]). + pub identification: Identification, /// Places this apply leaves as they are that still hold the removed text (bookmark titles, /// attachments, the comments that remain, XMP on other objects), counted so the user can be /// told; Remove hidden information removes them. See [`Elsewhere`] for when the counts are @@ -544,7 +549,9 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result String { /// The text values of an XMP stream, or `None` when it can't be read. fn xmp_values(doc: &Document, o: &Object) -> Option> { + xmp_text(doc, o).map(|t| xml_values(&t)) +} + +/// The decoded text of an XMP stream, or `None` when it can't be read. +pub(crate) fn xmp_text(doc: &Document, o: &Object) -> Option { let Object::Stream(s) = &*doc.resolve(o) else { return None }; // Decoded strictly: a tolerant decode returns whatever came before the damage, and the part // after it would go unsearched. A chain of more than one filter has no strict decoder here, @@ -384,7 +389,7 @@ fn xmp_values(doc: &Document, o: &Object) -> Option> { return None; } let bytes = s.decoded_first_filter_strict(MAX_XMP_BYTES).ok()?; - decode_xmp(&bytes).map(|t| xml_values(&t)) + decode_xmp(&bytes) } /// What the removed text is looked for with, and the searching left to do it. @@ -461,19 +466,30 @@ impl Search { } } +/// What [`scrub`] did. +#[derive(Debug, Default)] +pub(crate) struct Scrubbed { + /// `/Info` entries and XMP streams removed. + pub(crate) removed: usize, + /// What became of the PDF/A and PDF/UA identification of a removed catalog packet. + pub(crate) identification: crate::Identification, +} + /// Remove the metadata that still holds `removed` text: matching `/Info` entries, and the /// catalog's and pages' XMP streams that mention it. `complete` is false when the collection of /// removed text was cut short; then, as when the terms themselves exceed their limits, every -/// `/Info` string and every XMP stream goes. Returns how many entries and streams went. -pub(crate) fn scrub(doc: &mut Document, removed: &[String], complete: bool) -> Result { +/// `/Info` string and every XMP stream goes. A removed catalog packet's PDF/A and PDF/UA +/// identification is written back in a packet of its own ([`crate::identification`]). +pub(crate) fn scrub(doc: &mut Document, removed: &[String], complete: bool) -> Result { scrub_within(doc, removed, complete, MAX_WORK) } /// [`scrub`] with the searching it may do given, so tests can run out of it. -pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: bool, limit: usize) -> Result { +pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: bool, limit: usize) -> Result { let search = Search::new(removed, complete, limit); + let mut out = Scrubbed::default(); if search.is_nothing() { - return Ok(0); + return Ok(out); } let mut n = 0; @@ -496,16 +512,30 @@ pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: boo } // XMP on the catalog and on every page. + let root = doc.root(); for holder in catalog_and_pages(doc) { let Some(xmp) = doc.get(holder).as_dict().and_then(|d| d.get(b"Metadata").cloned()) else { continue }; if search.xmp(doc, &xmp) { - doc.update_dict(holder, |d| { - d.remove(b"Metadata"); + // The catalog's packet says whether the file conforms to PDF/A or PDF/UA: that much + // is written back, in a packet of its own. + let replacement = if Some(holder) == root { + let (id, packet) = crate::identification::keep(doc, &xmp, &search); + out.identification = id; + packet.map(|p| doc.add(Object::Stream(p))) + } else { + None + }; + doc.update_dict(holder, |d| match replacement { + Some(r) => d.set(b"Metadata".to_vec(), Object::Ref(r)), + None => { + d.remove(b"Metadata"); + } })?; n += 1; } } - Ok(n) + out.removed = n; + Ok(out) } /// The catalog and the pages: the objects whose XMP [`scrub`] checks. diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 24fd6eeba8..7a19040e52 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -299,10 +299,10 @@ fn metadata_search_has_one_budget_for_every_value() { let info = format!("<< {entries}/Producer (Test) >>"); let fresh = || with_metadata(b"BT /F1 10 Tf 10 100 Td (Bluefin) Tj ET", &info, ""); let mut doc = fresh(); - assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap(), 0, "room to search: nothing goes"); + assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap().removed, 0, "room to search: nothing goes"); let mut doc = fresh(); // Each value costs about 40; fifty of them don't fit in 1000. - let n = metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1000).unwrap(); + let n = metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1000).unwrap().removed; assert!(n > 20, "past the budget the rest goes ({n} went)"); } @@ -3226,6 +3226,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { mod descriptions; mod elsewhere; +mod identification; mod inline_images; mod limits; mod metadata_fail_closed; diff --git a/crates/redact/src/tests/identification.rs b/crates/redact/src/tests/identification.rs new file mode 100644 index 0000000000..c896e335fe --- /dev/null +++ b/crates/redact/src/tests/identification.rs @@ -0,0 +1,123 @@ +//! #2539 review: the PDF/A and PDF/UA identification of a catalog XMP packet the metadata check +//! removes is written back in a packet of its own, or reported lost. + +use super::*; +use crate::{Claim, Identification}; + +const DOC_XMP_HEAD: &str = r#""#; +const DOC_XMP_TAIL: &str = r#""#; + +/// One page showing "Codename BLUEFIN report", whose catalog XMP is `xmp` (raw bytes). +fn page(xmp: &[u8]) -> Document { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 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("", b"BT /F1 10 Tf 10 100 Td (Codename BLUEFIN report) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + stream("/Type /Metadata /Subtype /XML", xmp), + b"<< /Producer (Test) >>".to_vec(), + ]; + Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 8 0 R").build())).unwrap() +} + +/// Apply a mark over "BLUEFIN" and return the report and the catalog's XMP text afterwards. +fn redact(doc: &mut Document) -> (Report, Option) { + mark(doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(doc, None).unwrap(); + let xmp = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"Metadata").cloned()); + let text = xmp.and_then(|x| crate::metadata::xmp_text(doc, &x)); + (r, text) +} + +/// The packet that names the redacted word goes, and one holding only its PDF/A and PDF/UA +/// identification takes its place: the PDF/A properties written as attributes, PDF/UA's as an +/// element under a prefix of its own, both found by namespace. Nothing else is copied. +#[test] +fn the_identification_of_a_removed_packet_is_written_back() { + let body = r#"1Bluefin reportAda Lovelace"#; + let mut doc = page(format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()); + let (r, text) = redact(&mut doc); + assert_eq!(r.identification, Identification { pdfa: Claim::Kept, pdfua: Claim::Kept }, "{r:?}"); + assert_eq!(r.metadata, 1, "{r:?}"); + let text = text.expect("a packet is written back"); + assert!(!text.to_lowercase().contains("bluefin") && !text.contains("Lovelace"), "{text}"); + let xml = roxmltree::Document::parse(&text).expect("well-formed"); + let value = |ns: &str, local: &str| { + xml.descendants().find(|n| n.tag_name().namespace() == Some(ns) && n.tag_name().name() == local).and_then(|n| n.text()).map(str::to_string) + }; + let a = "http://www.aiim.org/pdfa/ns/id/"; + let ua = "http://www.aiim.org/pdfua/ns/id/"; + assert_eq!((value(a, "part").as_deref(), value(a, "conformance").as_deref(), value(ua, "part").as_deref()), (Some("2"), Some("B"), Some("1"))); + // PDF/A-2 accepts pdfuaid only through an extension schema describing it. + assert_eq!(value("http://www.aiim.org/pdfa/ns/schema#", "prefix").as_deref(), Some("pdfuaid"), "{text}"); +} + +/// A packet without identification is removed as before, and nothing is said about it. +#[test] +fn a_packet_without_identification_is_only_removed() { + let body = r#"Bluefin report"#; + let mut doc = page(format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()); + let (r, text) = redact(&mut doc); + assert_eq!((r.identification, text), (Identification::default(), None), "{r:?}"); +} + +/// The identification is lost, and said to be, when its value isn't the code it must be (a +/// conformance level that is a word) or when the packet can't be read (here it isn't UTF-8). +#[test] +fn an_identification_that_cannot_be_kept_is_reported_lost() { + let wordy = r#""#; + let mut doc = page(format!("{DOC_XMP_HEAD}{wordy}{DOC_XMP_TAIL}").as_bytes()); + let (r, text) = redact(&mut doc); + assert_eq!((r.identification, text), (Identification { pdfa: Claim::Lost, pdfua: Claim::None }, None), "{r:?}"); + + let latin1 = [ + DOC_XMP_HEAD.as_bytes(), + br#""#, + DOC_XMP_TAIL.as_bytes(), + ] + .concat(); + let mut doc = page(&latin1); + let (r, text) = redact(&mut doc); + assert_eq!((r.identification, text), (Identification { pdfa: Claim::None, pdfua: Claim::Lost }, None), "{r:?}"); + assert!(r.identification.lost()); +} + +/// When the removed text couldn't be searched for, every catalog packet goes, but a part number +/// and a conformance letter, one letter or digit each, can't hold it and are written back; a +/// revision year could, and isn't. +#[test] +fn unsearched_text_keeps_only_one_character_codes() { + let body = r#"Annual figures"#; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 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("", b"BT /F1 10 Tf 10 100 Td (ABC) Tj ET"), + // Glyph names outside the glyph list: the removed text isn't known. + FONT.replace("95 0 R", "6 0 R").replace(" /Widths", " /Encoding << /Differences [65 /g17 /g18 /g19] >> /Widths").into_bytes(), + widths(), + stream("/Type /Metadata /Subtype /XML", format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()), + ]; + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap(); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.identification, Identification { pdfa: Claim::Kept, pdfua: Claim::None }, "{r:?}"); + let xmp = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"Metadata").cloned()).unwrap(); + let text = crate::metadata::xmp_text(&doc, &xmp).unwrap(); + assert!(text.contains("4") && !text.contains("2020") && !text.contains("Annual"), "{text}"); +} + +/// The extension schema PDF/A-2 needs for pdfuaid is fixed text, but if the redacted words are +/// among its words ("schema" here) it isn't written, and the PDF/UA claim is reported lost. +#[test] +fn the_extension_schema_is_left_out_when_it_uses_a_redacted_word() { + let body = r#"Schema notes"#; + let mut doc = page(format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()); + let s = crate::metadata::scrub_within(&mut doc, &["SCHEMA".into()], true, 1 << 28).unwrap(); + assert_eq!(s.identification, Identification { pdfa: Claim::Kept, pdfua: Claim::Lost }); + let xmp = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"Metadata").cloned()).unwrap(); + let text = crate::metadata::xmp_text(&doc, &xmp).unwrap(); + assert!(!text.to_lowercase().contains("schema") && text.contains("U"), "{text}"); +} diff --git a/crates/redact/src/tests/metadata_matching.rs b/crates/redact/src/tests/metadata_matching.rs index 441ad104a3..8cab62b0ce 100644 --- a/crates/redact/src/tests/metadata_matching.rs +++ b/crates/redact/src/tests/metadata_matching.rs @@ -54,7 +54,7 @@ fn unicode_forms_of_the_removed_text_match() { ); let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); let removed = ["M\u{FC}ller".to_string(), "\u{FB01}nance".to_string(), "Bluefin".to_string()]; - let n = metadata::scrub_within(&mut doc, &removed, true, 1 << 28).unwrap(); + let n = metadata::scrub_within(&mut doc, &removed, true, 1 << 28).unwrap().removed; assert_eq!(info_keys(&doc), ["Author", "Producer"]); assert_eq!(n, 3); } @@ -65,7 +65,7 @@ fn unicode_forms_of_the_removed_text_match() { fn a_value_that_normalizes_to_far_more_text_counts_as_holding_it() { let info = format!("<< /Subject {} /Producer (Test) >>", utf16be(&"\u{FDFA}".repeat(50))); let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); - assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap(), 1); + assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap().removed, 1); assert_eq!(info_keys(&doc), ["Producer"]); } @@ -82,7 +82,7 @@ fn utf16le(s: &str) -> String { fn a_utf16le_string_is_searched_as_utf16le() { let info = format!("<< /Title {} /Subject {} /Producer (Test) >>", utf16le("Синий план"), utf16le("Годовой отчёт")); let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); - assert_eq!(metadata::scrub_within(&mut doc, &["СИНИЙ".into()], true, 1 << 28).unwrap(), 1); + assert_eq!(metadata::scrub_within(&mut doc, &["СИНИЙ".into()], true, 1 << 28).unwrap().removed, 1); assert_eq!(info_keys(&doc), ["Subject", "Producer"]); // A bookmark titled the same way is counted as still holding it. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 75f6e1463c..0c4c5c81c8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1724,13 +1724,14 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { } else { crate::i18n::fmt(tl!("Applied {n} redaction marks. Save to remove the content from the file."), &[("n", &marks.to_string())]) }; - // Where the removed text is still held that applying leaves as it is: said in the - // same notification, so it can't replace the first sentence. - let left = app.active_ids().and_then(|(_, id)| app.session.get(id)).and_then(|d| d.redaction_elsewhere()); - match left.and_then(|e| still_holding_note(&e)) { - Some(note) => app.notify(format!("{applied} {note}")), - None => app.notify(applied), - } + // Where the removed text is still held that applying leaves as it is, and what + // became of a PDF/A or PDF/UA claim in the document XMP it removed: said in the + // same notification, so neither can replace the first sentence. + let doc = app.active_ids().and_then(|(_, id)| app.session.get(id)); + let left = doc.and_then(|d| d.redaction_elsewhere()).and_then(|e| still_holding_note(&e)); + let id = doc.and_then(|d| d.redaction_identification()).and_then(|id| identification_note(&id)); + let message = [Some(applied), left, id].into_iter().flatten().collect::>().join(" "); + app.notify(message); } } _ => {} @@ -1807,6 +1808,33 @@ pub(crate) fn still_holding_note(e: &pdfcraft_engine::RedactionElsewhere) -> Opt Some(crate::i18n::fmt(tl!(template), &[("places", &list)])) } +/// The sentence telling the user what became of the document's PDF/A or PDF/UA identification +/// when applying redactions removed the XMP packet that held it (and the redacted text), or +/// `None` when no such packet was removed. +pub(crate) fn identification_note(id: &pdfcraft_engine::RedactionIdentification) -> Option { + use pdfcraft_engine::RedactionClaim as Claim; + let named = |claim: Claim| -> Option { + match (id.pdfa == claim, id.pdfua == claim) { + (true, true) => Some(tl!("PDF/A and PDF/UA").to_string()), + (true, false) => Some("PDF/A".to_string()), + (false, true) => Some("PDF/UA".to_string()), + (false, false) => None, + } + }; + let kept = named(Claim::Kept).map(|s| crate::i18n::fmt(tl!("Its {standard} identification was written back on its own."), &[("standard", &s)])); + let lost = named(Claim::Lost).map(|s| { + crate::i18n::fmt( + tl!("Its {standard} identification couldn't be kept, so the file no longer says it conforms to {standard}."), + &[("standard", &s)], + ) + }); + if kept.is_none() && lost.is_none() { + return None; + } + let head = tl!("The document's XMP metadata held the redacted text and was removed.").to_string(); + Some([Some(head), kept, lost].into_iter().flatten().collect::>().join(" ")) +} + /// Edits built from the dialog's drafts. fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { if req.props_now diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 517609d0fa..53d4257f01 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -179,6 +179,9 @@ impl PdfCraftApp { pdfcraft_engine::EditNotice::Undrawn(t) => crate::content_ui::undrawn_message(t), pdfcraft_engine::EditNotice::BackdropsLeftOff { pages } => crate::marks_ui::backdrops_left_off_message(pages), pdfcraft_engine::EditNotice::RedactionElsewhere(e) => crate::dialogs::still_holding_note(e).unwrap_or_else(|| n.to_string()), + pdfcraft_engine::EditNotice::RedactionIdentification(id) => { + crate::dialogs::identification_note(id).unwrap_or_else(|| n.to_string()) + } }); } let out = self.session.take_js_output(id); diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index f32341adf6..8f75b44985 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -325,3 +325,56 @@ fn applying_says_where_the_removed_text_is_still_held() { "Applied 1 redaction mark. Save to remove the content from the file. The redacted text also appears in 1 bookmark title, which redaction doesn't change. Remove hidden information removes them." ); } + +/// #2539 review: applying redactions that remove the document's XMP packet, which named the +/// redacted word and said the file conforms to PDF/A, says that the identification was written +/// back, in the same notification as the count of marks applied. +#[test] +fn applying_says_the_pdfa_identification_was_written_back() { + let xmp = r#"Bluefin report"#; + let content = "BT /F1 18 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("pdfa.pdf", None, bytes).unwrap(); + app + }); + crate::support::settle(&mut h); + assert!(h.state_mut().execute("redact.mark")); + h.run_steps(2); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(marks(&h), 1); + h.get_by_label("Redact all").click(); + h.run_steps(3); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(marks(&h), 0); + let toast = h.state().toast.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert_eq!( + toast, + "Applied 1 redaction mark. Save to remove the content from the file. The document's XMP metadata held the redacted text and was removed. Its PDF/A identification was written back on its own." + ); +}