Accessibility: alt text for every object, decorative flag, more checker rules (#386) (#396)

- Model: Float carries AltText (title, decorative; the description of shapes and groups);
  InlineObject::alt_text/set_alt_text/is_decorative; TableProps.description. Capped input.
- DOCX: wp:docPr / pic:cNvPr / wps:cNvPr descr, title and the [MS-ODRAWXML] adec:decorative
  extension read and written for pictures, shapes, text boxes, groups and members, charts;
  OLE objects get their alt text in the kept XML; w:tblCaption + w:tblDescription.
- Commands: object.altText {text?, title?, decorative?} (picture.altText kept as alias),
  table.altText {title?, text?}, view.accessibilityPane, view.altTextPane.
- Checker (cmd/accessibility.rs): alt text on every object and table (decorative passes),
  heading order, header rows, blank table rows/columns, merged cells, WCAG contrast,
  floating objects, blank paragraphs; issues carry pos/select and fix actions.
- UI: Accessibility pane (grouped, jump-to, fix buttons), Alt Text pane, Picture/Shape
  Format > Accessibility > Alt Text, right-click > View Alt Text...; strings in all catalogs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-11 16:01:20 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 56f9456f03
commit d5bcb9b490
40 changed files with 1031 additions and 88 deletions
+70 -10
View File
@@ -75,16 +75,16 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"left"?, "top"?, "right"?, "bottom"? (fractions 0–0.45)}"#)
.when(has_picture),
// Kept for scripts written before every object had alt text: `object.altText` on a picture.
CommandSpec::new("picture.altText", "Alt Text", "Picture Format › Accessibility", |s, v| {
let t = p::req_str(v, "text")?.to_string();
with_obj(s, |o| {
if let InlineObject::Image { alt, .. } = o {
*alt = t.clone();
}
})
p::req_str(v, "text")?;
alt_text(s, v)
})
.params(r#"{"text": string}"#)
.params(r#"{"text": string, "title"?: string, "decorative"?: bool}"#)
.when(has_picture),
CommandSpec::new("object.altText", "Alt Text", "Shape Format › Accessibility", alt_text)
.params(r#"{"text"?: string, "title"?: string, "decorative"?: bool} (the selected picture, shape, text box, group or chart)"#)
.when(has_object),
CommandSpec::new("picture.corrections", "Corrections", "Picture Format › Adjust", |s, v| {
let b = p::f32(v, "brightness").unwrap_or(0.0).clamp(-100.0, 100.0);
let c = p::f32(v, "contrast").unwrap_or(0.0).clamp(-100.0, 100.0);
@@ -790,6 +790,27 @@ fn obj_size(o: &InlineObject) -> (f32, f32) {
o.frame().map_or((0.0, 0.0), |(w, h, _)| (w, h))
}
/// Alt Text: the selected object's description, title and decorative mark, in one undo step.
fn alt_text(s: &mut Session, v: &Value) -> CmdResult {
let (text, title, decorative) = (p::str(v, "text"), p::str(v, "title"), p::bool(v, "decorative"));
if text.is_none() && title.is_none() && decorative.is_none() {
return Err(CmdError::Params("`text`, `title` or `decorative` is required".into()));
}
with_obj(s, |o| {
if let Some(t) = text {
o.set_alt_text(t);
}
if let Some(f) = o.float_mut() {
if let Some(t) = title {
f.alt.title = wordcraft_doc::para::cap_alt(t);
}
if let Some(d) = decorative {
f.alt.decorative = d;
}
}
})
}
fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult {
let (pos, _) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let o = edit_obj(s, &pos, f)?;
@@ -1138,13 +1159,16 @@ fn group(s: &mut Session, _: &Value) -> CmdResult {
let mut obj = obj.clone();
obj.set_size(cw, ch);
if let Some(f) = obj.float_mut() {
*f = Float::default();
// Each member keeps its own alt text.
let alt = std::mem::take(&mut f.alt);
*f = Float { alt, ..Float::default() };
f.set_spin(spin);
}
GroupChild { x: x - x0, y: y - y0, obj }
})
.collect();
let mut float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| f.clone()).unwrap_or_default();
// The group takes the first member's placement; its members keep their own alt text.
let mut float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| Float { alt: Default::default(), ..f.clone() }).unwrap_or_default();
float.set_spin(Spin::default());
let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children, canvas: None };
// Take the members out, last first so the earlier positions hold, and put the group where
@@ -1195,7 +1219,9 @@ fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
// Grown to its smallest size about its centre, so it stays centred where it was.
let (x, y) = (x - (mw - cw) / 2.0, y - (mh - ch) / 2.0);
if let Some(f) = c.float_mut() {
*f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), ..float.clone() };
// Each member keeps its own alt text; the group's placement is shared out.
let alt = std::mem::take(&mut f.alt);
*f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), alt, ..float.clone() };
f.set_spin(spin);
}
c
@@ -1638,4 +1664,38 @@ mod tests {
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc.text_box_chains().is_empty());
}
#[test]
fn shape_alt_text_and_decorative_are_one_undo_step_each() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.shape", &json!({"kind": "ellipse"})).unwrap();
s.run("object.altText", &json!({"text": "A green circle", "title": "Logo"})).unwrap();
let (_, o) = selected(&s).unwrap();
assert_eq!((o.alt_text(), o.frame().map(|f| f.2.alt.title.as_str()), o.is_decorative()), ("A green circle", Some("Logo"), false));
s.run("object.altText", &json!({"decorative": true})).unwrap();
assert!(selected(&s).unwrap().1.is_decorative());
s.run("edit.undo", &json!({})).unwrap();
let (_, o) = selected(&s).unwrap();
assert!(!o.is_decorative() && o.alt_text() == "A green circle");
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s).unwrap().1.alt_text(), "");
// Hostile input is capped, and nothing to set is an error.
s.run("object.altText", &json!({"text": "x".repeat(50_000)})).unwrap();
assert_eq!(selected(&s).unwrap().1.alt_text().len(), wordcraft_doc::para::MAX_ALT_TEXT);
assert!(s.run("object.altText", &json!({})).is_err());
// A picture-only alias stays a picture-only alias.
assert!(s.run("picture.altText", &json!({"text": "no"})).is_err());
}
#[test]
fn table_alt_text_sets_title_and_description() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
s.run("table.altText", &json!({"title": "Prices", "text": "Prices by year"})).unwrap();
let t = s.doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.clone()) } else { None }).unwrap();
assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), Some("Prices by year")));
s.run("table.altText", &json!({"text": ""})).unwrap();
let t = s.doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.clone()) } else { None }).unwrap();
assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), None));
}
}