- 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:
parent
56f9456f03
commit
d5bcb9b490
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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, .. }
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 = ¶s(&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")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}"#)),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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!({}));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue