Move sibling structure tags in reading order (#1229)

This commit is contained in:
Earl Co
2026-10-12 01:33:55 -04:00
committed by GitHub
parent 26a68d9765
commit 829afbc985
9 changed files with 155 additions and 5 deletions
+1 -1
View File
@@ -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 {
+49
View File
@@ -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<Self> {
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<String> {
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text))
}
+40
View File
@@ -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::<Vec<_>>(), [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::<Vec<_>>(), [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();
+22
View File
@@ -255,6 +255,28 @@ impl Automation {
.map_err(failed)?;
self.accessibility_tags(a)
}
pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result<Value> {
let reference = |number: &str, generation: &str| -> Result<pdfcraft_cos::ObjRef> {
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 {
+1
View File
@@ -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" => {
+8
View File
@@ -901,6 +901,14 @@ pub fn tools() -> Vec<ToolDef> {
"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"])),
+19
View File
@@ -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::<Vec<_>>(), [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",
+11
View File
@@ -1210,6 +1210,12 @@ pub enum Edit {
property: a11y::TagProperty,
value: Option<String>,
},
/// 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)?;
}
+4 -4
View File
@@ -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"