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 22:01:20 +02:00 committed by GitHub
parent 56f9456f03
commit d5bcb9b490
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1031 additions and 88 deletions

View File

@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **433 / 451 (96.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| Ribbon/menu catalog coverage | **436 / 454 (96.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-11 | Accessibility (#386): Alt Text pane for every object and table, Mark as decorative, alt text round-trips in DOCX, deeper checker with fix actions |
| 2026-10-11 | References dialogs (#402): Caption (labels, chapter numbering), Index and Mark Entry, Custom Table of Contents, Source Manager and Create/Edit Source, with TOC/INDEX/XE/SEQ switches honoured on update |
| 2026-10-11 | Document grid (#391): Page Setup › Document Grid sets lines per page and characters per line (`w:docGrid` round trip); lines snap to the line pitch, East Asian characters to the character pitch; View › Gridlines shows it |
| 2026-10-11 | Move tracking (#400): `w:moveFrom`/`w:moveTo` and their named ranges kept in DOCX, a tracked cut and paste of a paragraph or sentence records a move, accept/reject at either end resolves both, green double strikethrough/underline and "Moved from/to" balloons |
@ -164,6 +165,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Progress log: Alt Text pane, decorative objects, deeper accessibility checker (#386) |
| 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 |
| 2026-10-11 | trivial | Progress log: document grid (#391) |
| 2026-10-11 | trivial | Progress log: text direction, Align Text and linked text boxes (#369) |

View File

@ -208,6 +208,41 @@ pub struct Float {
/// (unrotated) frame; `None` = derived from the object (see [`crate::wrap`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub wrap_polygon: Option<std::sync::Arc<crate::wrap::WrapPolygon>>,
/// What the drawing is, for screen readers and exports (`wp:docPr` `@descr`, `@title` and the
/// decorative mark). See [`InlineObject::alt_text`].
#[serde(skip_serializing_if = "AltText::is_empty")]
pub alt: AltText,
}
/// Longest alternative text kept (characters): hostile files and commands are capped.
pub const MAX_ALT_TEXT: usize = 10_000;
/// A drawing's alternative text (Picture/Shape Format › Accessibility › Alt Text).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct AltText {
/// The description of a shape, text box or group. Pictures and charts keep theirs in their
/// own `alt`; read it with [`InlineObject::alt_text`], which knows where it is.
#[serde(skip_serializing_if = "String::is_empty")]
pub text: String,
/// The short title (`wp:docPr/@title`).
#[serde(skip_serializing_if = "String::is_empty")]
pub title: String,
/// Decorative: it adds nothing to the content, so screen readers skip it and the
/// accessibility checker doesn't ask for a description.
#[serde(skip_serializing_if = "is_false")]
pub decorative: bool,
}
impl AltText {
pub fn is_empty(&self) -> bool {
self.text.is_empty() && self.title.is_empty() && !self.decorative
}
}
/// `s` cut to [`MAX_ALT_TEXT`] characters.
pub fn cap_alt(s: &str) -> String {
s.chars().take(MAX_ALT_TEXT).collect()
}
fn is_zero(v: &f32) -> bool {
@ -607,6 +642,28 @@ impl InlineObject {
| InlineObject::FieldEnd
)
}
/// A drawing's description (alternative text), wherever it is kept.
pub fn alt_text(&self) -> &str {
match self {
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => alt,
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => &float.alt.text,
_ => "",
}
}
/// Set a drawing's description (capped at [`MAX_ALT_TEXT`] characters); `false` if this is no drawing.
pub fn set_alt_text(&mut self, text: &str) -> bool {
let text = cap_alt(text);
match self {
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => *alt = text,
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.alt.text = text,
_ => return false,
}
true
}
/// A drawing marked decorative.
pub fn is_decorative(&self) -> bool {
self.frame().is_some_and(|(_, _, f)| f.alt.decorative)
}
pub fn is_floating(&self) -> bool {
match self {
InlineObject::Image { float, .. }

View File

@ -873,8 +873,11 @@ pub struct TableProps {
/// column, banded columns.
pub look: TableLook,
pub shading: Option<Rgb>,
/// Alternative text.
/// Alternative text: the title (`w:tblCaption`).
pub caption: Option<String>,
/// Alternative text: the description (`w:tblDescription`).
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Floating placement (`w:tblpPr`); `None` for a table in the text flow.
#[serde(skip_serializing_if = "Option::is_none")]
pub float: Option<TableFloat>,

View File

@ -2,6 +2,7 @@
use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign,
TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
@ -317,8 +318,8 @@ impl PropCtx {
f.overlap = k.attr("w:val") != Some("never");
}
}
"w:tblCaption" => t.caption = k.attr("w:val").map(str::to_string),
"w:tblDescription" if t.caption.is_none() => t.caption = k.attr("w:val").map(str::to_string),
"w:tblCaption" => t.caption = k.attr("w:val").map(cap_alt),
"w:tblDescription" => t.description = k.attr("w:val").map(cap_alt),
_ => {}
}
}

View File

@ -4,7 +4,7 @@ use std::sync::Arc;
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind};
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap, WrapText};
use wordcraft_doc::para::{AltText, Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap, WrapText, cap_alt};
use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb};
use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table};
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKind, Run, para_block};
@ -771,8 +771,9 @@ impl Reader<'_> {
let ext = c.child("wp:extent");
let dim = |n: &str| ext.and_then(|e| e.attr(n)).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT);
let (w, h) = (dim("cx"), dim("cy"));
let alt = c.child("wp:docPr").and_then(|p| p.attr("descr").filter(|s| !s.is_empty()).or_else(|| p.attr("title"))).unwrap_or("").to_string();
let (alt, meta) = nv_alt(c.child("wp:docPr"));
let mut float = if anchored { anchor_float(c) } else { Float::default() };
float.alt = meta;
if let Some(e) = c.child("wp:effectExtent") {
for (slot, n) in float.effect.iter_mut().zip(["l", "t", "r", "b"]) {
// Negative for a rotated object narrower than its frame; settled below.
@ -806,7 +807,7 @@ impl Reader<'_> {
} else if let Some(g) = gd.child("wpg:wgp") {
self.read_group(sc, g, rels, w, h, float)?
} else if gd.find("a:blip").is_some() {
self.read_pic(gd, rels, w, h, alt, float)?
self.read_pic(gd, rels, w, h, alt.clone(), float)?
} else {
let wsp = gd.find("wps:wsp")?;
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
@ -824,6 +825,9 @@ impl Reader<'_> {
}
obj
};
if !alt.is_empty() {
obj.set_alt_text(&alt);
}
// Word's effect extent also covers a rotated object's overhang; the model keeps that
// apart (it follows from the angle), so take it back out.
if let Some(f) = obj.float_mut() {
@ -973,12 +977,14 @@ impl Reader<'_> {
let max = crate::units::MAX_LEN_PT;
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0));
let obj = if e.name == "pic:pic" {
let alt = e.find("pic:cNvPr").and_then(|p| p.attr("descr")).unwrap_or("").to_string();
self.read_pic(e, rels, w, h, alt, Float::default())
} else {
Some(self.read_wsp(sc, e, rels, w, h, Float::default()))
};
let (alt, meta) = nv_alt(if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") });
let float = Float { alt: meta, ..Float::default() };
let obj =
if e.name == "pic:pic" { self.read_pic(e, rels, w, h, alt.clone(), float) } else { Some(self.read_wsp(sc, e, rels, w, h, float)) };
let obj = obj.map(|mut o| {
o.set_alt_text(&alt);
o
});
if let Some(obj) = obj {
out.push(wordcraft_doc::para::GroupChild { x: fit(x0 + ox * sx, -max), y: fit(y0 + oy * sy, -max), obj });
}
@ -1195,6 +1201,23 @@ fn on_off_attr(e: &El, name: &str) -> bool {
e.attr(name).is_some_and(|v| !matches!(v, "0" | "false" | "off"))
}
/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376
/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and
/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]).
fn nv_alt(p: Option<&El>) -> (String, AltText) {
let Some(p) = p else { return Default::default() };
let title = cap_alt(p.attr("title").unwrap_or(""));
let descr = p.attr("descr").filter(|s| !s.is_empty()).map(cap_alt).unwrap_or_else(|| title.clone());
let decorative = p
.child("a:extLst")
.into_iter()
.flat_map(|l| l.children("a:ext"))
.filter(|x| x.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(crate::xml::DECORATIVE_EXT)))
.flat_map(|x| x.els())
.any(|d| d.local() == "decorative" && on_off_attr(d, "val"));
(descr, AltText { text: String::new(), title, decorative })
}
fn anchor_float(c: &El) -> Float {
let mut f = Float { wrap: Wrap::Square, ..Float::default() };
let behind = on_off_attr(c, "behindDoc");

View File

@ -191,7 +191,7 @@ impl super::Writer<'_> {
/// story part's) and the parts it needs queued for writing. `resize`, for an OLE object, writes
/// the object's current size and position into its own markup (a chart or diagram's frame is
/// written by the caller). `None` when it can't be written back (its parts are gone).
pub(super) fn embedded_xml(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float)>) -> Option<String> {
pub(super) fn embedded_xml(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float, &str)>) -> Option<String> {
let Some(left) = self.embeds.markup_bytes.checked_sub(src.xml.len() as u64) else {
log::warn!("docx: too much embedded-object markup; the rest is left out");
return None;
@ -243,16 +243,16 @@ impl super::Writer<'_> {
}
}
set_rel_ids(&mut root, &ids, 0);
if let Some((w, h, float)) = resize {
self.resize_object(&mut root, w, h, float, 0);
if let Some((w, h, float, alt)) = resize {
self.resize_object(&mut root, w, h, float, alt, 0);
}
Some(root.to_xml())
}
/// Write an OLE object's size (and position, when floating), rotation and flips into its VML
/// shape and DrawingML picture, with the effect extent covering the rotated bounds as for
/// other drawings, and give its drawing a fresh `wp:docPr` id.
fn resize_object(&mut self, e: &mut El, w: f32, h: f32, float: &Float, depth: usize) {
/// other drawings, its alt text into both, and give its drawing a fresh `wp:docPr` id.
fn resize_object(&mut self, e: &mut El, w: f32, h: f32, float: &Float, alt: &str, depth: usize) {
if depth > MAX_DEPTH {
return;
}
@ -261,6 +261,7 @@ impl super::Writer<'_> {
if let Some((_, style)) = e.attrs.iter_mut().find(|(k, _)| k == "style") {
*style = vml_style_with(style, w, h, float);
}
set_attr(e, "alt", alt);
}
"wp:extent" => set_size_attrs(e, w, h),
// A turned object needs an effect extent (filled in below) right after its extent.
@ -299,12 +300,13 @@ impl super::Writer<'_> {
if let Some((_, v)) = e.attrs.iter_mut().find(|(k, _)| k == "id") {
*v = id;
}
set_attr(e, "descr", alt);
}
_ => {}
}
for k in e.kids.iter_mut() {
if let xml::Node::El(c) = k {
self.resize_object(c, w, h, float, depth + 1);
self.resize_object(c, w, h, float, alt, depth + 1);
}
}
}
@ -321,6 +323,15 @@ fn set_size_attrs(e: &mut El, w: f32, h: f32) {
}
}
/// Set attribute `k` of `e` to `v` (added when missing; left out when both are empty).
fn set_attr(e: &mut El, k: &str, v: &str) {
match e.attrs.iter_mut().find(|(n, _)| n == k) {
Some((_, old)) => *old = v.to_string(),
None if !v.is_empty() => e.attrs.push((k.to_string(), v.to_string())),
None => {}
}
}
/// Is attribute `k` a relationship id (see `read::embed::rel_ids`)?
fn is_rel_attr(k: &str) -> bool {
k.starts_with("r:") || k == "o:relid"

View File

@ -395,6 +395,9 @@ fn tblpr_inner(w: &mut W, t: &TableProps) {
if let Some(c) = &t.caption {
w.val("w:tblCaption", c);
}
if let Some(d) = &t.description {
w.val("w:tblDescription", d);
}
}
/// `w:trPr` (when the row has any), with a tracked change last as for [`tblpr`].

View File

@ -1,7 +1,7 @@
//! Blocks, paragraphs, runs, objects and tables → WordprocessingML.
use wordcraft_doc::effects::ShapeEffects;
use wordcraft_doc::para::{Anchor, Float, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::para::{AltText, Anchor, Float, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{CharProps, PropChange, Rgb};
use wordcraft_doc::section::{LineNumberRestart, NoteProps, SectionProps, SectionStart};
use wordcraft_doc::table::Table;
@ -500,7 +500,7 @@ impl Writer<'_> {
InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } => {
// An OLE object read from a file: the object itself, at its current size.
if let Some(src) = ole.as_deref()
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float)))
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str())))
{
self.rev_open(w, props);
w.open("w:r", &[]);
@ -549,7 +549,7 @@ impl Writer<'_> {
.map(std::sync::Arc::new);
}
let float = &float;
self.drawing_open(w, float, *sw, *sh, &docpr, &name, "");
self.drawing_open(w, float, *sw, *sh, &docpr, &name, o.alt_text());
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
@ -568,7 +568,7 @@ impl Writer<'_> {
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
self.drawing_open(w, float, *gw, *gh, &docpr, &name, "");
self.drawing_open(w, float, *gw, *gh, &docpr, &name, o.alt_text());
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
let outer = match canvas {
@ -812,7 +812,7 @@ impl Writer<'_> {
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
w.open("pic:nvPicPr", &[]);
w.empty("pic:cNvPr", &[("id", "0"), ("name", file), ("descr", alt)]);
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
@ -844,7 +844,7 @@ impl Writer<'_> {
w.open("wps:wsp", &[]);
if let Some(id) = id {
let name = shape_name(*kind, freeform.as_deref(), id);
w.empty("wps:cNvPr", &[("id", id), ("name", &name)]);
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt);
}
if *kind == ShapeKind::TextBox {
w.empty("wps:cNvSpPr", &[("txBox", "1")]);
@ -1023,7 +1023,7 @@ impl Writer<'_> {
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
}
w.empty("wp:docPr", &[("id", docpr), ("name", name), ("descr", alt)]);
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt);
}
fn table(&mut self, w: &mut W, t: &Table, rels: &mut PartRels, depth: usize) {
@ -1366,6 +1366,31 @@ fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
(entries > 0).then_some((start, start + entries))
}
/// A drawing's `wp:docPr` (or a group member's `pic:cNvPr`/`wps:cNvPr`, ECMA-376
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
/// from a file with no description) is not written twice.
pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText) {
let mut attrs = vec![("id", id), ("name", name)];
if descr != alt.title || descr.is_empty() {
attrs.push(("descr", descr));
}
if !alt.title.is_empty() {
attrs.push(("title", alt.title.as_str()));
}
if !alt.decorative {
w.empty(tag, &attrs);
return;
}
w.open(tag, &attrs);
w.open("a:extLst", &[]);
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
w.close("a:ext");
w.close("a:extLst");
w.close(tag);
}
/// A DrawingML `a:solidFill` in `c`, or `a:noFill`.
fn solid_fill(w: &mut W, c: Option<wordcraft_doc::props::Rgb>) {
match c {

View File

@ -39,6 +39,11 @@ pub const NAMESPACES: &[(&str, &str)] = &[
("w10", "urn:schemas-microsoft-com:office:word"),
];
/// The `a:ext` URI under which a drawing is marked decorative ([MS-ODRAWXML]), and the
/// namespace of its `adec:decorative` element.
pub const DECORATIVE_EXT: &str = "{C183D7F6-B498-43B3-948B-1728B52AA6E4}";
pub const DECORATIVE_NS: &str = "http://schemas.microsoft.com/office/drawing/2017/decorative";
/// Namespaces that only appear in non-body parts.
const OTHER_NAMESPACES: &[(&str, &str)] = &[
("rel", "http://schemas.openxmlformats.org/package/2006/relationships"),

View File

@ -1778,3 +1778,77 @@ fn wrap_text_sides_round_trip() {
// A value outside the schema is no side (read as both sides).
assert_eq!(WrapText::from_ooxml("middle"), None);
}
#[test]
fn alt_text_title_and_decorative_round_trip_for_every_object() {
use wordcraft_doc::para::AltText;
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let pic = InlineObject::Image {
media: key,
w: 100.0,
h: 50.0,
alt: "A kiln at dusk".into(),
float: Float { alt: AltText { title: "Kiln".into(), ..Default::default() }, ..Default::default() },
crop: [0.0; 4],
ole: None,
};
let shape = |alt: AltText| InlineObject::Shape {
kind: ShapeKind::Ellipse,
w: 80.0,
h: 40.0,
fill: Some(Rgb(1, 2, 3)),
stroke: None,
stroke_width: 0.0,
float: Float { alt, ..Default::default() },
story: None,
effects: Default::default(),
freeform: None,
};
let described = shape(AltText { text: "Process flow & \"steps\"".into(), ..Default::default() });
let decorative = shape(AltText { decorative: true, ..Default::default() });
let group = InlineObject::Group {
w: 120.0,
h: 60.0,
float: Float { alt: AltText { text: "Two circles".into(), ..Default::default() }, ..Default::default() },
ch_w: 120.0,
ch_h: 60.0,
children: vec![wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj: shape(AltText { text: "Left circle".into(), ..Default::default() }) }],
canvas: None,
};
let mut p = Paragraph::with_text("objects ", CharProps::default());
for o in [pic, described, decorative, group] {
let end = p.len();
p.insert_object(end, o, &CharProps::default()).unwrap();
}
let mut t = Table::new(2, 2, 300.0);
t.props.caption = Some("Prices".into());
t.props.description = Some("Prices by year".into());
d.body = vec![para_block(p), Arc::new(Block::Table(t))];
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"{C183D7F6-B498-43B3-948B-1728B52AA6E4}"><adec:decorative xmlns:adec="http://schemas.microsoft.com/office/drawing/2017/decorative" val="1"/>"#), "{xml}");
assert!(
xml.contains(r#"<w:tblLook "#) && xml.contains(r#"<w:tblCaption w:val="Prices"/><w:tblDescription w:val="Prices by year"/></w:tblPr>"#),
"{xml}"
);
let r = wordcraft_docx::read(&bytes).expect("read");
let objs = &paras(&r)[0].objects;
let alts: Vec<_> =
objs.iter().map(|o| (o.alt_text().to_string(), o.frame().map(|f| f.2.alt.title.clone()).unwrap_or_default(), o.is_decorative())).collect();
assert_eq!(
alts,
[
("A kiln at dusk".into(), "Kiln".into(), false),
("Process flow & \"steps\"".into(), String::new(), false),
(String::new(), String::new(), true),
("Two circles".into(), String::new(), false),
]
);
let InlineObject::Group { children, .. } = &objs[3] else { panic!("{objs:?}") };
assert_eq!(children[0].obj.alt_text(), "Left circle");
let Some(Block::Table(t)) = r.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!() };
assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), Some("Prices by year")));
}

View File

@ -275,6 +275,7 @@ Review|Proofing|Thesaurus|review.thesaurus
Review|Proofing|Word Count|review.wordCount
Review|Speech|Read Aloud|review.readAloud
Review|Accessibility|Check Accessibility|file.accessibility
Review|Accessibility|Alt Text|view.altTextPane
Review|Language|Translate|review.translate
Review|Language|Language|review.language
Review|Comments|New Comment|review.newComment
@ -342,6 +343,7 @@ Table Layout|Table|Select Row|table.selectRow
Table Layout|Table|Select Cell|table.selectCell
Table Layout|Table|View Gridlines|table.viewGridlines
Table Layout|Table|Properties|table.properties
Table Layout|Table|Alt Text|table.altText
Table Layout|Table|Table Direction|table.rtl
Table Layout|Draw|Draw Table|table.draw
Table Layout|Draw|Eraser|table.eraser
@ -401,6 +403,7 @@ Picture Format|Adjust|Reset Picture|picture.reset
Picture Format|Picture Styles|Picture Styles|picture.style
Picture Format|Picture Styles|Picture Border|picture.border
Picture Format|Accessibility|Alt Text|picture.altText
Shape Format|Accessibility|Alt Text|object.altText
Picture Format|Size|Crop|picture.crop
Picture Format|Size|Size|picture.size
Picture Format|Size|Rotation|arrange.rotation

View File

@ -0,0 +1,321 @@
//! The accessibility checker (Review › Check Accessibility): what makes the body hard to use with
//! a screen reader or hard to read, as `{"kind": "error|warning|tip", "rule", "issue", "where",
//! "pos"?, "select"?, "fix"?, "action"?}` items.
//!
//! `where` is the paragraph (or table) path; `pos` a place to put the caret there; `select` the
//! range that selects the object an issue is about; `action` a command (`{"label", "command",
//! "params"}`) that fixes it, run with the caret at `pos` (or the object selected). The rules and
//! their wording are our own; the contrast rule uses the WCAG 2 contrast ratio.
use serde_json::{Value, json};
use wordcraft_doc::para::{InlineObject, OBJ};
use wordcraft_doc::props::{Rgb, TextColor, VMerge};
use wordcraft_doc::table::Table;
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef};
/// Most issues reported (a hostile document can't make the report huge).
const MAX_ISSUES: usize = 500;
/// This many empty paragraphs in a row look like spacing made with Enter.
const BLANK_RUN: usize = 3;
/// The checker's findings for `doc`, errors first, then warnings, then tips (each in document order).
pub fn check(doc: &Document) -> Vec<Value> {
let mut c = Checker { doc, issues: Vec::new() };
c.run();
let rank = |v: &Value| match v["kind"].as_str() {
Some("error") => 0,
Some("warning") => 1,
_ => 2,
};
c.issues.sort_by_key(rank);
c.issues.truncate(MAX_ISSUES);
c.issues
}
struct Checker<'a> {
doc: &'a Document,
issues: Vec<Value>,
}
fn at(path: &Path, off: usize) -> Pos {
Pos { story: StoryRef::Body, path: path.clone(), off }
}
impl Checker<'_> {
fn push(&mut self, v: Value) {
if self.issues.len() < MAX_ISSUES * 2 {
self.issues.push(v);
}
}
fn run(&mut self) {
let doc = self.doc;
let mut last_level: Option<u8> = None;
let mut tables: Vec<Path> = Vec::new();
let mut blank: Vec<Path> = Vec::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
if let Some((tp, _, _)) = path.cell()
&& !tables.contains(&tp)
{
tables.push(tp);
}
self.objects(&path, p);
// Empty paragraphs in a row (top level, no section break): spacing made with Enter.
let consecutive = blank.last().is_none_or(|b| b.0.len() == 1 && path.0.len() == 1 && b.last() + 1 == path.last());
if path.0.len() == 1 && p.text.trim().is_empty() && p.section.is_none() && consecutive {
blank.push(path.clone());
} else {
self.blank_run(&blank);
blank.clear();
if path.0.len() == 1 && p.text.trim().is_empty() && p.section.is_none() {
blank.push(path.clone());
}
}
let rp = doc.styles.resolve_para(&p.props);
if let Some(l) = rp.outline_level.filter(|_| !p.plain_text().trim().is_empty()) {
if let Some(prev) = last_level
&& l > prev + 1
{
self.push(json!({"kind": "warning", "rule": "headingOrder", "issue": format!("Heading level skipped (Heading {} after Heading {})", l + 1, prev + 1), "where": path.0, "pos": at(&path, 0), "fix": "Use the next heading level down, so the outline has no gaps"}));
}
last_level = Some(l);
}
if p.runs.iter().any(|r| r.props.link.is_some()) {
let txt = p.plain_text().to_lowercase();
if txt.contains("click here") {
self.push(json!({"kind": "tip", "rule": "linkText", "issue": "Link text \"click here\" isn't descriptive", "where": path.0, "pos": at(&path, 0)}));
}
}
self.contrast(&path, p, rp.shading);
}
self.blank_run(&blank);
for tp in tables {
if let Some(t) = doc.table(StoryRef::Body, &tp) {
self.table(&tp, t);
}
}
if doc.core.title.trim().is_empty() {
self.push(json!({"kind": "tip", "rule": "docTitle", "issue": "Document has no title", "fix": "file.properties {title}"}));
}
}
fn blank_run(&mut self, blank: &[Path]) {
if blank.len() >= BLANK_RUN
&& let Some(first) = blank.first()
{
self.push(json!({"kind": "tip", "rule": "blankParagraphs", "issue": format!("{} empty paragraphs in a row", blank.len()), "where": first.0, "pos": at(first, 0), "fix": "Use Space Before/After (Layout › Paragraph) or a page break instead of empty paragraphs"}));
}
}
/// Alt text and reading order of the drawings in paragraph `p`.
fn objects(&mut self, path: &Path, p: &Paragraph) {
for off in p.object_offsets() {
let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) else { continue };
let pos = at(path, off);
let select = json!({"anchor": pos, "focus": at(path, off + OBJ.len_utf8())});
// Ink: every stroke is its own object, so asking about each would bury the report.
if o.is_decorative() || o.ink().is_some() {
continue;
}
let titled = o.frame().is_some_and(|(_, _, f)| !f.alt.title.trim().is_empty());
let described = !o.alt_text().trim().is_empty() || titled;
if !described && !self.speaks_for_itself(o) {
let (rule, issue) = match o {
InlineObject::Graphic { .. } => ("altText", "Chart or diagram has no alternative text"),
_ => ("altText", "Missing alternative text"),
};
self.push(json!({
"kind": "error", "rule": rule, "issue": issue, "what": kind_of(o), "where": path.0, "pos": pos, "select": select,
"fix": "Describe it in the Alt Text pane (object.altText), or mark it decorative",
"action": {"label": "Mark as decorative", "command": "object.altText", "params": {"decorative": true}},
}));
}
if o.is_floating() {
let mut v = json!({"kind": "tip", "rule": "floating", "issue": "Object isn't in line with text, so its reading order is unclear", "what": kind_of(o), "where": path.0, "pos": pos, "select": select, "fix": "Place it in line with text (Layout › Wrap Text)"});
// Charts and diagrams keep their imported placement (see objects.rs).
if !matches!(o, InlineObject::Graphic { .. }) {
v["action"] = json!({"label": "In Line with Text", "command": "arrange.wrap", "params": {"wrap": "inline"}});
}
self.push(v);
}
}
}
/// A text box with text in it is read out as that text.
fn speaks_for_itself(&self, o: &InlineObject) -> bool {
o.text_box().and_then(|id| self.doc.parts.get(&id)).is_some_and(|part| {
part.blocks.iter().any(|b| match &**b {
Block::Para(p) => !p.plain_text().trim().is_empty(),
_ => true,
})
})
}
/// Text whose colour is too close to what it's drawn on (WCAG 2: 4.5:1, 3:1 for large text).
fn contrast(&mut self, path: &Path, p: &Paragraph, para_shading: Option<Rgb>) {
let doc = self.doc;
let cell = path.cell().and_then(|(tp, r, c)| {
let t = doc.table(StoryRef::Body, &tp)?;
t.rows.get(r).and_then(|row| row.cells.get(c)).and_then(|c| c.props.shading).or(t.props.shading)
});
let page = doc.settings.page_color.unwrap_or(Rgb::WHITE);
let mut start = 0usize;
for r in &p.runs {
let text = p.text.get(start..start.saturating_add(r.len)).unwrap_or("");
start = start.saturating_add(r.len);
if text.trim().is_empty() || r.props.hidden == Some(true) {
continue;
}
let rc = doc.styles.resolve_char(p.props.style.as_deref(), &r.props);
let TextColor::Rgb(fg) = rc.color else { continue };
let bg = rc.highlight.or(rc.shading).or(para_shading).or(cell).unwrap_or(page);
let ratio = contrast_ratio(fg, bg);
let large = rc.size >= 18.0 || (rc.bold && rc.size >= 14.0);
let need = if large { 3.0 } else { 4.5 };
if ratio < need {
let kind = if ratio < 3.0 { "warning" } else { "tip" };
self.push(json!({"kind": kind, "rule": "contrast", "issue": format!("Text is hard to read: contrast {:.1}:1, {need}:1 needed", ratio), "where": path.0, "pos": at(path, 0), "fix": "Use a darker or lighter text colour, or change the shading behind it"}));
return;
}
}
}
fn table(&mut self, tp: &Path, t: &Table) {
let cell_pos = |r: usize, c: usize| {
let mut v = tp.0.clone();
v.extend([r as u32, c as u32, 0]);
at(&Path(v), 0)
};
if t.props.caption.as_deref().is_none_or(|s| s.trim().is_empty()) && t.props.description.as_deref().is_none_or(|s| s.trim().is_empty()) {
self.push(json!({"kind": "warning", "rule": "tableAltText", "issue": "Table has no alternative text", "where": tp.0, "pos": cell_pos(0, 0), "fix": "Give it a title or description (table.altText)"}));
}
if t.rows.len() >= 2 && !t.rows.first().is_some_and(|r| r.props.header) {
self.push(json!({
"kind": "warning", "rule": "tableHeader", "issue": "Table has no header row", "where": tp.0, "pos": cell_pos(0, 0),
"fix": "Repeat the first row as a header row (table.repeatHeader)",
"action": {"label": "Repeat Header Rows", "command": "table.repeatHeader", "params": {"value": true}},
}));
}
let empty = |c: &wordcraft_doc::table::Cell| {
c.blocks.iter().all(|b| match &**b {
Block::Para(p) => p.text.trim().is_empty(),
_ => false,
})
};
// A table with nothing in it yet is being filled in, not used for spacing.
let filled = !t.rows.iter().flat_map(|r| &r.cells).all(empty);
if t.rows.len() >= 2 && filled {
for (r, row) in t.rows.iter().enumerate() {
if !row.cells.is_empty() && row.cells.iter().all(empty) {
self.push(json!({"kind": "warning", "rule": "blankRow", "issue": format!("Row {} of a table is empty: it looks like spacing", r + 1), "where": tp.0, "pos": cell_pos(r, 0), "fix": "Delete the row and use cell margins or spacing instead"}));
}
}
}
let cols = t.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
if cols >= 2 && t.rows.len() >= 2 && filled {
for c in 0..cols.min(63) {
if t.rows.iter().all(|row| row.cells.get(c).is_none_or(empty)) {
self.push(json!({"kind": "warning", "rule": "blankColumn", "issue": format!("Column {} of a table is empty: it looks like spacing", c + 1), "where": tp.0, "pos": cell_pos(0, c), "fix": "Delete the column and use cell margins instead"}));
}
}
}
if t.rows.iter().flat_map(|r| &r.cells).any(|c| c.span() > 1 || c.props.vmerge != VMerge::None) {
self.push(json!({"kind": "tip", "rule": "mergedCells", "issue": "Table has merged cells, which screen readers can lose track of", "where": tp.0, "pos": cell_pos(0, 0), "fix": "Split the merged cells (Table Layout › Merge)"}));
}
}
}
fn kind_of(o: &InlineObject) -> &'static str {
match o {
InlineObject::Image { ole: Some(_), .. } => "object",
InlineObject::Image { .. } => "picture",
InlineObject::Graphic { .. } => "chart",
InlineObject::Shape { story: Some(_), .. } => "textBox",
InlineObject::Shape { .. } => "shape",
InlineObject::Group { .. } => "group",
_ => "object",
}
}
/// WCAG 2 relative luminance of an sRGB colour.
fn luminance(c: Rgb) -> f64 {
let ch = |v: u8| {
let s = v as f64 / 255.0;
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
};
0.2126 * ch(c.0) + 0.7152 * ch(c.1) + 0.0722 * ch(c.2)
}
/// WCAG 2 contrast ratio of two colours, 1 to 21.
pub fn contrast_ratio(a: Rgb, b: Rgb) -> f64 {
let (la, lb) = (luminance(a), luminance(b));
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use wordcraft_doc::Document;
use wordcraft_doc::props::Rgb;
use crate::Session;
fn rules(s: &mut Session) -> Vec<(String, String)> {
let v = s.run("file.accessibility", &json!({})).unwrap();
v["issues"]
.as_array()
.unwrap()
.iter()
.map(|i| (i["kind"].as_str().unwrap().to_string(), i["rule"].as_str().unwrap_or("").to_string()))
.collect()
}
#[test]
fn contrast_ratio_matches_wcag() {
assert!((super::contrast_ratio(Rgb::BLACK, Rgb::WHITE) - 21.0).abs() < 0.01);
assert!((super::contrast_ratio(Rgb(0x77, 0x77, 0x77), Rgb::WHITE) - 4.48).abs() < 0.01);
}
#[test]
fn shapes_need_alt_text_unless_decorative_and_the_fix_marks_them() {
let mut s = Session::new(Document::from_text("Title"));
s.doc.core.title = "T".into();
s.run("insert.shape", &json!({"kind": "ellipse"})).unwrap();
assert!(rules(&mut s).contains(&("error".into(), "altText".into())));
// The issue says how to fix it: select the object, run the action.
let v = s.run("file.accessibility", &json!({})).unwrap();
let i = v["issues"].as_array().unwrap().iter().find(|i| i["rule"] == "altText").unwrap().clone();
s.run("select.range", &i["select"]).unwrap();
s.run(i["action"]["command"].as_str().unwrap(), &i["action"]["params"]).unwrap();
assert!(!rules(&mut s).iter().any(|(_, r)| r == "altText"));
}
#[test]
fn tables_blank_paragraphs_and_contrast_are_reported() {
let mut s = Session::new(Document::from_text("Intro"));
s.doc.core.title = "T".into();
// Light grey text on white.
s.run("select.all", &json!({})).unwrap();
s.run("format.color", &json!({"color": "BBBBBB"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
for _ in 0..4 {
s.run("text.newParagraph", &json!({})).unwrap();
}
s.run("insert.table", &json!({"rows": 3, "cols": 2})).unwrap();
s.run("text.insert", &json!({"text": "Year"})).unwrap();
let r = rules(&mut s);
for want in
[("warning", "contrast"), ("tip", "blankParagraphs"), ("warning", "tableHeader"), ("warning", "tableAltText"), ("warning", "blankRow")]
{
assert!(r.contains(&(want.0.into(), want.1.into())), "{want:?} in {r:?}");
}
// Fix the header row from the issue's action, with the caret where it says.
let v = s.run("file.accessibility", &json!({})).unwrap();
let i = v["issues"].as_array().unwrap().iter().find(|i| i["rule"] == "tableHeader").unwrap().clone();
s.run("caret.set", &json!({"pos": i["pos"]})).unwrap();
s.run(i["action"]["command"].as_str().unwrap(), &i["action"]["params"]).unwrap();
assert!(!rules(&mut s).iter().any(|(_, r)| r == "tableHeader"));
}
}

View File

@ -1,5 +1,6 @@
//! Command implementations, one module per area. Each module exposes `specs()`.
pub mod accessibility;
pub mod canvas;
pub mod caret;
pub mod citations;

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));
}
}

View File

@ -144,6 +144,23 @@ pub fn specs() -> Vec<CommandSpec> {
}
})
})),
// Table Properties › Alt Text: the title (`w:tblCaption`) and description (`w:tblDescription`).
t(CommandSpec::new("table.altText", "Alt Text", "Table Layout › Table › Properties", |s, v| {
let (title, text) = (p::str(v, "title"), p::str(v, "text"));
if title.is_none() && text.is_none() {
return Err(CmdError::Params("`text` or `title` (string) is required".into()));
}
let set = |x: Option<&str>, cur: &Option<String>| match x {
Some(x) if x.trim().is_empty() => None,
Some(x) => Some(wordcraft_doc::para::cap_alt(x)),
None => cur.clone(),
};
with_table(s, |t| {
t.props.caption = set(title, &t.props.caption);
t.props.description = set(text, &t.props.description);
})
})
.params(r#"{"text"?: string, "title"?: string} (empty clears)"#)),
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text).params(r#"{"separator"?: "tab|comma|paragraph"}"#)),

View File

@ -750,49 +750,8 @@ fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32) -> Paragraph {
}
fn accessibility(s: &mut Session, _: &Value) -> CmdResult {
let mut issues = Vec::new();
let mut last_level: Option<u8> = None;
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for o in &p.objects {
if let InlineObject::Image { alt, .. } = o
&& alt.trim().is_empty()
{
issues.push(json!({"kind": "error", "issue": "Missing alternative text", "where": path.0, "fix": "Select the picture and run picture.altText"}));
}
// Charts and diagrams can't be given alt text yet (see objects.rs), so only report them.
if let InlineObject::Graphic { alt, .. } = o
&& alt.trim().is_empty()
{
issues.push(json!({"kind": "error", "issue": "Chart or diagram has no alternative text", "where": path.0}));
}
}
if let Some(l) = s.doc.styles.resolve_para(&p.props).outline_level {
if let Some(prev) = last_level
&& l > prev + 1
{
issues.push(json!({"kind": "warning", "issue": format!("Heading level skipped (Heading {} after Heading {})", l + 1, prev + 1), "where": path.0}));
}
last_level = Some(l);
}
if p.runs.iter().any(|r| r.props.link.is_some()) {
let txt = p.plain_text().to_lowercase();
if txt.contains("click here") {
issues.push(json!({"kind": "tip", "issue": "Link text \"click here\" isn't descriptive", "where": path.0}));
}
}
}
for (i, b) in s.doc.body.iter().enumerate() {
if let Block::Table(t) = &**b
&& !t.rows.first().is_some_and(|r| r.props.header)
{
issues.push(json!({"kind": "warning", "issue": "Table has no header row", "where": [i], "fix": "table.repeatHeader on the first row"}));
}
}
if s.doc.core.title.is_empty() {
issues.push(json!({"kind": "tip", "issue": "Document has no title", "fix": "file.properties {title}"}));
}
Ok(json!({"issues": issues, "ok": issues.is_empty()}))
let issues = super::accessibility::check(&s.doc);
Ok(json!({"ok": issues.is_empty(), "issues": issues}))
}
fn inspect_doc(s: &mut Session, v: &Value) -> CmdResult {

View File

@ -63,6 +63,14 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.darkMode", "Switch Modes", "View › Dark Mode", |s, v| toggle(s, v, |x| &mut x.dark_mode)).pure(),
CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(),
CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(),
CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| {
toggle(s, v, |x| &mut x.accessibility_pane)
})
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("view.altTextPane", "Alt Text Pane", "Review › Accessibility", |s, v| toggle(s, v, |x| &mut x.alt_text_pane))
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| {
s.ui_requests.push(json!({"open": "newWindow"}));
sel_result(s)

View File

@ -68,6 +68,12 @@ pub struct ViewState {
/// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut.
#[serde(default)]
pub clipboard_pane: bool,
/// The Accessibility pane (Review › Check Accessibility): the checker's findings.
#[serde(default)]
pub accessibility_pane: bool,
/// The Alt Text pane (Picture/Shape Format › Alt Text): the selected object's or table's alt text.
#[serde(default)]
pub alt_text_pane: bool,
pub multi_page: bool,
/// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty.
pub fit: String,
@ -108,6 +114,8 @@ impl Default for ViewState {
style_inspector: false,
comments_pane: false,
clipboard_pane: false,
accessibility_pane: false,
alt_text_pane: false,
multi_page: false,
fit: String::new(),
web_width: 800.0,

View File

@ -0,0 +1,158 @@
//! The Accessibility pane (the checker's findings, grouped, each a jump and maybe a fix) and the
//! Alt Text pane (the selected object's or table's alternative text).
use egui::Ui;
use serde_json::{Value, json};
use wordcraft_doc::Pos;
use crate::WordApp;
use crate::theme::{regular, semibold};
/// Review › Check Accessibility: the findings for the document as it is now.
pub fn accessibility(app: &mut WordApp, ui: &mut Ui) {
if crate::panes::header(ui, "Accessibility") {
let _ = app.run("view.accessibilityPane", json!({"value": false}));
return;
}
let issues = issues(app, ui);
if issues.is_empty() {
ui.label(egui::RichText::new(tl!("No accessibility issues found.")).weak());
return;
}
let mut picked: Option<(Value, Option<Value>)> = None;
egui::ScrollArea::vertical().show(ui, |ui| {
for (kind, title) in [("error", "Errors ({n})"), ("warning", "Warnings ({n})"), ("tip", "Tips ({n})")] {
let group: Vec<&Value> = issues.iter().filter(|i| i["kind"] == kind).collect();
if group.is_empty() {
continue;
}
let heading = crate::i18n::fmt(tl!(title), &[("n", &group.len().to_string())]);
egui::CollapsingHeader::new(egui::RichText::new(heading).font(semibold(13.0))).id_salt(("a11y", kind)).default_open(true).show(
ui,
|ui| {
for (k, i) in group.into_iter().enumerate() {
ui.push_id(k, |ui| {
let text = i["issue"].as_str().unwrap_or("");
let r = ui.add(egui::Button::new(egui::RichText::new(text).font(regular(12.5))).frame(false).wrap());
if let Some(fix) = i["fix"].as_str() {
ui.label(egui::RichText::new(fix).small().weak());
}
if r.clicked() {
picked = Some((i.clone(), None));
}
if let Some(a) = i.get("action").filter(|a| a.is_object())
&& let Some(label) = a["label"].as_str()
&& ui.small_button(tl!(label)).clicked()
{
picked = Some((i.clone(), Some(a.clone())));
}
ui.add_space(4.0);
});
}
},
);
}
});
if let Some((issue, action)) = picked {
go_to(app, &issue);
if let Some(a) = action
&& let Some(cmd) = a["command"].as_str()
{
let _ = app.run(cmd, a.get("params").cloned().unwrap_or_else(|| json!({})));
}
}
}
/// The checker's findings, kept until the document changes.
fn issues(app: &mut WordApp, ui: &Ui) -> Vec<Value> {
let key = egui::Id::new("a11y_issues");
let stamp = (app.session.document_id(), app.session.rev());
if let Some((s, v)) = ui.data(|d| d.get_temp::<((u64, u64), Vec<Value>)>(key))
&& s == stamp
{
return v;
}
let v = wordcraft_engine::cmd::accessibility::check(&app.session.doc);
ui.data_mut(|d| d.insert_temp(key, (stamp, v.clone())));
v
}
/// Select the object an issue is about, or put the caret where it is.
fn go_to(app: &mut WordApp, issue: &Value) {
if let Some(sel) = issue.get("select").filter(|s| s.is_object()) {
let _ = app.run("select.range", sel.clone());
} else if let Some(pos) = issue.get("pos").filter(|p| p.is_object()) {
let _ = app.run("caret.set", json!({"pos": pos}));
}
app.canvas.want_focus = true;
}
/// Alt Text: the selected picture's, shape's, chart's or group's description and decorative mark,
/// or the title and description of the table the caret is in.
pub fn alt_text(app: &mut WordApp, ui: &mut Ui) {
if crate::panes::header(ui, "Alt Text") {
let _ = app.run("view.altTextPane", json!({"value": false}));
return;
}
if let Some((pos, obj)) = wordcraft_engine::cmd::objects::selected(&app.session) {
let decorative = obj.is_decorative();
ui.label(egui::RichText::new(tl!("Describe what this object shows, for people who can't see it.")).weak());
ui.add_space(4.0);
if let Some(text) = draft_box(ui, ("alt_obj", &pos), obj.alt_text(), !decorative, true)
&& still_selected(app, &pos)
{
let _ = app.run("object.altText", json!({"text": text}));
}
ui.add_space(6.0);
let mut d = decorative;
if ui.checkbox(&mut d, tl!("Mark as decorative")).changed() {
let _ = app.run("object.altText", json!({"decorative": d}));
}
ui.label(egui::RichText::new(tl!("Decorative objects only add visual interest: screen readers skip them.")).small().weak());
return;
}
let focus = app.session.sel.focus.clone();
let table = focus
.path
.cell()
.and_then(|(tp, _, _)| app.session.doc.table(focus.story, &tp).map(|t| (tp, t.props.caption.clone(), t.props.description.clone())));
if let Some((tp, caption, description)) = table {
ui.label(egui::RichText::new(tl!("Describe what this table shows, for people who can't see it.")).weak());
ui.add_space(4.0);
ui.label(tl!("Title:"));
if let Some(t) = draft_box(ui, ("alt_table_title", &tp), caption.as_deref().unwrap_or(""), true, false) {
let _ = app.run("table.altText", json!({"title": t}));
}
ui.add_space(4.0);
ui.label(tl!("Description:"));
if let Some(t) = draft_box(ui, ("alt_table_text", &tp), description.as_deref().unwrap_or(""), true, true) {
let _ = app.run("table.altText", json!({"text": t}));
}
return;
}
ui.label(egui::RichText::new(tl!("Select a picture, shape, chart or table to describe it.")).weak());
}
fn still_selected(app: &WordApp, pos: &Pos) -> bool {
wordcraft_engine::cmd::objects::selected(&app.session).is_some_and(|(p, _)| &p == pos)
}
/// A text box editing `current`; the draft lives in egui memory while it has focus (frame-local
/// buffers lose keystrokes). Returns the new text when editing ends with a change.
fn draft_box(ui: &mut Ui, key: impl std::hash::Hash + std::fmt::Debug, current: &str, enabled: bool, multiline: bool) -> Option<String> {
let key = egui::Id::new(key);
let mut text = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| current.to_string());
let edit = if multiline { egui::TextEdit::multiline(&mut text).desired_rows(5) } else { egui::TextEdit::singleline(&mut text) };
let r = ui.add_enabled(enabled, edit.desired_width(f32::INFINITY));
if r.changed() {
ui.data_mut(|d| d.insert_temp(key, text.clone()));
}
if r.lost_focus() {
ui.data_mut(|d| d.remove::<String>(key));
return (text != current).then_some(text);
}
if !r.has_focus() {
ui.data_mut(|d| d.remove::<String>(key));
}
None
}

View File

@ -1656,6 +1656,10 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) {
item(ui, app, tl!("Edit Field…"), "fields.setCode", json!({}));
item(ui, app, tl!("Toggle Field Codes"), "fields.toggleCode", json!({}));
}
let object = wordcraft_engine::cmd::objects::selected(&app.session).is_some();
if object || app.session.sel.focus.path.cell().is_some() {
item(ui, app, "View Alt Text…", "view.altTextPane", json!({"value": true}));
}
if crate::ribbon::has_picture_selected(&app.session) {
ui.separator();
item(ui, app, "Change Picture…", "ui.changePicture", json!({}));

View File

@ -112,6 +112,11 @@ impl Exempt {
names.extend(app.session.doc.styles.styles.iter().map(|s| s.name.clone()));
// Fonts a document asks for, installed or not (Aptos is drawn with a substitute).
names.extend([wordcraft_doc::styles::BODY_FONT, wordcraft_doc::styles::HEADING_FONT].map(str::to_string));
// The accessibility checker's findings: like command messages, they answer scripts and
// MCP too, so they stay English for now.
for i in wordcraft_engine::cmd::accessibility::check(&app.session.doc) {
names.extend(["issue", "fix"].iter().filter_map(|k| i[*k].as_str()).map(str::to_string));
}
for m in crate::credits::MODELS {
names.extend([m.company, m.model, m.version].map(str::to_string));
}

View File

@ -1412,6 +1412,20 @@
New Group Nuevo grupo
Rename… Cambiar nombre…
Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido:
Accessibility Pane Panel de accesibilidad
Alt Text Pane Panel de texto alternativo
Alt\nText Texto\nalternativo
View Alt Text… Ver texto alternativo…
Mark as decorative Marcar como decorativo
Title: Título:
Errors ({n}) Errores ({n})
Warnings ({n}) Advertencias ({n})
Tips ({n}) Sugerencias ({n})
No accessibility issues found. No se encontraron problemas de accesibilidad.
Describe what this object shows, for people who can't see it. Describa lo que muestra este objeto para quienes no pueden verlo.
Decorative objects only add visual interest: screen readers skip them. Los objetos decorativos solo aportan interés visual: los lectores de pantalla los omiten.
Describe what this table shows, for people who can't see it. Describa lo que muestra esta tabla para quienes no pueden verla.
Select a picture, shape, chart or table to describe it. Seleccione una imagen, forma, gráfico o tabla para describirlo.
Document Grid Cuadrícula del documento
No grid Sin cuadrícula
Lines only Solo líneas

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1444,6 +1444,20 @@
Rotate all text 270° Pööra kogu teksti 270°
Rotate all text 90° Pööra kogu teksti 90°
Stacked Virnas
Accessibility Pane Ligipääsetavuse paan
Alt Text Pane Asendusteksti paan
Alt\nText Asendus-\ntekst
View Alt Text… Kuva asendustekst…
Mark as decorative Märgi dekoratiivseks
Title: Pealkiri:
Errors ({n}) Vead ({n})
Warnings ({n}) Hoiatused ({n})
Tips ({n}) Näpunäited ({n})
No accessibility issues found. Ligipääsetavuse probleeme ei leitud.
Describe what this object shows, for people who can't see it. Kirjeldage, mida see objekt näitab, neile, kes seda ei näe.
Decorative objects only add visual interest: screen readers skip them. Dekoratiivsed objektid lisavad ainult visuaalset huvi: ekraanilugerid jätavad need vahele.
Describe what this table shows, for people who can't see it. Kirjeldage, mida see tabel näitab, neile, kes seda ei näe.
Select a picture, shape, chart or table to describe it. Valige pilt, kujund, diagramm või tabel, et seda kirjeldada.
Document Grid Dokumendi ruudustik
No grid Ruudustikuta
Lines only Ainult read

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -1396,6 +1396,20 @@
New Group 新しいグループ
Rename… 名前の変更…
Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー:
Accessibility Pane アクセシビリティ ウィンドウ
Alt Text Pane 代替テキスト ウィンドウ
Alt\nText 代替\nテキスト
View Alt Text… 代替テキストを表示…
Mark as decorative 装飾用にする
Title: タイトル:
Errors ({n}) エラー ({n})
Warnings ({n}) 警告 ({n})
Tips ({n}) ヒント ({n})
No accessibility issues found. アクセシビリティの問題は見つかりませんでした。
Describe what this object shows, for people who can't see it. このオブジェクトが示す内容を、見ることができない人のために説明してください。
Decorative objects only add visual interest: screen readers skip them. 装飾用のオブジェクトは見た目を整えるだけなので、スクリーン リーダーは読み上げません。
Describe what this table shows, for people who can't see it. この表が示す内容を、見ることができない人のために説明してください。
Select a picture, shape, chart or table to describe it. 説明する画像、図形、グラフ、または表を選択してください。
Document Grid 文書グリッド
No grid グリッドなし
Lines only 行のみ

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -1408,6 +1408,20 @@
New Group Novo Grupo
Rename… Renomear…
Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido:
Accessibility Pane Painel de acessibilidade
Alt Text Pane Painel de texto alternativo
Alt\nText Texto\nalternativo
View Alt Text… Exibir texto alternativo…
Mark as decorative Marcar como decorativo
Title: Título:
Errors ({n}) Erros ({n})
Warnings ({n}) Avisos ({n})
Tips ({n}) Dicas ({n})
No accessibility issues found. Nenhum problema de acessibilidade encontrado.
Describe what this object shows, for people who can't see it. Descreva o que este objeto mostra, para quem não pode vê-lo.
Decorative objects only add visual interest: screen readers skip them. Objetos decorativos só acrescentam interesse visual: os leitores de tela os ignoram.
Describe what this table shows, for people who can't see it. Descreva o que esta tabela mostra, para quem não pode vê-la.
Select a picture, shape, chart or table to describe it. Selecione uma imagem, forma, gráfico ou tabela para descrevê-lo.
Document Grid Grade do documento
No grid Sem grade
Lines only Somente linhas

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1790,6 +1790,20 @@ preview Title Пример
Left Only Только слева
Right Only Только справа
Largest Only Только по большей стороне
Accessibility Pane Область специальных возможностей
Alt Text Pane Область замещающего текста
Alt\nText Замещающий\nтекст
View Alt Text… Показать замещающий текст…
Mark as decorative Пометить как декоративный
Title: Заголовок:
Errors ({n}) Ошибки ({n})
Warnings ({n}) Предупреждения ({n})
Tips ({n}) Советы ({n})
No accessibility issues found. Проблем со специальными возможностями не найдено.
Describe what this object shows, for people who can't see it. Опишите, что показывает этот объект, для людей, которые его не видят.
Decorative objects only add visual interest: screen readers skip them. Декоративные объекты лишь украшают документ: программы чтения с экрана их пропускают.
Describe what this table shows, for people who can't see it. Опишите, что показывает эта таблица, для людей, которые её не видят.
Select a picture, shape, chart or table to describe it. Выберите рисунок, фигуру, диаграмму или таблицу, чтобы описать её.
Document Grid Сетка документа
No grid Без сетки
Lines only Только строки

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1406,6 +1406,20 @@
New Group Nova grupa
Rename… Preimenuj…
Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup:
Accessibility Pane Okno pristupačnosti
Alt Text Pane Okno alternativnog teksta
Alt\nText Alternativni\ntekst
View Alt Text… Prikaži alternativni tekst…
Mark as decorative Označi kao dekorativno
Title: Naslov:
Errors ({n}) Greške ({n})
Warnings ({n}) Upozorenja ({n})
Tips ({n}) Saveti ({n})
No accessibility issues found. Nije pronađen nijedan problem pristupačnosti.
Describe what this object shows, for people who can't see it. Opišite šta ovaj objekat prikazuje, za one koji ne mogu da ga vide.
Decorative objects only add visual interest: screen readers skip them. Dekorativni objekti samo doprinose izgledu: čitači ekrana ih preskaču.
Describe what this table shows, for people who can't see it. Opišite šta ova tabela prikazuje, za one koji ne mogu da je vide.
Select a picture, shape, chart or table to describe it. Izaberite sliku, oblik, grafikon ili tabelu da biste ih opisali.
Document Grid Mreža dokumenta
No grid Bez mreže
Lines only Samo redovi

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1410,6 +1410,20 @@
New Group Нова група
Rename… Преименуј…
Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ:
Accessibility Pane Окно приступачности
Alt Text Pane Окно алтернативног текста
Alt\nText Алтернативни\nтекст
View Alt Text… Прикажи алтернативни текст…
Mark as decorative Означи као декоративно
Title: Наслов:
Errors ({n}) Грешке ({n})
Warnings ({n}) Упозорења ({n})
Tips ({n}) Савети ({n})
No accessibility issues found. Није пронађен ниједан проблем приступачности.
Describe what this object shows, for people who can't see it. Опишите шта овај објекат приказује, за оне који не могу да га виде.
Decorative objects only add visual interest: screen readers skip them. Декоративни објекти само доприносе изгледу: читачи екрана их прескачу.
Describe what this table shows, for people who can't see it. Опишите шта ова табела приказује, за оне који не могу да је виде.
Select a picture, shape, chart or table to describe it. Изаберите слику, облик, графикон или табелу да бисте их описали.
Document Grid Мрежа документа
No grid Без мреже
Lines only Само редови

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -1408,6 +1408,20 @@
Left Only Yalnızca Sol
Right Only Yalnızca Sağ
Largest Only Yalnızca En Geniş
Accessibility Pane Erişilebilirlik Bölmesi
Alt Text Pane Alternatif Metin Bölmesi
Alt\nText Alternatif\nMetin
View Alt Text… Alternatif Metni Görüntüle…
Mark as decorative Dekoratif olarak işaretle
Title: Başlık:
Errors ({n}) Hatalar ({n})
Warnings ({n}) Uyarılar ({n})
Tips ({n}) İpuçları ({n})
No accessibility issues found. Erişilebilirlik sorunu bulunamadı.
Describe what this object shows, for people who can't see it. Bu nesnenin neyi gösterdiğini, göremeyenler için açıklayın.
Decorative objects only add visual interest: screen readers skip them. Dekoratif nesneler yalnızca görsel çekicilik katar: ekran okuyucular bunları atlar.
Describe what this table shows, for people who can't see it. Bu tablonun neyi gösterdiğini, göremeyenler için açıklayın.
Select a picture, shape, chart or table to describe it. Açıklamak için bir resim, şekil, grafik veya tablo seçin.
Document Grid Belge Kılavuzu
No grid Kılavuz yok
Lines only Yalnızca satırlar

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -1408,6 +1408,20 @@
New Group Нова група
Rename… Перейменувати…
Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу:
Accessibility Pane Панель доступності
Alt Text Pane Панель альтернативного тексту
Alt\nText Альтернативний\nтекст
View Alt Text… Переглянути альтернативний текст…
Mark as decorative Позначити як декоративний
Title: Заголовок:
Errors ({n}) Помилки ({n})
Warnings ({n}) Попередження ({n})
Tips ({n}) Поради ({n})
No accessibility issues found. Проблем із доступністю не знайдено.
Describe what this object shows, for people who can't see it. Опишіть, що показує цей об’єкт, для тих, хто не може його бачити.
Decorative objects only add visual interest: screen readers skip them. Декоративні об’єкти лише прикрашають документ: засоби читання з екрана їх пропускають.
Describe what this table shows, for people who can't see it. Опишіть, що показує ця таблиця, для тих, хто не може її бачити.
Select a picture, shape, chart or table to describe it. Виберіть зображення, фігуру, діаграму або таблицю, щоб описати її.
Document Grid Сітка документа
No grid Без сітки
Lines only Лише рядки

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -1408,6 +1408,20 @@
New Group 新建组
Rename… 重命名…
Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏:
Accessibility Pane 辅助功能窗格
Alt Text Pane 替代文字窗格
Alt\nText 替代\n文字
View Alt Text… 查看替代文字…
Mark as decorative 标记为装饰性
Title: 标题:
Errors ({n}) 错误 ({n})
Warnings ({n}) 警告 ({n})
Tips ({n}) 提示 ({n})
No accessibility issues found. 未发现辅助功能问题。
Describe what this object shows, for people who can't see it. 请为看不见的人描述此对象显示的内容。
Decorative objects only add visual interest: screen readers skip them. 装饰性对象仅增添视觉效果:屏幕阅读器会跳过它们。
Describe what this table shows, for people who can't see it. 请为看不见的人描述此表格显示的内容。
Select a picture, shape, chart or table to describe it. 选择图片、形状、图表或表格以添加描述。
Document Grid 文档网格
No grid 无网格
Lines only 仅行

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1397,6 +1397,20 @@
New Group 新增群組
Rename… 重新命名…
Ribbon and Quick Access Toolbar: 功能區和快速存取工具列:
Accessibility Pane 協助工具窗格
Alt Text Pane 替代文字窗格
Alt\nText 替代\n文字
View Alt Text… 檢視替代文字…
Mark as decorative 標示為裝飾用
Title: 標題:
Errors ({n}) 錯誤 ({n})
Warnings ({n}) 警告 ({n})
Tips ({n}) 提示 ({n})
No accessibility issues found. 找不到協助工具問題。
Describe what this object shows, for people who can't see it. 請為看不見的人描述此物件顯示的內容。
Decorative objects only add visual interest: screen readers skip them. 裝飾用物件只增添視覺效果:螢幕助讀程式會略過它們。
Describe what this table shows, for people who can't see it. 請為看不見的人描述此表格顯示的內容。
Select a picture, shape, chart or table to describe it. 選取圖片、圖形、圖表或表格以加入描述。
Document Grid 文件格線
No grid 無格線
Lines only 僅行

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -782,6 +782,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
pen.line(&[(10.0, 7.0), (10.0, 12.0), (7.0, 17.5)]);
pen.line(&[(10.0, 12.0), (13.0, 17.5)]);
}
"altText" => {
// A small picture with lines of text beside and below it.
pen.rect(2.5, 3.5, 11.5, 11.0, c);
pen.line_c(&[(3.5, 10.0), (6.0, 6.5), (8.0, 8.5), (9.0, 7.5), (10.5, 10.0)], a);
for (x0, x1, y) in [(13.5, 17.5, 5.0), (13.5, 17.5, 8.0), (2.5, 17.5, 14.0), (2.5, 13.0, 17.0)] {
pen.line_c(&[(x0, y), (x1, y)], c);
}
}
"translate" | "language" => {
pen.text(6.5, 7.0, 9.0, "A", c, true);
pen.text(13.5, 13.5, 9.0, "あ", a, false);

View File

@ -13,6 +13,7 @@ macro_rules! tl {
};
}
pub mod a11y_pane;
pub mod backstage;
pub mod canvas;
pub mod chrome;
@ -372,6 +373,10 @@ impl WordApp {
return r;
}
let r = self.execute_user(id, params);
// Check Accessibility shows its findings in the Accessibility pane, kept up to date there.
if id == "file.accessibility" && r.is_ok() {
self.session.view.accessibility_pane = true;
}
// Match Fields and Check for Errors show what they found.
if let Ok(v) = &r
&& let Some(d) = dialogs::Dialog::report(id, v)

View File

@ -1,4 +1,5 @@
//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, Comments.
//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector,
//! Accessibility and Alt Text (see `a11y_pane`), Comments.
use egui::{Stroke, Ui, vec2};
use serde_json::{Value, json};
@ -37,6 +38,20 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| inspector(app, ui));
}
if app.session.view.accessibility_pane {
egui::Panel::right("accessibility_pane")
.default_size(280.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| crate::a11y_pane::accessibility(app, ui));
}
if app.session.view.alt_text_pane {
egui::Panel::right("alt_text_pane")
.default_size(260.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| crate::a11y_pane::alt_text(app, ui));
}
if app.session.view.comments_pane {
egui::Panel::right("comments_pane")
.default_size(290.0)
@ -46,7 +61,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
fn header(ui: &mut Ui, title: &str) -> bool {
pub(crate) fn header(ui: &mut Ui, title: &str) -> bool {
let mut close = false;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!(title)).font(semibold(15.0)));

View File

@ -1436,6 +1436,7 @@ fn shape_format(app: &mut WordApp, ui: &mut Ui) {
});
});
});
alt_text_group(ui, app);
if wordcraft_engine::cmd::canvas::selected_canvas(&app.session).is_some() {
group(ui, "Drawing Canvas", None, app, |ui, app| {
menu_button(ui, app, "canvas", Some("Drawing\nCanvas"), "Drawing Canvas", true, |ui, app| {
@ -1514,6 +1515,13 @@ fn shape_text_group(app: &mut WordApp, ui: &mut Ui) {
});
}
/// Picture/Shape Format › Accessibility: Alt Text opens the Alt Text pane.
fn alt_text_group(ui: &mut Ui, app: &mut WordApp) {
group(ui, "Accessibility", None, app, |ui, app| {
big(ui, app, "altText", "Alt\nText", "view.altTextPane", json!({"value": true}), false);
});
}
/// Shape Format › Shape Effects: Shadow, Glow and Soft Edges, each with presets and a "No …" entry.
fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rgb], glow_size: Option<f32>) {
ui.menu_button(tl!("Shadow"), |ui| {
@ -1602,6 +1610,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) {
}
});
});
alt_text_group(ui, app);
group(ui, "Arrange", None, app, |ui, app| {
big(ui, app, "position", "Position", "arrange.position", json!({}), false);
wrap_menu(ui, app);

View File

@ -61,6 +61,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight |
| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases |
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures and text boxes, best effort (#242) |
| Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) |
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292); not editable. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319) |
| OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited |
| Word ink (`w14:contentPart`, InkML), 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
@ -86,6 +87,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Alt text, titles and the decorative mark for every drawing, table captions and descriptions round-trip (#386) |
| 2026-10-11 | trivial | Document grid (`w:docGrid`) and paragraph `w:snapToGrid` read and written (#391) |
| 2026-10-11 | trivial | Move tracking (`w:moveFrom`/`w:moveTo` and their ranges) read and written (#400) |
| 2026-10-11 | trivial | DOCX text-box `bodyPr` `vert`/`anchor` and linked text boxes (`wps:linkedTxbx`) read and written (#369) |

View File

@ -2,7 +2,7 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**433 of 451 catalog features have commands (96.0%).**
**436 of 454 catalog features have commands (96.0%).**
| Tab | Live | Total |
|---|---|---|
@ -15,13 +15,13 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
| Layout | 21 | 21 |
| References | 24 | 24 |
| Mailings | 17 | 17 |
| Review | 28 | 30 |
| Review | 29 | 31 |
| View | 25 | 29 |
| Table Design | 8 | 8 |
| Table Layout | 31 | 31 |
| Table Layout | 32 | 32 |
| Equation | 21 | 22 |
| Picture Format | 14 | 14 |
| Shape Format | 6 | 6 |
| Shape Format | 7 | 7 |
| Header & Footer | 5 | 5 |
| Editing | 38 | 38 |
| Tools | 5 | 6 |

View File

@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **433 / 451 (96.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Ribbon/menu catalog coverage | **436 / 454 (96.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated |
| Feature depth (weighted by use, table below) | **~68%** | estimated |
| **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) |
@ -75,7 +75,7 @@ full parity for the area.
| Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. |
| Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. |
| East Asian typography | 1 | 25% | not rated | 13–22 | IME input works (winit fixes pending, #155–#164). Ruby/Phonetic Guide with DOCX round trip (#288); document grid (lines per page, characters per line, #391). No vertical text, `w:eastAsianLayout`, enclose characters, Asian line-break options. |
| Accessibility | 1 | 35% | not rated | 15–25 | AccessKit enabled in eframe; an accessibility checker exists. Screen-reader access to the document canvas is untested; no alt-text workflow polish, no read-order tools. |
| Accessibility | 1 | 50% | not rated | 10–18 | AccessKit enabled in eframe. Alt Text pane for pictures, shapes, text boxes, groups, charts and tables with Mark as decorative, all round-tripping in DOCX (#386). The Accessibility pane groups errors, warnings and tips with jump-to and one-click fixes; rules for alt text, heading order, header rows, blank table rows/columns, merged cells, low contrast (WCAG ratio), floating objects and blank paragraphs. Screen-reader access to the document canvas is still untested (#18); no reading-order tools. |
| Agent control (CLI, MCP, control channel, macro record/playback) | 0 | beyond Word | 100%+ | — | Every action is a command id usable from CLI, MCP and a keyed control port. Word has VBA and Office.js instead (see Ecosystem). |
Weighted feature depth: **~68%** (Σ weight × parity / 100 over the table).
@ -249,6 +249,7 @@ can be used without committing Word output.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Accessibility 35% → 50%: Alt Text pane for every object and tables, decorative flag, deeper checker with an Accessibility pane (#386); weight 1, headline unchanged |
| 2026-10-11 | trivial | References: Caption, Index, Mark Entry, Custom Table of Contents and Create/Edit Source dialogs, with the field switches they write honoured on update (#402); no percentage change |
| 2026-10-11 | trivial | Move tracking (#400): Review row updated; no percentage change |
| 2026-10-11 | trivial | Footnotes and endnotes: Footnote and Endnote dialog, options per section (position, format, start, restart each section/page), convert notes (#385); no percentage change |

View File

@ -17,7 +17,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 |
| Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 |
| Modal dialogs | **44** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402)) plus panes and menus for the rest | ~100 (Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~45% | 15–25 |
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility, Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); objects and tables partial | rich per context | 55% | 3–5 |
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268) | | 68% | 4–6 |
| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column drag pending (#49) | ✅ | 70% | 2–3 |
@ -37,6 +37,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) |
| 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) |
| 2026-10-11 | trivial | Customize Ribbon (custom tabs and groups, hidden and reordered tabs) and a customizable Quick Access Toolbar with a working … menu (#379, #376) |
| 2026-10-11 | trivial | Customize Keyboard dialog (Tools › Customize Keyboard, File › Options › Keyboard shortcuts): custom keys override built-ins and persist (#368) |