mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 08:50:02 -06:00
- 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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
56f9456f03
commit
d5bcb9b490
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user