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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-02 01:16:34 +08:00
1 parent 17a9345064
commit 6ce5b5fe43
20 files changed
+601 -24

No files matched your search

Generated
+1
View File
@@ -4585,6 +4585,7 @@ dependencies = [
"pdfcraft-model",
"pdfcraft-testkit",
"pdfcraft-xfa",
"roxmltree 0.21.2",
"thiserror 2.0.21",
"unicode-normalization",
]
+8
View File
@@ -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)
}
+1 -1
View File
@@ -545,7 +545,7 @@ pub fn tools() -> Vec<ToolDef> {
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"])),
+34
View File
@@ -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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/" pdfaid:part="2" pdfaid:conformance="B" pdfuaid:part="1"/><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Bluefin report</dc:title></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#;
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]
+31
View File
@@ -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<String> {
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<pdfcraft_redact::Identification> {
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).
+5
View File
@@ -187,6 +187,9 @@ pub(crate) struct EditCtx {
pub(crate) redaction_overlays: Vec<pdfcraft_cos::ObjRef>,
/// What the edit's applied redactions left as it was that still holds the removed text.
pub(crate) redaction_elsewhere: Option<pdfcraft_redact::Elsewhere>,
/// 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)?;
+1
View File
@@ -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;
+5 -1
View File
@@ -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);
+32
View File
@@ -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<u8> {
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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="2" pdfaid:conformance="B"/><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Bluefin report</dc:title></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#;
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<String> = 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]
+2
View File
@@ -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 }
+11 -1
View File
@@ -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
+204
View File
@@ -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#"<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">"#,
r#"<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">"#,
r#"<pdfaSchema:schema>PDF/UA identification schema</pdfaSchema:schema><pdfaSchema:namespaceURI>http://www.aiim.org/pdfua/ns/id/</pdfaSchema:namespaceURI><pdfaSchema:prefix>pdfuaid</pdfaSchema:prefix>"#,
r#"<pdfaSchema:property><rdf:Seq><rdf:li rdf:parseType="Resource">"#,
r#"<pdfaProperty:name>part</pdfaProperty:name><pdfaProperty:valueType>Integer</pdfaProperty:valueType><pdfaProperty:category>internal</pdfaProperty:category><pdfaProperty:description>The part of ISO 14289 the file conforms to</pdfaProperty:description>"#,
r#"</rdf:li></rdf:Seq></pdfaSchema:property></rdf:li></rdf:Bag></pdfaExtension:schemas></rdf:Description>"#,
);
/// 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<Stream>) {
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<String>, valid: fn(&str) -> bool| -> Option<String> {
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#"<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">"#);
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!("<pdfaid:{name}>{v}</pdfaid:{name}>"));
}
}
body.push_str("</rdf:Description>");
} 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!("<pdfuaid:rev>{r}</pdfuaid:rev>")).unwrap_or_default();
body.push_str(&format!(r#"<rdf:Description rdf:about="" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/"><pdfuaid:part>{p}</pdfuaid:part>{rev}</rdf:Description>"#));
if extension {
body.push_str(PDFUA_EXTENSION);
}
}
None => id.pdfua = Claim::Lost,
}
}
if body.is_empty() {
return (id, None);
}
let packet = format!(
"<?xpacket begin=\"\u{FEFF}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">{body}</rdf:RDF></x:xmpmeta>\n<?xpacket end=\"w\"?>"
);
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<String>,
conformance: Option<String>,
amd: Option<String>,
rev: Option<String>,
}
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<Found> {
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)
}
+8 -1
View File
@@ -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<ObjRef>,
/// 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<Report, Redac
return Err(RedactError::Residue(residue));
}
// 6. The document's metadata must not keep what the pages no longer show (#2448).
report.metadata = metadata::scrub(doc, &removed_text, !removed_cut)?;
let scrubbed = metadata::scrub(doc, &removed_text, !removed_cut)?;
report.metadata = scrubbed.removed;
report.identification = scrubbed.identification;
// What it doesn't scrub is counted, never changed.
report.still_holding = elsewhere::find(doc, &removed_text, !removed_cut);
// The previous revision still holds the removed content: the next save must rewrite.
+38 -8
View File
@@ -375,6 +375,11 @@ pub(crate) fn text_string(s: &pdfcraft_cos::PdfString) -> String {
/// The text values of an XMP stream, or `None` when it can't be read.
fn xmp_values(doc: &Document, o: &Object) -> Option<Vec<String>> {
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<String> {
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<Vec<String>> {
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<usize, pdfcraft_cos::CosError> {
/// `/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<Scrubbed, pdfcraft_cos::CosError> {
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<usize, pdfcraft_cos::CosError> {
pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: bool, limit: usize) -> Result<Scrubbed, pdfcraft_cos::CosError> {
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.
+3 -2
View File
@@ -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;
+123
View File
@@ -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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">"#;
const DOC_XMP_TAIL: &str = r#"</rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#;
/// One page showing "Codename BLUEFIN report", whose catalog XMP is `xmp` (raw bytes).
fn page(xmp: &[u8]) -> Document {
let objs: Vec<Vec<u8>> = 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<String>) {
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#"<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="2" pdfaid:conformance="B"/><rdf:Description rdf:about="" xmlns:ua="http://www.aiim.org/pdfua/ns/id/"><ua:part>1</ua:part></rdf:Description><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Bluefin report</dc:title><dc:creator>Ada Lovelace</dc:creator></rdf:Description>"#;
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#"<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Bluefin report</dc:title></rdf:Description>"#;
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#"<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="2" pdfaid:conformance="Bluefin"/>"#;
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#"<rdf:Description rdf:about="" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/" pdfuaid:part="1" dc:title="Bluefin caf\xE9"/>"#,
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#"<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="4" pdfaid:rev="2020"/><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Annual figures</dc:title></rdf:Description>"#;
let objs: Vec<Vec<u8>> = 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("<pdfaid:part>4</pdfaid:part>") && !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#"<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="2" pdfaid:conformance="U"/><rdf:Description rdf:about="" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/" pdfuaid:part="1"/><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Schema notes</dc:title></rdf:Description>"#;
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("<pdfaid:conformance>U</pdfaid:conformance>"), "{text}");
}
+3 -3
View File
@@ -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.
+35 -7
View File
@@ -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::<Vec<_>>().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<String> {
use pdfcraft_engine::RedactionClaim as Claim;
let named = |claim: Claim| -> Option<String> {
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::<Vec<_>>().join(" "))
}
/// Edits built from the dialog's drafts.
fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
if req.props_now
+3
View File
@@ -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);
+53
View File
@@ -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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="2" pdfaid:conformance="B"/><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Bluefin report</dc:title></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#;
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."
);
}