Accessibility: set a table's Summary and a header cell's Scope (#4103, #4104) (#4130)

* M12: set a table's Summary and a header cell's Scope (#4103, #4104)

accessibility_set_tag_property takes `summary` (Table tags) and `scope`
(TH tags: Row, Column or Both). Both are Table attributes, written into
the tag's attribute object with /O /Table in /A: an existing one is
edited in place, keeping other attribute objects and their revision
numbers; one reached by reference is copied into the tag's own /A first,
so tags sharing it keep their value; a new one is added when there is
none. Clearing drops the key, an attribute object left with only /O,
and an empty /A. The tag type is checked after the role map.

accessibility_tags lists both. Setting a summary clears the
table-summary finding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12: edit the Table attribute object that holds the value (#4103, #4104)

With more than one /O /Table attribute object in a tag's /A, setting a
summary or scope edited the first one even when a later one held the
key, and clearing left that later value in place (both still read back
as the old value). Setting now edits the object holding the key (else
the first Table object, else a new one), and clearing removes the key
from every Table object holding it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pat26-dotcom
2026-10-23 10:42:54 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ba01244987
commit 7322091d95
6 changed files with 301 additions and 11 deletions
+138 -5
View File
@@ -1,6 +1,6 @@
//! Properties of reachable indirect structure elements.
use pdfcraft_cos::{CosError, Document, ObjRef, Object, PdfString};
use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, PdfString};
use crate::alt;
@@ -17,6 +17,10 @@ pub struct TagInfo {
pub actual_text: Option<String>,
pub alt: Option<String>,
pub language: Option<String>,
/// A table's `Summary` attribute.
pub summary: Option<String>,
/// A header cell's `Scope` attribute (`Row`, `Column` or `Both`).
pub scope: Option<String>,
/// Zero-based page when `/Pg` is known.
pub page: Option<usize>,
}
@@ -36,6 +40,10 @@ pub enum TagProperty {
ActualText,
AlternateText,
Language,
/// A table's `Summary`: a Table attribute (an attribute object with `/O /Table` in `/A`).
Summary,
/// A header cell's `Scope` (a Table attribute): `Row`, `Column` or `Both`.
Scope,
}
/// Where to place a structure tag relative to one of its direct siblings.
@@ -53,6 +61,8 @@ impl TagProperty {
"actual_text" => Some(Self::ActualText),
"alternate_text" => Some(Self::AlternateText),
"language" => Some(Self::Language),
"summary" => Some(Self::Summary),
"scope" => Some(Self::Scope),
_ => None,
}
}
@@ -64,6 +74,17 @@ impl TagProperty {
Self::ActualText => b"ActualText",
Self::AlternateText => b"Alt",
Self::Language => b"Lang",
Self::Summary => b"Summary",
Self::Scope => b"Scope",
}
}
/// The standard structure type a Table attribute belongs to; `None` for the tag's own entries.
fn table_attribute_of(self) -> Option<&'static str> {
match self {
Self::Summary => Some("Table"),
Self::Scope => Some("TH"),
_ => None,
}
}
}
@@ -82,6 +103,10 @@ pub enum TagError {
AmbiguousChildren,
#[error("the tag is already at the requested position")]
AlreadyThere,
#[error("{0} applies only to {1} tags")]
WrongTagType(&'static str, &'static str),
#[error("a scope must be Row, Column or Both")]
InvalidScope,
#[error(transparent)]
Cos(#[from] CosError),
}
@@ -142,6 +167,8 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
actual_text: text(doc, &e.dict, b"ActualText"),
alt: text(doc, &e.dict, b"Alt"),
language: text(doc, &e.dict, b"Lang"),
summary: table_attribute(doc, &e.dict, b"Summary").and_then(|o| o.as_string().map(PdfString::to_text)),
scope: table_attribute(doc, &e.dict, b"Scope").and_then(|o| o.as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
page: e.page.and_then(|p| page_index.get(&p).copied()),
})
})
@@ -200,11 +227,97 @@ fn direct_marks(doc: &Document, item: &Object, page: Option<ObjRef>, out: &mut V
}
}
/// Change one property of a reachable tag; `None` removes a text property.
pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> {
if !alt::elements(doc).iter().any(|e| e.object == object) {
return Err(TagError::NotATag(object));
/// The attribute objects of a tag's `/A` entry (one object, or an array of them each optionally
/// followed by its revision number), as written: each entry and its revision number.
fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option<Object>)>, bool) {
let Some(a) = d.get(b"A") else { return (Vec::new(), false) };
// An indirect array is read as its items.
let resolved = doc.resolve(a);
let Object::Array(items) = &*resolved else { return (vec![(a.clone(), None)], false) };
let mut out: Vec<(Object, Option<Object>)> = Vec::new();
for item in items {
match (item, out.last_mut()) {
(Object::Int(_), Some((_, revision @ None))) => *revision = Some(item.clone()),
_ => out.push((item.clone(), None)),
}
}
(out, true)
}
/// Whether attribute object `o` (resolved) is a Table attribute object.
fn is_table_attributes(o: &Object) -> bool {
o.as_dict().is_some_and(|d| d.name(b"O") == Some(b"Table"))
}
/// The value of `key` in the first Table attribute object of tag `d` that has it.
fn table_attribute(doc: &Document, d: &Dict, key: &[u8]) -> Option<Object> {
attribute_entries(doc, d).0.iter().map(|(o, _)| doc.resolve(o)).filter(|o| is_table_attributes(o)).find_map(|o| o.as_dict()?.get(key).cloned())
}
/// Set `key` in the Table attribute object of `object`'s `/A` entry that holds it (else the first
/// one, else a new one), or with `None` remove it from every Table attribute object that holds it.
/// An edited object is written into the tag's own `/A`, so an attribute object other tags share
/// keeps its value for them; one left with nothing but its owner goes, and so does an `/A` left
/// empty.
fn set_table_attribute(doc: &mut Document, object: ObjRef, key: &[u8], value: Option<Object>) -> Result<(), TagError> {
let dict = doc.get(object).as_dict().cloned().ok_or(TagError::NotATag(object))?;
let (entries, array) = attribute_entries(doc, &dict);
// Each entry with its Table attributes (resolved), when it is a Table attribute object.
let mut entries: Vec<(Object, Option<Object>, Option<Dict>)> = entries
.into_iter()
.map(|(o, r)| {
let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(a)).and_then(|a| a.as_dict().cloned());
(o, r, table)
})
.collect();
let holding = |e: &(Object, Option<Object>, Option<Dict>)| e.2.as_ref().is_some_and(|d| d.contains(key));
match value {
Some(v) => match entries.iter().position(holding).or_else(|| entries.iter().position(|e| e.2.is_some())) {
Some(i) => {
if let Some((o, _, Some(attributes))) = entries.get_mut(i) {
attributes.set(key.to_vec(), v);
*o = Object::Dict(attributes.clone());
}
}
None => {
let mut attributes = Dict::new();
attributes.set(b"O".to_vec(), Object::name("Table"));
attributes.set(key.to_vec(), v);
entries.push((Object::Dict(attributes), None, None));
}
},
None => {
if !entries.iter().any(holding) {
// Nothing to clear.
return Ok(());
}
for (o, _, table) in &mut entries {
if let Some(attributes) = table.as_mut().filter(|a| a.contains(key)) {
attributes.remove(key);
*o = Object::Dict(attributes.clone());
}
}
entries.retain(|(_, _, table)| !table.as_ref().is_some_and(|a| a.iter().all(|(k, _)| k.as_slice() == b"O")));
}
}
let entries: Vec<(Object, Option<Object>)> = entries.into_iter().map(|(o, r, _)| (o, r)).collect();
doc.update_dict(object, |d| match entries.as_slice() {
[] => {
d.remove(b"A");
}
[(only, None)] if !array => d.set(b"A".to_vec(), only.clone()),
_ => d.set(b"A".to_vec(), Object::Array(entries.into_iter().flat_map(|(o, r)| std::iter::once(o).chain(r)).collect())),
})?;
Ok(())
}
/// Change one property of a reachable tag; `None` removes a text property. A table's summary and
/// a header cell's scope are Table attributes ([`TagProperty::Summary`], [`TagProperty::Scope`]),
/// set only on tags of that type after the role map.
pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> {
let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else {
return Err(TagError::NotATag(object));
};
// ActualText may intentionally contain leading or trailing whitespace; preserve it.
let value = value.filter(|s| !s.is_empty());
if value.is_some_and(|s| s.len() > 65_536) {
@@ -215,6 +328,26 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert
{
return Err(TagError::InvalidType);
}
if let Some(owner) = property.table_attribute_of() {
let role_map =
alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let ty = element.dict.name(b"S").map(|s| alt::standard(doc, &role_map, s)).unwrap_or_default();
if ty != owner.as_bytes() {
let name = if property == TagProperty::Summary { "summary" } else { "scope" };
return Err(TagError::WrongTagType(name, owner));
}
let value = match value {
Some(s) if property == TagProperty::Scope => {
if !matches!(s, "Row" | "Column" | "Both") {
return Err(TagError::InvalidScope);
}
Some(Object::name(s))
}
Some(s) => Some(Object::String(PdfString::text(s))),
None => None,
};
return set_table_attribute(doc, object, property.key(), value);
}
doc.update_dict(object, |d| match value {
Some(s) if property == TagProperty::Type => d.set(property.key().to_vec(), Object::name(s)),
Some(s) => d.set(property.key().to_vec(), Object::String(PdfString::text(s))),
+97
View File
@@ -367,6 +367,103 @@ fn reachable_tag_properties_round_trip_and_reject_unrelated_objects() {
assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(4, 1), TagProperty::Title, Some("No")), Err(TagError::NotATag(_))));
}
/// A table (object 4) with a header cell (6) and a data cell (7); `th` is the header cell's
/// dictionary and `extra` more objects from 8 on, of which the Document element (3) lists `kids`
/// after the table. The role map maps /MyTable to /Table.
fn table_doc(th: &str, extra: &[&str], kids: &str) -> Document {
let mut objs = vec![
"<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(),
"<< /Type /StructTreeRoot /K 3 0 R /RoleMap << /MyTable /Table >> >>".to_string(),
format!("<< /S /Document /K [4 0 R {kids}] >>"),
"<< /S /Table /K [5 0 R] >>".to_string(),
"<< /S /TR /K [6 0 R 7 0 R] >>".to_string(),
th.to_string(),
"<< /S /TD >>".to_string(),
];
objs.extend(extra.iter().map(|s| s.to_string()));
pdf(&objs, "")
}
/// The attribute objects of tag `r`'s /A entry as written (one object or an array).
fn attributes(doc: &Document, r: pdfcraft_cos::ObjRef) -> Option<pdfcraft_cos::Object> {
doc.get(r).as_dict()?.get(b"A").cloned()
}
#[test]
fn a_table_summary_is_a_table_attribute() {
let mut doc = table_doc("<< /S /TH >>", &["<< /S /MyTable >>"], "8 0 R");
let (table, th) = (pdfcraft_cos::ObjRef::new(4, 0), pdfcraft_cos::ObjRef::new(6, 0));
set_tag_property(&mut doc, table, TagProperty::Summary, Some("Opening hours by day")).unwrap();
let a = attributes(&doc, table).unwrap();
let a = a.as_dict().unwrap();
assert_eq!(a.name(b"O"), Some(&b"Table"[..]));
assert_eq!(
a.get(b"Summary").and_then(pdfcraft_cos::Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(),
Some("Opening hours by day")
);
assert_eq!(tags(&doc).iter().find(|t| t.object == table).unwrap().summary.as_deref(), Some("Opening hours by day"));
// Only a table takes one (after the role map: /MyTable is a table).
assert!(matches!(set_tag_property(&mut doc, th, TagProperty::Summary, Some("No")), Err(TagError::WrongTagType(..))));
set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), TagProperty::Summary, Some("Mapped")).unwrap();
// Cleared, the attribute object has nothing left and goes.
set_tag_property(&mut doc, table, TagProperty::Summary, None).unwrap();
assert_eq!(attributes(&doc, table), None);
}
#[test]
fn a_header_cell_scope_keeps_its_other_attributes() {
// The cell's /A holds a layout attribute object, then a table one with a revision number.
let mut doc = table_doc("<< /S /TH /A [ << /O /Layout /Placement /Block >> << /O /Table /ColSpan 2 >> 1 ] >>", &[], "");
let th = pdfcraft_cos::ObjRef::new(6, 0);
set_tag_property(&mut doc, th, TagProperty::Scope, Some("Column")).unwrap();
let Some(pdfcraft_cos::Object::Array(items)) = attributes(&doc, th) else { panic!("{:?}", attributes(&doc, th)) };
assert_eq!(items.len(), 3, "{items:?}");
assert_eq!(items[0].as_dict().and_then(|d| d.name(b"O")), Some(&b"Layout"[..]));
let table = items[1].as_dict().unwrap();
assert_eq!((table.int(b"ColSpan"), table.name(b"Scope")), (Some(2), Some(&b"Column"[..])));
assert_eq!(items[2].as_int(), Some(1), "the revision number stays with its object");
assert_eq!(tags(&doc).iter().find(|t| t.object == th).unwrap().scope.as_deref(), Some("Column"));
// Cleared, the span stays.
set_tag_property(&mut doc, th, TagProperty::Scope, None).unwrap();
let Some(pdfcraft_cos::Object::Array(items)) = attributes(&doc, th) else { panic!() };
assert_eq!(items[1].as_dict().map(|d| (d.int(b"ColSpan"), d.contains(b"Scope"))), Some((Some(2), false)));
// A scope is Row, Column or Both, and only header cells have one.
for bad in ["row", "Sideways", " Both"] {
assert!(matches!(set_tag_property(&mut doc, th, TagProperty::Scope, Some(bad)), Err(TagError::InvalidScope)), "{bad}");
}
assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(7, 0), TagProperty::Scope, Some("Row")), Err(TagError::WrongTagType(..))));
}
/// With two Table attribute objects, the one holding the value is the one set and cleared
/// (#4130 review): a value in the second isn't left behind or shadowed.
#[test]
fn the_table_attribute_object_holding_a_value_is_the_one_edited() {
let mut doc = table_doc("<< /S /TH /A [ << /O /Table /ColSpan 2 >> << /O /Table /Scope /Row >> ] >>", &[], "");
let th = pdfcraft_cos::ObjRef::new(6, 0);
let scopes = |doc: &Document| -> Vec<Option<Vec<u8>>> {
let Some(pdfcraft_cos::Object::Array(items)) = attributes(doc, th) else { return vec![] };
items.iter().map(|i| i.as_dict().and_then(|d| d.name(b"Scope")).map(<[u8]>::to_vec)).collect()
};
set_tag_property(&mut doc, th, TagProperty::Scope, Some("Column")).unwrap();
assert_eq!(scopes(&doc), [None, Some(b"Column".to_vec())]);
assert_eq!(tags(&doc).iter().find(|t| t.object == th).unwrap().scope.as_deref(), Some("Column"));
set_tag_property(&mut doc, th, TagProperty::Scope, None).unwrap();
assert_eq!(scopes(&doc), [None], "the second object had only the scope: {:?}", attributes(&doc, th));
assert_eq!(tags(&doc).iter().find(|t| t.object == th).unwrap().scope, None);
}
#[test]
fn a_shared_attribute_object_is_not_changed_for_other_tags() {
// Both cells point at attribute object 8.
let mut doc = table_doc("<< /S /TH /A 8 0 R >>", &["<< /O /Table /Scope /Row >>"], "");
doc.update_dict(pdfcraft_cos::ObjRef::new(7, 0), |d| d.set(b"A".to_vec(), pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)))).unwrap();
set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(6, 0), TagProperty::Scope, Some("Both")).unwrap();
assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(8, 0)).as_dict().and_then(|d| d.name(b"Scope")), Some(&b"Row"[..]));
let a = attributes(&doc, pdfcraft_cos::ObjRef::new(6, 0)).unwrap();
assert_eq!(a.as_dict().and_then(|d| d.name(b"Scope")), Some(&b"Both"[..]), "the cell's own copy: {a:?}");
assert_eq!(attributes(&doc, pdfcraft_cos::ObjRef::new(7, 0)), Some(pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0))));
}
#[test]
fn tag_hierarchy_keeps_indirect_parent_through_inline_tags() {
let doc = structure_doc(&["<< /S /Document /K 4 0 R >>".into(), "<< /S /Sect /K << /S /Div /K 5 0 R >> >>".into(), "<< /S /Figure >>".into()]);
+1 -1
View File
@@ -504,7 +504,7 @@ impl Automation {
"object": t.object.num, "generation": t.object.generation, "type": t.kind,
"parent": t.parent.map(|p| json!({ "object": p.num, "generation": p.generation })), "depth": t.depth,
"title": t.title, "actual_text": t.actual_text, "alternate_text": t.alt,
"language": t.language, "page": t.page.map(|p| p + 1),
"language": t.language, "summary": t.summary, "scope": t.scope, "page": t.page.map(|p| p + 1),
})
})
.collect();
+3 -3
View File
@@ -1054,7 +1054,7 @@ pub fn tools() -> Vec<ToolDef> {
t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.")
.cmd("a11y.alt_text")
.with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])),
t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language, one page at a time: count is how many tags match, next the offset of the following page (null at the end). types, pages and max_depth keep only some tags. Inline tag dictionaries have no stable object reference and are not listed.")
t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text, language, a table's summary and a header cell's scope, one page at a time: count is how many tags match, next the offset of the following page (null at the end). types, pages and max_depth keep only some tags. Inline tag dictionaries have no stable object reference and are not listed.")
.ro()
.cmd("a11y.tags")
.with(schema(
@@ -1072,11 +1072,11 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("a11y.tags")
.with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 } }), &["doc", "object", "generation"])),
t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.")
t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language, summary (Table tags: the table's Summary attribute), scope (TH tags: Row, Column or Both). Summary and scope are written in the tag's Table attribute object (/A, /O /Table); the role map decides a tag's type. Empty or omitted value clears text properties, summary and scope; type requires a nonempty PDF name. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] },
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] },
"value": { "type": "string" },
}), &["doc", "object", "generation", "property"])),
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, or content not found on its page are rejected. Undoable.")
+60
View File
@@ -2730,6 +2730,66 @@ trailer << /Root 1 0 R >>
assert!(matches!(a.call("accessibility_set_alt", &json!({ "doc": fd, "figure": 6, "alt": "x" })), Err(ToolError::Failed(_))));
}
/// A table's summary and a header cell's scope are set through `accessibility_set_tag_property`
/// (#4103, #4104): the summary clears the table-summary finding, both are listed with the tags,
/// undone, and saved.
#[test]
fn table_summary_and_header_scope_through_automation() {
let dir = workdir("a11y-table");
let content = "/TH << /MCID 0 >> BDC 10 150 40 20 re f EMC /TD << /MCID 1 >> BDC 60 150 40 20 re f EMC";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 11 0 R >> endobj
6 0 obj << /S /Document /P 5 0 R /K [7 0 R] >> endobj
7 0 obj << /S /Table /P 6 0 R /K [8 0 R] >> endobj
8 0 obj << /S /TR /P 7 0 R /K [9 0 R 10 0 R] >> endobj
9 0 obj << /S /TH /P 8 0 R /Pg 3 0 R /K 0 >> endobj
10 0 obj << /S /TD /P 8 0 R /Pg 3 0 R /K 1 >> endobj
11 0 obj << /Nums [0 [9 0 R 10 0 R]] >> endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
std::fs::write(dir.join("table.pdf"), pdf).unwrap();
let mut a = auto(&dir);
let fd = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
let summary_status = |a: &mut Automation| {
let r = ok(a, "accessibility_check", json!({ "doc": fd, "rules": ["table-summary"] }));
r["results"].as_array().unwrap().iter().find(|x| x["rule"] == "table-summary").unwrap()["status"].as_str().unwrap().to_owned()
};
assert_eq!(summary_status(&mut a), "failed");
let args =
|object: u32, property: &str, value: &str| json!({ "doc": fd, "object": object, "generation": 0, "property": property, "value": value });
let tags = ok(&mut a, "accessibility_set_tag_property", args(7, "summary", "Opening hours by day"));
let tag = |tags: &Value, object: u64| tags["tags"].as_array().unwrap().iter().find(|t| t["object"] == object).unwrap().clone();
assert_eq!(tag(&tags, 7)["summary"], "Opening hours by day");
assert_eq!(summary_status(&mut a), "passed");
let tags = ok(&mut a, "accessibility_set_tag_property", args(9, "scope", "Column"));
assert_eq!(tag(&tags, 9)["scope"], "Column");
// A scope is Row, Column or Both; only a table has a summary and only a header cell a scope.
for (object, property, value) in [(9, "scope", "column"), (10, "scope", "Row"), (9, "summary", "No")] {
assert!(
matches!(err(&mut a, "accessibility_set_tag_property", args(object, property, value)), ToolError::Failed(_)),
"{property} {value} on {object}"
);
}
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Change tag properties");
assert_eq!(tag(&ok(&mut a, "accessibility_tags", json!({ "doc": fd })), 9)["scope"], Value::Null);
ok(&mut a, "edit_redo", json!({ "doc": fd }));
ok(&mut a, "doc_save", json!({ "doc": fd, "path": "tagged-table.pdf" }));
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("tagged-table.pdf")).unwrap())).unwrap();
// Saving renumbers the objects: the tags are found by type.
let saved = pdfcraft_engine::a11y::tags(&saved);
assert_eq!(saved.iter().find(|t| t.kind == "Table").and_then(|t| t.summary.as_deref()), Some("Opening hours by day"));
assert_eq!(saved.iter().find(|t| t.kind == "TH").and_then(|t| t.scope.as_deref()), Some("Column"));
}
#[test]
fn marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved() {
let dir = workdir("a11y-artifact");
+2 -2
View File
@@ -8929,8 +8929,8 @@ milestone = "M12"
status = "partial"
commands = ["a11y.tags"]
tools = ["accessibility_tags", "accessibility_set_tag_property"]
evidence = ["crates/a11y/src/tests.rs::reachable_tag_properties_round_trip_and_reject_unrelated_objects", "crates/a11y/src/tests.rs::tag_hierarchy_keeps_indirect_parent_through_inline_tags", "crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/ui-egui/tests/it/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"]
notes = "The engine, headless tools and right-side editor list reachable indirect tags with parent and depth and edit type, title, actual text, alternate text and language with undo. Inline tag dictionaries and structural operations remain planned with the Tags Panel (#982)."
evidence = ["crates/a11y/src/tests.rs::reachable_tag_properties_round_trip_and_reject_unrelated_objects", "crates/a11y/src/tests.rs::a_table_summary_is_a_table_attribute", "crates/a11y/src/tests.rs::a_header_cell_scope_keeps_its_other_attributes", "crates/a11y/src/tests.rs::a_shared_attribute_object_is_not_changed_for_other_tags", "crates/automation/tests/it/automation.rs::table_summary_and_header_scope_through_automation", "crates/a11y/src/tests.rs::tag_hierarchy_keeps_indirect_parent_through_inline_tags", "crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/ui-egui/tests/it/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"]
notes = "The engine, headless tools and right-side editor list reachable indirect tags with parent and depth and edit type, title, actual text, alternate text and language with undo. accessibility_set_tag_property also sets a table's Summary and a header cell's Scope (Row, Column, Both) in the tag's Table attribute object, keeping its other attribute objects and revision numbers and leaving shared attribute objects unchanged for other tags; the Tags panel doesn't offer these two yet. Inline tag dictionaries and structural operations remain planned with the Tags Panel (#982)."
[[feature]]
id = "a11y.role-map"