From 26051871d0772b4befa031f24722320a867d825d Mon Sep 17 00:00:00 2001 From: Enrico Sita Date: Tue, 13 Oct 2026 12:12:01 +0200 Subject: [PATCH] M11: remove PDF/A or PDF/X identification (optimize.remove-standards-id) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file edited after it was made PDF/A or PDF/X can keep claiming a standard it no longer meets. organize::remove_standard_id removes the claim: the pdfaid: XMP properties for PDF/A, or GTS_PDFXVersion and GTS_PDFXConformance in the document information and as pdfxid: XMP properties for PDF/X. Output intents and other claims (PDF/UA) stay. The XMP is edited with the packet editor Document Properties already uses (sync_info and the new remove_properties share its read and write), so the rest of the packet stays byte for byte and unsafe packets are left alone. It is one undoable edit, in Standards ▸ PDF/A as a button worded like its undo label, and the standards_remove_id tool (pdfa or pdfx). Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.toml | 6 ++--- crates/automation/src/lib.rs | 8 ++++++ crates/automation/src/tools.rs | 3 +++ crates/automation/tests/automation.rs | 4 +++ crates/engine/src/lib.rs | 9 +++++++ crates/organize/src/lib.rs | 35 +++++++++++++++++++++++++++ crates/organize/src/tests.rs | 31 ++++++++++++++++++++++++ crates/organize/src/xmp.rs | 35 +++++++++++++++++++++------ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/standards_ui.rs | 11 ++++++++- crates/ui-egui/tests/compare.rs | 5 ++++ parity/acrobat-features.toml | 10 ++++---- 14 files changed, 144 insertions(+), 16 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index be9e2c448e..c084b98ca8 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2acb3f6522d6d1f744343f474fe4c51ebec610a888a7fa83fc9422228e6fa3c1" +sha256 = "861492683ca8d30ee241384d1ddbe2209b9d5dcfd661dae7b184b3dd601b2935" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "17c8579e0e9d1524ffbeb87e7b30c14562dbaac64400826ee786cecfe31648fa" +sha256 = "f3931df85fd67f791fcceaf5eaf5bf3d0d7d0f2d5e0e0ec089a6dd6887b51a69" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2d1c4b4276d02512a4e20101be95a1671de5c4f5cdfd661887af5ebab0db06a6" +sha256 = "1d81f9e76210e32df89d8f0af88dd802ef89f10d453ba94e077856a3064f4247" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b2..3dc2cc8dc6 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -782,6 +782,14 @@ impl Automation { "print_readiness" => self.print_readiness(&a)?, "doc_export_office" => self.doc_export_office(&a)?, "pdfa_convert" => self.pdfa_convert(&a)?, + "standards_remove_id" => { + let standard = match a.str("standard")? { + "pdfa" => pdfcraft_engine::Standard::PdfA, + "pdfx" => pdfcraft_engine::Standard::PdfX, + other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))), + }; + self.apply(&a, Edit::RemoveStandardId { standard })? + } "action_run" => self.action_run(&a)?, "doc_compare_report" => self.doc_compare_report(&a)?, "doc_compare_mark" => self.doc_compare_mark(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78a..ad3ded203f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1073,6 +1073,9 @@ pub fn tools() -> Vec { t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), + t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") + .cmd("standards.pdfa") + .with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])), t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).") .ro() .cmd("actions.wizard") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..9dcbd30306 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5038,6 +5038,10 @@ fn pdfa_through_tools() { assert_eq!(r["declared"]["pdfa"], "PDF/A-3b", "{r}"); assert!(r["issues"].as_array().unwrap().iter().all(|i| i["fixable"] == false), "{r}"); assert!(a.call("pdfa_verify", &json!({ "doc": doc, "level": "9z" })).is_err()); + ok(&mut a, "standards_remove_id", json!({ "doc": doc, "standard": "pdfa" })); + let r = ok(&mut a, "pdfa_verify", json!({ "doc": doc })); + assert!(r["declared"]["pdfa"].is_null() && !r["declared"]["output_intents"].as_array().unwrap().is_empty(), "{r}"); + assert!(matches!(a.call("standards_remove_id", &json!({ "doc": doc, "standard": "pdfa" })), Err(ToolError::Failed(_))), "nothing left"); } #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3432cd868e..5052b73218 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -48,6 +48,7 @@ pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transitio /// One file produced by a split: (1-based first page, last page, PDF bytes). pub use pdfcraft_organize::LabelStyle; +pub use pdfcraft_organize::Standard; pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; pub use pdfcraft_cos::Algorithm; @@ -1529,6 +1530,10 @@ pub enum Edit { ConvertPdfA { level: pdfcraft_preflight::Level, }, + /// Remove what identifies the document as meeting `standard` (PDF/A or PDF/X). + RemoveStandardId { + standard: Standard, + }, /// What a script (button or console) changed in form fields: values, read-only, required /// and visibility. ApplyScriptChanges { @@ -1860,6 +1865,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Run JavaScript", "Run form script", "Save as {level}", + "Remove {standard} identification", "Edit script of {name}", "Delete document JavaScript", "Edit document JavaScript", @@ -1991,6 +1997,7 @@ impl Edit { Edit::XfaEvent { .. } => Label::of("Run form script"), // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), + Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), @@ -2186,6 +2193,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetDocumentScript { .. } | Edit::SetFieldScript { .. } | Edit::ConvertPdfA { .. } + | Edit::RemoveStandardId { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } | Edit::RegisterTextFont { .. } @@ -2488,6 +2496,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::ConvertPdfA { level } => { pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; } + Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, Edit::AddOcrText { page, words } => { pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 3b2873abec..3cae4b3e20 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -579,5 +579,40 @@ fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option) -> Resu Ok(()) } +/// A standard a document can declare it meets, for [`remove_standard_id`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Standard { + PdfA, + PdfX, +} + +impl Standard { + pub fn label(self) -> &'static str { + match self { + Standard::PdfA => "PDF/A", + Standard::PdfX => "PDF/X", + } + } +} + +/// Remove what identifies the document as meeting `standard`: the `pdfaid:` XMP properties of +/// ISO 19005, or ISO 15930's `GTS_PDFXVersion` and `GTS_PDFXConformance` in the document +/// information and as `pdfxid:` XMP properties. Output intents and other metadata stay. Refused +/// when the document declares nothing of the kind. +pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(), OrganizeError> { + let (props, info_keys): (&[&str], &[&str]) = match standard { + Standard::PdfA => (&["pdfaid:part", "pdfaid:conformance", "pdfaid:amd", "pdfaid:rev"], &[]), + Standard::PdfX => (&["pdfxid:GTS_PDFXVersion", "pdfxid:GTS_PDFXConformance"], &["GTS_PDFXVersion", "GTS_PDFXConformance"]), + }; + let mut removed = xmp::remove_properties(doc, props)?; + for key in info_keys { + if info(doc, key).is_some() { + set_info_entry(doc, key.as_bytes(), None)?; + removed = true; + } + } + if removed { Ok(()) } else { Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))) } +} + #[cfg(test)] mod tests; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index d34b1e431d..fdc8739f84 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -928,6 +928,37 @@ fn malformed_output_intents_and_identification_do_not_fail() { assert!(!xmp.contains('\0') && !xmp.contains("GTS_PDFXConformance"), "{xmp}"); } +#[test] +fn removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims() { + let mut x4 = doc_x4("FOGRA51", "icc"); + assert!(matches!(remove_standard_id(&mut x4, Standard::PdfA), Err(OrganizeError::Invalid(_)))); + assert!(!x4.is_modified(), "a refused removal changes nothing"); + remove_standard_id(&mut x4, Standard::PdfX).unwrap(); + let x = pdfx_of(&full_roundtrip(&x4)); + assert_eq!((x.version, x.intents), (None, vec![("FOGRA51".to_string(), b"icc".to_vec())])); + assert!(x.xmp.is_some_and(|x| !x.contains("pdfxid:GTS") && x.contains("pdfuaid:part=\"1\"")), "PDF/UA's claim stays"); + assert!(matches!(remove_standard_id(&mut x4, Standard::PdfX), Err(OrganizeError::Invalid(_))), "nothing left to remove"); + + let mut x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001)", None); + remove_standard_id(&mut x1a, Standard::PdfX).unwrap(); + assert_eq!((info(&x1a, "GTS_PDFXVersion"), info(&x1a, "GTS_PDFXConformance")), (None, None)); +} + +#[test] +fn removing_the_pdfa_identification_removes_its_xmp_properties_in_either_form() { + let xmp = "\ + 2Report\ + "; + let mut doc = doc_print("sRGB IEC61966-2.1", "icc", "/Title (Report)", Some(xmp)); + remove_standard_id(&mut doc, Standard::PdfA).unwrap(); + let x = pdfx_of(&full_roundtrip(&doc)); + let packet = x.xmp.unwrap(); + assert!(!packet.contains("pdfaid:part") && !packet.contains("pdfaid:conformance"), "{packet}"); + assert!(packet.contains(">Report"), "the rest of the packet stays: {packet}"); + assert_eq!(x.intents.len(), 1, "the output intent stays"); +} + #[test] fn duplicate_pages_become_independent_copies() { let out = full_roundtrip(&extract_pages(&doc_b(), &[0, 0]).unwrap()); diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index 3efa8d2653..bbcbbcf83f 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -54,21 +54,42 @@ fn property(key: &str) -> Option<(&'static str, &'static str, Shape)> { /// that can't be edited safely. pub(crate) fn sync_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { let Some((name, ns, shape)) = property(key) else { return Ok(()) }; - let Some(root) = doc.root() else { return Ok(()) }; + edit_packet(doc, |xmp| set_property(xmp, name, ns, shape, value)).map(|_| ()) +} + +/// Remove every occurrence of the properties `names` from the catalog's XMP packet; whether any +/// was there. +pub(crate) fn remove_properties(doc: &mut Document, names: &[&str]) -> Result { + edit_packet(doc, |xmp| { + let mut out: Option = None; + for name in names { + // Each removal shortens the packet, so this ends. + while let Some(next) = set_property(out.as_deref().unwrap_or(xmp), name, "", Shape::Text, "") { + out = Some(next); + } + } + out + }) +} + +/// Rewrite the catalog's XMP packet with `edit` (`None`: unchanged); whether it changed. Files +/// without XMP and packets that can't be edited safely are left alone. +fn edit_packet(doc: &mut Document, edit: impl FnOnce(&str) -> Option) -> Result { + let Some(root) = doc.root() else { return Ok(false) }; // A metadata stream is always indirect (§7.3.8); anything else is left alone. - let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(()) }; + let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(false) }; let obj = doc.get(r); - let Object::Stream(stream) = &*obj else { return Ok(()) }; - let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(()) }; - let Ok(xmp) = String::from_utf8(bytes) else { return Ok(()) }; - let Some(updated) = set_property(&xmp, name, ns, shape, value) else { return Ok(()) }; + let Object::Stream(stream) = &*obj else { return Ok(false) }; + let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(false) }; + let Ok(xmp) = String::from_utf8(bytes) else { return Ok(false) }; + let Some(updated) = edit(&xmp) else { return Ok(false) }; // Written unfiltered, as PDF/A asks of metadata streams, so other tools can read it. let mut dict = stream.dict.clone(); for k in [&b"Filter"[..], b"DecodeParms", b"DL", b"Length"] { dict.remove(k); } doc.set(r, Object::Stream(Stream::from_raw(dict, updated.into_bytes()))); - Ok(()) + Ok(true) } /// `xmp` with the property `name` (namespace `ns`) set to `value`, or removed when `value` is diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e4eb634468..52b52c1ea7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2362,6 +2362,7 @@ action wizard Start Iniciar Change Subject Cambiar el asunto Change Keywords Cambiar las palabras clave Save as {level} Guardar como {level} + Remove {standard} identification Quitar la identificación {standard} Create links Crear enlaces # Multi-object selection and movement (#844) diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 56a785cd3e..8510213037 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2680,6 +2680,7 @@ key Space Tarpas # AI-assisted additions for concurrently merged UI features (2026-10-15). # These additions have not had separate native-speaker review. Mouse wheel scroll speed Pelės ratuko slinkimo greitis + Remove {standard} identification Pašalinti {standard} identifikaciją The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b072755236..fac14f3607 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2658,6 +2658,7 @@ key Space Space Redaction mark appearance Hình thức dấu xóa nội dung nhạy cảm Remove Crop Bỏ xén Remove this leaf tag from reading order and mark its direct content as an artifact Xóa thẻ lá này khỏi thứ tự đọc và đánh dấu nội dung trực tiếp của nó là thành phần trang trí + Remove {standard} identification Gỡ thông tin nhận dạng {standard} Resolution: Độ phân giải: Save anyway Vẫn lưu Save over the changed file? Lưu đè lên tệp đã thay đổi? diff --git a/crates/ui-egui/src/standards_ui.rs b/crates/ui-egui/src/standards_ui.rs index 1d409f2007..5fc4b3d355 100644 --- a/crates/ui-egui/src/standards_ui.rs +++ b/crates/ui-egui/src/standards_ui.rs @@ -53,7 +53,16 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool let mut standards: Vec = declared.pdfa.iter().map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())).collect(); standards.extend(declared.pdfua.map(|p| format!("PDF/UA-{p}"))); let shown = if standards.is_empty() { tl!("none").to_string() } else { standards.join(", ") }; - ui.label(crate::i18n::fmt(tl!("Declared conformance: {shown}"), &[("shown", &shown)])); + let removal = pdfcraft_engine::Edit::RemoveStandardId { standard: pdfcraft_engine::Standard::PdfA }; + let mut remove = false; + ui.horizontal(|ui| { + ui.label(crate::i18n::fmt(tl!("Declared conformance: {shown}"), &[("shown", &shown)])); + // Worded as Edit ▸ Undo will name it. + remove = declared.pdfa.is_some() && widgets::pill_button(ui, &crate::i18n::edit_label(&removal.label()), false).clicked(); + }); + if remove && app.apply_edit(removal) { + app.pdfa.issues = None; + } if !declared.output_intents.is_empty() { ui.label( egui::RichText::new(crate::i18n::fmt(tl!("Output intent: {list}"), &[("list", &declared.output_intents.join(", "))])).color(t.text_muted), diff --git a/crates/ui-egui/tests/compare.rs b/crates/ui-egui/tests/compare.rs index e735668a1b..13b6f0d475 100644 --- a/crates/ui-egui/tests/compare.rs +++ b/crates/ui-egui/tests/compare.rs @@ -96,6 +96,7 @@ fn pdfa_dialog_verifies_and_converts() { assert!(h.state_mut().execute("standards.pdfa")); h.run_steps(2); h.get_by_label("Declared conformance: none"); + assert!(h.query_by_label("Remove PDF/A identification").is_none(), "nothing to remove yet"); h.get_by_label("Verify").click(); h.run_steps(3); h.get_by_label("The document has no XMP metadata"); @@ -106,6 +107,10 @@ fn pdfa_dialog_verifies_and_converts() { assert!(issues.iter().all(|i| !i.fixable), "{issues:?}"); let id = h.state().active_ids().unwrap().1; assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Save as PDF/A-2b")); + h.get_by_label("Remove PDF/A identification").click(); + h.run_steps(3); + h.get_by_label("Declared conformance: none"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Remove PDF/A identification")); } /// A one-page PDF whose XMP declares the given `pdfaid`/`pdfuaid` attributes. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94f..56d5a785a3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7893,11 +7893,11 @@ title = "Remove PDF/A or PDF/X identification" area = "K" tier = "P2" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["standards.pdfa"] +tools = ["standards_remove_id"] +evidence = ["crates/organize/src/tests.rs::removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims", "crates/organize/src/tests.rs::removing_the_pdfa_identification_removes_its_xmp_properties_in_either_form", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"] +notes = "Standards ▸ PDF/A ▸ Remove PDF/A identification, and the standards_remove_id tool (pdfa or pdfx): removes the pdfaid: XMP properties, or GTS_PDFXVersion and GTS_PDFXConformance from the document information and the pdfxid: XMP properties. Output intents and other claims (PDF/UA) stay; a packet that can't be edited safely is left as it is. Not yet: PDF/X removal in the interface (there is no PDF/X dialog yet)" [[feature]] id = "optimize.pdfx5-reference-xobjects"