diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 219e7610dd..8d412de295 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -792,6 +792,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 4246ad6a2c..73925ccb49 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1089,6 +1089,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 b2539c3120..b4e2fc9d4b 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5412,6 +5412,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 8c4ed343cf..4a1fff0271 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -47,6 +47,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; @@ -1567,6 +1568,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 { @@ -1905,6 +1910,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", @@ -2038,6 +2044,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"), @@ -2235,6 +2242,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 { .. } @@ -2549,6 +2557,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 99c228c60f..2f18f1c05e 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -658,5 +658,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 9bd3eb396a..044eec05cd 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1047,6 +1047,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 7c7b7c8d71..3053855a5f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2395,6 +2395,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 6448c6949e..32ebd0b67e 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2720,6 +2720,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 56caf38859..7f25a4b536 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2698,6 +2698,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 6c86eb5880..4fd26fc280 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"