diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index e7d8223ee..983bd1afa 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -22,7 +22,7 @@ mod tags; pub use alt::{AltError, Figure, figures, mark_decorative, set_alt}; pub use headings::{Heading, headings}; pub use report::{REPORT_WORDS, report_html}; -pub use tags::{TagError, TagInfo, TagProperty, set_tag_property, tags}; +pub use tags::{TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tags}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Category { diff --git a/crates/a11y/src/tags.rs b/crates/a11y/src/tags.rs index 4ba25fd79..00f178b07 100644 --- a/crates/a11y/src/tags.rs +++ b/crates/a11y/src/tags.rs @@ -26,6 +26,13 @@ pub enum TagProperty { Language, } +/// Where to place a structure tag relative to one of its direct siblings. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TagPlacement { + Before, + After, +} + impl TagProperty { pub fn from_id(id: &str) -> Option { match id { @@ -57,10 +64,52 @@ pub enum TagError { InvalidType, #[error("tag property text is too long (maximum 65536 bytes)")] TooLong, + #[error("both tags must be distinct, reachable, direct children of the same structure parent")] + NotSiblings, + #[error("the parent must list each tag exactly once in its /K array")] + AmbiguousChildren, + #[error("the tag is already at the requested position")] + AlreadyThere, #[error(transparent)] Cos(#[from] CosError), } +/// Reorder two direct sibling tags in their parent's `/K` array. Content and page appearance +/// stay untouched; tagged reading order follows the new array order. +pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: TagPlacement) -> Result<(), TagError> { + if object == anchor { + return Err(TagError::NotSiblings); + } + let reachable = alt::elements(doc); + if !reachable.iter().any(|e| e.0 == object) || !reachable.iter().any(|e| e.0 == anchor) { + return Err(TagError::NotSiblings); + } + let parent_of = |r| doc.get(r).as_dict().and_then(|d| d.get(b"P").and_then(Object::as_ref)); + let Some(parent) = parent_of(object) else { return Err(TagError::NotSiblings) }; + if parent_of(anchor) != Some(parent) { + return Err(TagError::NotSiblings); + } + let owner = doc.get(parent); + let Some(children) = owner.as_dict().and_then(|d| d.get(b"K").and_then(Object::as_array)) else { + return Err(TagError::AmbiguousChildren); + }; + let one_position = |child| { + let mut matches = children.iter().enumerate().filter(|(_, item)| item.as_ref() == Some(child)); + matches.next().and_then(|(index, _)| matches.next().is_none().then_some(index)) + }; + let (Some(from), Some(to)) = (one_position(object), one_position(anchor)) else { return Err(TagError::AmbiguousChildren) }; + let mut reordered = children.clone(); + let moved = reordered.remove(from); + let target = if from < to { to - 1 } else { to }; + let destination = target + usize::from(placement == TagPlacement::After); + if destination == from { + return Err(TagError::AlreadyThere); + } + reordered.insert(destination, moved); + doc.update_dict(parent, |d| d.set(b"K".to_vec(), Object::Array(reordered)))?; + Ok(()) +} + fn text(doc: &Document, d: &pdfcraft_cos::Dict, key: &[u8]) -> Option { d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text)) } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index abd117312..b890720f5 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -448,6 +448,46 @@ fn figures_are_listed_with_their_alt_text_and_place() { assert_eq!((finding.object, finding.page, finding.bbox), (Some(f[0].obj), Some(0), f[0].bbox)); } +#[test] +fn sibling_tags_move_in_reading_order_without_changing_content() { + let mut doc = figures_doc(); + let before: Vec<_> = tags(&doc).into_iter().map(|tag| tag.object.num).collect(); + assert_eq!(before, [6, 7, 8]); + let content = doc.get(pdfcraft_cos::ObjRef::new(4, 0)); + move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before).unwrap(); + assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::>(), [6, 8, 7]); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(4, 0)), content, "page content stays unchanged"); + assert_eq!( + move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before), + Err(TagError::AlreadyThere) + ); + move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::After).unwrap(); + assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::>(), [6, 7, 8]); + assert_eq!( + move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(6, 0), TagPlacement::Before), + Err(TagError::NotSiblings) + ); + assert_eq!( + move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 1), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before), + Err(TagError::NotSiblings) + ); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + d.set( + b"K".to_vec(), + pdfcraft_cos::Object::Array(vec![ + pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)), + pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)), + pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)), + ]), + ); + }) + .unwrap(); + assert_eq!( + move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::After), + Err(TagError::AmbiguousChildren) + ); +} + #[test] fn setting_alt_text_and_marking_decorative() { let mut doc = figures_doc(); diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index ccf8128d9..e5181394d 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -255,6 +255,28 @@ impl Automation { .map_err(failed)?; self.accessibility_tags(a) } + + pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result { + let reference = |number: &str, generation: &str| -> Result { + let n = u32::try_from(a.int(number)?).map_err(|_| ToolError::InvalidArgs(format!("{number} must be a positive tag object number")))?; + if n == 0 { + return Err(ToolError::InvalidArgs(format!("{number} must be a positive tag object number"))); + } + let g = u16::try_from(a.int(generation)?) + .map_err(|_| ToolError::InvalidArgs(format!("{generation} must be a PDF object generation from accessibility_tags")))?; + Ok(pdfcraft_cos::ObjRef::new(n, g)) + }; + let object = reference("object", "generation")?; + let anchor = reference("anchor", "anchor_generation")?; + let placement = match a.str("placement")? { + "before" => pdfcraft_engine::a11y::TagPlacement::Before, + "after" => pdfcraft_engine::a11y::TagPlacement::After, + other => return Err(ToolError::InvalidArgs(format!("placement must be before or after, not {other:?}"))), + }; + let id = self.doc(a)?.id; + self.session.apply(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement }).map_err(failed)?; + self.accessibility_tags(a) + } } impl Automation { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 8c520740a..53da3a078 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -741,6 +741,7 @@ impl Automation { "accessibility_set_alt" => self.accessibility_set_alt(&a)?, "accessibility_tags" => self.accessibility_tags(&a)?, "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, + "accessibility_move_tag" => self.accessibility_move_tag(&a)?, "sign_id_create" => self.sign_id_create(&a)?, "sign_document" => self.sign_document(&a)?, "sign_keychain_ids" => { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e01cc06b7..d56d976fe 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -901,6 +901,14 @@ pub fn tools() -> Vec { "property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] }, "value": { "type": "string" }, }), &["doc", "object", "generation", "property"])), + t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.") + .with(schema(json!({ + "doc": doc(), "object": { "type": "integer", "minimum": 1 }, + "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "anchor": { "type": "integer", "minimum": 1 }, + "anchor_generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "placement": { "type": "string", "enum": ["before", "after"] }, + }), &["doc", "object", "generation", "anchor", "anchor_generation", "placement"])), t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index d56f75293..420eb41eb 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1734,6 +1734,25 @@ trailer << /Root 1 0 R >> assert_eq!(changed["tags"][1]["title"], "First shape"); assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Change tag properties"); assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": fd }))["tags"][1]["title"], Value::Null); + ok(&mut a, "accessibility_set_tag_property", json!({ "doc": fd, "object": 8, "generation": 0, "property": "title", "value": "Second shape" })); + let moved = ok( + &mut a, + "accessibility_move_tag", + json!({ "doc": fd, "object": 8, "generation": 0, "anchor": 7, "anchor_generation": 0, "placement": "before" }), + ); + assert_eq!((moved["tags"][1]["object"].as_u64(), moved["tags"][2]["object"].as_u64()), (Some(8), Some(7))); + ok(&mut a, "doc_save", json!({ "doc": fd, "path": "reordered.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("reordered.pdf")).unwrap())).unwrap(); + assert_eq!(pdfcraft_engine::a11y::tags(&saved).into_iter().map(|tag| tag.title).collect::>(), [None, Some("Second shape".into()), None]); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Move object"); + assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": fd }))["tags"][1]["object"], 7); + assert!(matches!( + a.call( + "accessibility_move_tag", + &json!({ "doc": fd, "object": 8, "generation": 0, "anchor": 7, "anchor_generation": 0, "placement": "sideways" }) + ), + Err(ToolError::InvalidArgs(_)) + )); assert!(matches!( a.call( "accessibility_set_tag_property", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bb5aeaa79..7d090f505 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1210,6 +1210,12 @@ pub enum Edit { property: a11y::TagProperty, value: Option, }, + /// Move a structure tag before or after a direct sibling in reading order. + MoveTag { + object: pdfcraft_cos::ObjRef, + anchor: pdfcraft_cos::ObjRef, + placement: a11y::TagPlacement, + }, /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. MarkDecorative { figure: u32, @@ -1675,6 +1681,7 @@ impl Edit { Edit::SetInitialView(_) => Label::of("Change initial view"), Edit::SetAltText { .. } => Label::of("Set alternate text"), Edit::SetTagProperty { .. } => Label::of("Change tag properties"), + Edit::MoveTag { .. } => Label::of("Move object"), Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), Edit::AddOcrText { .. } => Label::of("Recognize text"), Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), @@ -1862,6 +1869,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetInitialView(_) | Edit::SetAltText { .. } | Edit::SetTagProperty { .. } + | Edit::MoveTag { .. } | Edit::MarkDecorative { .. } | Edit::AddOcrText { .. } | Edit::SetDocumentScript { .. } @@ -2140,6 +2148,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::SetTagProperty { object, property, value } => { a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::MoveTag { object, anchor, placement } => { + a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; + } Edit::EditTextLine { page, line, text } => { pdfcraft_edit::replace_line(doc, *page, *line, text)?; } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index faef35b10..0ad5e2c67 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8831,11 +8831,11 @@ title = "Reading order tool" area = "M" tier = "P1" milestone = "M12" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["accessibility_move_tag"] +evidence = ["crates/a11y/src/tests.rs::sibling_tags_move_in_reading_order_without_changing_content", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +notes = "The headless tool can move a reachable indirect tag before or after a direct sibling in their parent's /K array as one undoable edit. It preserves page content and rejects ambiguous children. The visual Reading Order tool, inline tags, arbitrary retagging, and moving across parents remain planned." [[feature]] id = "a11y.tags-panel"