mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Move sibling structure tags in reading order (#1229)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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"])),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user