M12: Add alternate text: figures one by one with a preview, alternate text or Decorative (content becomes an artifact); accessibility_figures / accessibility_set_alt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 05:36:39 -04:00
parent 095a17610f
commit e9322b597b
17 changed files with 697 additions and 6 deletions

321
crates/a11y/src/alt.rs Normal file
View File

@ -0,0 +1,321 @@
//! Add alternate text: the document's figures one by one, their alternate text, and marking a
//! figure as decorative (its content becomes an artifact and its tag goes away).
use std::collections::{HashMap, HashSet};
use printcraft_content::Matrix;
use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
/// A Figure element (after the role map).
#[derive(Clone, Debug, PartialEq)]
pub struct Figure {
pub obj: ObjRef,
/// 0-based page of its content.
pub page: Option<usize>,
pub alt: Option<String>,
/// Where its content is drawn, in user space (images, forms and paths it marks).
pub bbox: Option<[f64; 4]>,
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum AltError {
#[error("the document has no tags")]
Untagged,
#[error("object {0} is not a figure")]
NotAFigure(u32),
#[error("{0}")]
Cos(#[from] printcraft_cos::CosError),
}
fn tree_root(doc: &Document) -> Option<Dict> {
let cat = doc.root().and_then(|r| doc.get(r).as_dict().cloned())?;
cat.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned())
}
/// The standard type of a structure type through the role map.
fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec<u8> {
let mut t = s.to_vec();
for _ in 0..12 {
if crate::structure::is_standard(&t) {
break;
}
match role_map.get(&t).map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(<[u8]>::to_vec)) {
Some(n) if n != t => t = n,
_ => break,
}
}
t
}
/// Indirect structure elements in document order, with their page.
fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option<ObjRef>)> {
let Some(root) = tree_root(doc) else { return Vec::new() };
let mut out = Vec::new();
let mut seen = HashSet::new();
let mut stack: Vec<(Object, Option<ObjRef>)> = root.get(b"K").map(|k| vec![(k.clone(), None)]).unwrap_or_default();
while let Some((k, pg)) = stack.pop() {
if out.len() > 1_000_000 {
break;
}
match k {
Object::Array(a) => stack.extend(a.into_iter().rev().map(|x| (x, pg))),
Object::Ref(r) => {
if !seen.insert(r) {
continue;
}
let Some(d) = doc.get(r).as_dict().cloned() else { continue };
if d.contains(b"S") {
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
if let Some(kids) = d.get(b"K") {
stack.push((kids.clone(), pg));
}
out.push((r, d, pg));
}
}
Object::Dict(d) if d.contains(b"S") => {
// Direct elements can't be addressed; their indirect children still can.
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
if let Some(kids) = d.get(b"K") {
stack.push((kids.clone(), pg));
}
}
_ => {}
}
}
out
}
/// The marked-content ids an element owns directly.
fn mcids(d: &Dict) -> Vec<i64> {
let mut out = Vec::new();
let mut push = |o: &Object| match o {
Object::Int(n) => out.push(*n),
Object::Dict(m) if m.name(b"Type") == Some(b"MCR") => {
if let Some(n) = m.get(b"MCID").and_then(Object::as_int) {
out.push(n)
}
}
_ => {}
};
match d.get(b"K") {
Some(Object::Array(a)) => a.iter().for_each(&mut push),
Some(o) => push(o),
None => {}
}
out
}
fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
let Some(c) = page.get(b"Contents") else { return Vec::new() };
match &*doc.resolve(c) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
Object::Array(a) => a
.iter()
.flat_map(|x| match &*doc.resolve(x) {
Object::Stream(s) => {
let mut v = s.decoded().unwrap_or_default();
v.push(b'\n');
v
}
_ => Vec::new(),
})
.collect(),
_ => Vec::new(),
}
}
fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] {
match a {
Some(a) => [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])],
None => b,
}
}
/// Where the marked content `ids` is drawn on a page (images, forms and paths).
fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64; 4]> {
let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])];
let mut marks: Vec<bool> = Vec::new();
let mut bbox = None;
let mut path: Vec<(f64, f64)> = Vec::new();
for op in printcraft_content::parse(&page_content(doc, page)).ops {
let inside = marks.iter().any(|m| *m);
let top = *ctm.last().expect("never empty");
match op.op.as_slice() {
b"q" => ctm.push(top),
b"Q" => {
if ctm.len() > 1 {
ctm.pop();
}
}
b"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands) {
*ctm.last_mut().expect("never empty") = m.then(&top);
}
}
b"BMC" => marks.push(false),
b"BDC" => marks.push(
matches!(op.operands.get(1), Some(Object::Dict(d)) if d.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n))),
),
b"EMC" => {
marks.pop();
}
b"m" | b"l" => {
if let Some([x, y]) = op.nums::<2>() {
path.push(top.apply(x, y));
}
}
b"c" => {
if let Some([_, _, _, _, x, y]) = op.nums::<6>() {
path.push(top.apply(x, y));
}
}
b"re" => {
if let Some([x, y, w, h]) = op.nums::<4>() {
let b = top.bbox([x, y, x + w, y + h]);
path.push((b[0], b[1]));
path.push((b[2], b[3]));
}
}
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"n" => {
if inside && !matches!(op.op.as_slice(), b"n") {
for (x, y) in &path {
bbox = Some(union(bbox, [*x, *y, *x, *y]));
}
}
path.clear();
}
b"Do" if inside => {
let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) else { continue };
let unit = match &*doc.get(r) {
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form") => {
let bb: Vec<f64> =
s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default();
let m = Matrix::from_operands(s.dict.get(b"Matrix").and_then(|m| m.as_array()).map(Vec::as_slice).unwrap_or(&[]))
.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
match bb[..] {
[a, b, c, d] => m.then(&top).bbox([a, b, c, d]),
_ => continue,
}
}
_ => top.bbox([0.0, 0.0, 1.0, 1.0]),
};
bbox = Some(union(bbox, unit));
}
b"BI" if inside => bbox = Some(union(bbox, top.bbox([0.0, 0.0, 1.0, 1.0]))),
_ => {}
}
}
bbox
}
/// The figures, in document order.
pub fn figures(doc: &Document) -> Vec<Figure> {
let Some(root) = tree_root(doc) else { return Vec::new() };
let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let pages = printcraft_model::pages(doc);
let index: HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
elements(doc)
.into_iter()
.filter(|(_, d, _)| d.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|(obj, d, pg)| {
let page = pg.and_then(|p| index.get(&p).copied());
let alt = d.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids));
Figure { obj, page, alt, bbox }
})
.collect()
}
fn check_figure(doc: &Document, obj: ObjRef) -> Result<Dict, AltError> {
let root = tree_root(doc).ok_or(AltError::Untagged)?;
let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let d = doc.get(obj).as_dict().cloned().ok_or(AltError::NotAFigure(obj.num))?;
match d.name(b"S") {
Some(s) if standard(doc, &role_map, s) == b"Figure" => Ok(d),
_ => Err(AltError::NotAFigure(obj.num)),
}
}
/// Set (or, with `None` or blank text, remove) a figure's alternate text.
pub fn set_alt(doc: &mut Document, obj: ObjRef, alt: Option<&str>) -> Result<(), AltError> {
check_figure(doc, obj)?;
let alt = alt.map(str::trim).filter(|a| !a.is_empty());
doc.update_dict(obj, |d| match alt {
Some(a) => d.set(b"Alt".to_vec(), Object::String(PdfString::text(a))),
None => {
d.remove(b"Alt");
}
})?;
Ok(())
}
/// Decorative figure: its marked content on the page becomes an artifact, and the element
/// leaves the structure tree (and the parent tree).
pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> {
let d = check_figure(doc, obj)?;
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
let pages = printcraft_model::pages(doc);
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|(r, _, _)| *r == obj).and_then(|(_, _, p)| p));
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
let mut ops = printcraft_content::parse(&page_content(doc, &page.dict)).ops;
let mut changed = false;
for op in ops.iter_mut() {
let ours = op.op == b"BDC"
&& matches!(op.operands.get(1), Some(Object::Dict(m)) if m.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n)));
if ours {
*op = printcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
changed = true;
}
}
if changed {
let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &printcraft_content::serialize_ops(&ops))));
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(s)))?;
// The parent tree no longer points those ids at the figure.
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
clear_parent_tree(doc, key, obj)?;
}
}
}
// Out of its parent's kids.
if let Some(parent) = d.get(b"P").and_then(Object::as_ref) {
let kids = doc.get(parent).as_dict().and_then(|p| p.get(b"K").cloned());
let keep = |o: &Object| o.as_ref() != Some(obj);
let new = match kids {
Some(Object::Array(a)) => Some(Object::Array(a.into_iter().filter(keep).collect())),
Some(Object::Ref(r)) if r == obj => Some(Object::Array(Vec::new())),
_ => None,
};
if let Some(k) = new {
doc.update_dict(parent, |p| p.set(b"K".to_vec(), k))?;
}
}
Ok(())
}
/// Replace `obj` by null in the page's parent-tree array (`key`), in a flat /Nums tree.
fn clear_parent_tree(doc: &mut Document, key: i64, obj: ObjRef) -> Result<(), AltError> {
let Some(pt) = tree_root(doc).and_then(|r| r.get(b"ParentTree").and_then(Object::as_ref)) else { return Ok(()) };
let Some(nums) = doc.get(pt).as_dict().and_then(|d| d.get(b"Nums").and_then(|n| n.as_array().cloned())) else { return Ok(()) };
let mut nums = nums;
for pair in nums.chunks_mut(2) {
if pair[0].as_int() == Some(key) {
match &pair[1] {
Object::Array(a) => {
pair[1] = Object::Array(a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect())
}
Object::Ref(r) => {
if let Some(a) = doc.get(*r).as_array().cloned() {
let a: Vec<Object> = a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect();
doc.set(*r, Object::Array(a));
}
}
_ => {}
}
}
}
doc.update_dict(pt, |d| d.set(b"Nums".to_vec(), Object::Array(nums)))?;
Ok(())
}

View File

@ -10,10 +10,12 @@ use std::collections::BTreeSet;
use printcraft_cos::Document;
mod alt;
mod content;
mod report;
mod structure;
pub use alt::{AltError, Figure, figures, mark_decorative, set_alt};
pub use report::report_html;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]

View File

@ -69,6 +69,10 @@ const STANDARD: &[&[u8]] = &[
b"Artifact",
];
pub(crate) fn is_standard(t: &[u8]) -> bool {
STANDARD.contains(&t)
}
/// Elements beyond this are not read (a guard against absurd trees).
const MAX_ELEMENTS: usize = 2_000_000;

View File

@ -222,3 +222,60 @@ fn rules_have_ids_and_the_report_lists_them() {
assert!(html.contains("a&lt;b&gt;.pdf") && html.contains("Accessibility permission flag") && html.contains("Needs manual check: 5"));
assert!(html.contains("<h3>Headings</h3>"));
}
fn figures_doc() -> Document {
pdf(
&[
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 /Resources << /XObject << /Im 10 0 R >> >> >>"
.into(),
stream(
"/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC \
/Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC",
),
"<< /Type /StructTreeRoot /K 6 0 R /ParentTree 9 0 R /RoleMap << /Photo /Figure >> >>".into(),
"<< /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".into(),
"<< /S /Figure /P 6 0 R /Pg 3 0 R /K 0 >>".into(),
"<< /S /Photo /P 6 0 R /Pg 3 0 R /Alt (A bar) /K 1 >>".into(),
"<< /Nums [0 [7 0 R 8 0 R]] >>".into(),
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream"
.into(),
],
"",
)
}
#[test]
fn figures_are_listed_with_their_alt_text_and_place() {
let doc = figures_doc();
let f = figures(&doc);
assert_eq!(f.len(), 2, "role-mapped Photo counts");
assert_eq!((f[0].page, f[0].alt.as_deref(), f[0].bbox), (Some(0), None, Some([10.0, 150.0, 50.0, 170.0])));
assert_eq!((f[1].alt.as_deref(), f[1].bbox), (Some("A bar"), Some([100.0, 100.0, 150.0, 130.0])));
}
#[test]
fn setting_alt_text_and_marking_decorative() {
let mut doc = figures_doc();
let f = figures(&doc);
set_alt(&mut doc, f[0].obj, Some(" A grey square ")).unwrap();
assert_eq!(figures(&doc)[0].alt.as_deref(), Some("A grey square"));
let r = check(&doc, &Options { rules: [Rule::FiguresAltText].into(), pages: None });
assert_eq!(r.result(Rule::FiguresAltText).unwrap().status, Status::Passed);
set_alt(&mut doc, f[0].obj, None).unwrap();
assert_eq!(figures(&doc)[0].alt, None);
assert_eq!(set_alt(&mut doc, printcraft_cos::ObjRef::new(6, 0), Some("x")), Err(AltError::NotAFigure(6)));
// Decorative: the figure leaves the tags and its content becomes an artifact.
mark_decorative(&mut doc, f[0].obj).unwrap();
let left = figures(&doc);
assert_eq!(left.len(), 1);
assert_eq!(left[0].alt.as_deref(), Some("A bar"));
let r = check(&doc, &Options { rules: [Rule::FiguresAltText, Rule::TaggedContent].into(), pages: None });
assert_eq!(failed(&r), Vec::<Rule>::new(), "{:?}", r.results);
let pages = printcraft_model::pages(&doc);
let c = doc.resolve(pages[0].dict.get(b"Contents").unwrap());
let printcraft_cos::Object::Stream(c) = &*c else { panic!("one content stream") };
let text = String::from_utf8(c.decoded().unwrap()).unwrap();
assert!(text.contains("/Artifact BMC") && !text.contains("/MCID 0"), "{text}");
}

View File

@ -101,4 +101,36 @@ impl Automation {
self.doc(a)?.accessibility_check(&Options { rules: [rule].into(), pages: None }).ok_or_else(|| failed("the document can't be read"))?;
Ok(json!({ "rule": id, "status": status_id(r.results.iter().find(|x| x.rule == rule).map_or(Status::Skipped, |x| x.status)) }))
}
pub(crate) fn accessibility_figures(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let list: Vec<Value> = doc
.figures()
.iter()
.map(|f| {
// Top-left-origin points on the displayed page.
let rect = f.page.zip(f.bbox).and_then(|(p, b)| {
let info = doc.info.pages.get(p)?;
let (u, v) = (info.user_to_view(b[0] as f32, b[1] as f32), info.user_to_view(b[2] as f32, b[3] as f32));
let r = |x: f32| (x as f64 * 100.0).round() / 100.0;
Some([r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))])
});
json!({ "figure": f.obj.num, "page": f.page.map(|p| p + 1), "alt": f.alt, "rect": rect })
})
.collect();
Ok(json!({ "count": list.len(), "figures": list }))
}
pub(crate) fn accessibility_set_alt(&mut self, a: &Args) -> Result<Value> {
let figure = u32::try_from(a.int("figure")?)
.map_err(|_| ToolError::InvalidArgs("figure must be a figure number from accessibility_figures".into()))?;
let edit = if a.opt_bool("decorative")?.unwrap_or(false) {
printcraft_engine::Edit::MarkDecorative { figure }
} else {
printcraft_engine::Edit::SetAltText { figure, alt: a.opt_str("alt")?.map(str::to_owned) }
};
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.accessibility_figures(a)
}
}

View File

@ -330,6 +330,8 @@ impl Automation {
"accessibility_check" => self.a11y_check(&a)?,
"accessibility_report" => self.a11y_report(&a)?,
"accessibility_fix" => self.a11y_fix(&a)?,
"accessibility_figures" => self.accessibility_figures(&a)?,
"accessibility_set_alt" => self.accessibility_set_alt(&a)?,
"sign_id_create" => self.sign_id_create(&a)?,
"sign_document" => self.sign_document(&a)?,
"sign_trust" => self.sign_trust(&a)?,

View File

@ -726,6 +726,13 @@ pub fn tools() -> Vec<ToolDef> {
)),
t("accessibility_fix", "Fix an accessibility problem", "Apply the checker's automatic fix for a rule: primary-language (value: the language, e.g. en-US), title (value: the title; default the current title or file name; also shows it in the title bar) or tab-order (every page tabs in structure order). Returns the rule's new status. Undoable.")
.with(schema(json!({ "doc": doc(), "rule": { "type": "string", "enum": ["primary-language", "title", "tab-order"] }, "value": { "type": "string" } }), &["doc", "rule"])),
t("accessibility_figures", "List figures", "The tagged figures (Figure elements, through the role map) in document order: figure number, page, alternate text and where the figure is drawn (top-left-origin points).")
.ro()
.cmd("a11y.alt_text")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.")
.cmd("a11y.alt_text")
.with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])),
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.")
.cmd("export.all_images")
.with(schema(

View File

@ -600,6 +600,39 @@ fn accessibility_check_report_and_fixes_through_tools() {
assert_eq!(r["failed"].as_u64(), Some(3), "tagging, tagged content and (undone) title");
let html = std::fs::read_to_string(dir.join("report.html")).unwrap();
assert!(html.contains("Accessibility Report") && html.contains("a.pdf"));
// Figures: list, describe, mark decorative.
std::fs::write(
dir.join("figures.pdf"),
b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 >> endobj
4 0 obj << /Length 74 >> stream
/Figure << /MCID 0 >> BDC 10 150 40 20 re f EMC /Figure << /MCID 1 >> BDC 100 100 50 30 re f EMC
endstream endobj
5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 9 0 R >> endobj
6 0 obj << /S /Document /P 5 0 R /K [7 0 R 8 0 R] >> endobj
7 0 obj << /S /Figure /P 6 0 R /Pg 3 0 R /K 0 >> endobj
8 0 obj << /S /Figure /P 6 0 R /Pg 3 0 R /K 1 >> endobj
9 0 obj << /Nums [0 [7 0 R 8 0 R]] >> endobj
trailer << /Root 1 0 R >>
%%EOF"
.as_slice(),
)
.unwrap();
let fd = ok(&mut a, "doc_open", json!({ "path": "figures.pdf" }))["doc"].as_u64().unwrap();
let figs = ok(&mut a, "accessibility_figures", json!({ "doc": fd }));
assert_eq!(figs["count"], 2);
assert_eq!(figs["figures"][0]["rect"], json!([10.0, 30.0, 50.0, 50.0]), "top-left-origin points");
let first = figs["figures"][0]["figure"].as_u64().unwrap();
let second = figs["figures"][1]["figure"].as_u64().unwrap();
let r = ok(&mut a, "accessibility_set_alt", json!({ "doc": fd, "figure": first, "alt": "A small bar" }));
assert_eq!(r["figures"][0]["alt"], "A small bar");
let r = ok(&mut a, "accessibility_set_alt", json!({ "doc": fd, "figure": second, "decorative": true }));
assert_eq!(r["count"], 1);
let check = ok(&mut a, "accessibility_check", json!({ "doc": fd, "rules": ["figures-alt-text", "tagged-content"] }));
assert_eq!(check["failed"], 0, "{check}");
assert!(matches!(a.call("accessibility_set_alt", &json!({ "doc": fd, "figure": 6, "alt": "x" })), Err(ToolError::Failed(_))));
}
#[test]

View File

@ -445,7 +445,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
item("Change reading options", "book-open", "a11y.reading_options", Ready),
item("Check for accessibility", "accessibility", "a11y.check", Ready),
item("Open accessibility report", "file-text", "a11y.report", Ready),
item("Add alternate text", "image", "a11y.alt_text", Planned("M12")),
item("Add alternate text", "image", "a11y.alt_text", Ready),
item("Fix reading order", "list-ordered", "a11y.reading_order", Planned("M12")),
],
}],

View File

@ -248,6 +248,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
c("a11y.report", "Open accessibility report", None, None, Document, "file-text"),
c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"),
c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"),
c("export.all_images", "Export all images…", FILE, None, Document, "image"),
c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"),
c("edit.header_footer.update", "Update header & footer…", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),

View File

@ -162,6 +162,11 @@ impl Document {
self.editor.as_ref().map(|e| a11y::check(&e.cos, options))
}
/// Add alternate text: the figures, in document order.
pub fn figures(&self) -> Vec<a11y::Figure> {
self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default()
}
/// The edit that fixes `rule`, for the rules with an automatic fix: the document language
/// (`value` is the language), the title (`value` replaces it; else the current title or the
/// file name) and the tab order.
@ -694,6 +699,16 @@ pub enum Edit {
},
/// Document Properties ▸ Initial View (and the language and binding).
SetInitialView(Box<InitialView>),
/// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is
/// the figure element's object number (from `Document::figures`).
SetAltText {
figure: u32,
alt: Option<String>,
},
/// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact.
MarkDecorative {
figure: u32,
},
/// Order tabs manually: move a field one place earlier or later on its page.
MoveInTabOrder {
name: String,
@ -858,6 +873,8 @@ impl Edit {
Edit::DuplicateField { .. } => "Duplicate field".into(),
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => "Set tab order".into(),
Edit::SetInitialView(_) => "Change initial view".into(),
Edit::SetAltText { .. } => "Set alternate text".into(),
Edit::MarkDecorative { .. } => "Mark figure as decorative".into(),
Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(),
Edit::AddHeaderFooter { .. } => "Update header & footer".into(),
Edit::AddWatermark { replace: false, .. } => "Add watermark".into(),
@ -1015,6 +1032,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(),
| Edit::SetTabOrder { .. }
| Edit::MoveInTabOrder { .. }
| Edit::SetInitialView(_)
| Edit::SetAltText { .. }
| Edit::MarkDecorative { .. }
| Edit::Flatten { .. } => {
if p.modify() {
Ok(())
@ -1191,6 +1210,14 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -
Edit::SetTabOrder { pages, order } => printcraft_forms::set_tab_order(doc, pages, *order)?,
Edit::MoveInTabOrder { name, earlier } => printcraft_forms::move_in_tab_order(doc, name, *earlier)?,
Edit::SetInitialView(v) => printcraft_organize::set_initial_view(doc, v)?,
Edit::SetAltText { figure, alt } => {
let r = printcraft_cos::ObjRef::new(*figure, doc.generation(*figure));
a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::MarkDecorative { figure } => {
let r = printcraft_cos::ObjRef::new(*figure, doc.generation(*figure));
a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::AddHeaderFooter { pages, settings, replace } => {
let date = cx.today;
printcraft_edit::add_header_footer(doc, pages, settings, *replace, &printcraft_edit::Context { date })?;
@ -1397,6 +1424,8 @@ pub enum EditError {
Redact(#[from] printcraft_redact::RedactError),
#[error("{0}")]
Print(String),
#[error("{0}")]
Accessibility(String),
#[error(transparent)]
Data(#[from] printcraft_xfdf::DataError),
#[error("{0}")]

View File

@ -380,3 +380,143 @@ impl PrintCraftApp {
}
}
}
/// Add alternate text: the figures one by one.
#[derive(Clone, Default)]
pub struct AltDraft {
pub doc: Option<DocId>,
pub figures: Vec<printcraft_engine::a11y::Figure>,
pub index: usize,
pub texts: Vec<String>,
pub decorative: Vec<bool>,
/// The figure shown (index, picture).
preview: Option<(usize, egui::TextureHandle)>,
}
impl std::fmt::Debug for AltDraft {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AltDraft").field("figures", &self.figures.len()).field("index", &self.index).finish()
}
}
/// Render a figure's area for the dialog (at most 360 × 220 px).
fn figure_picture(ctx: &egui::Context, doc: &printcraft_engine::Document, f: &printcraft_engine::a11y::Figure) -> Option<egui::TextureHandle> {
let (page, b) = (f.page?, f.bbox?);
let info = doc.info.pages.get(page)?;
let (u, v) = (info.user_to_view(b[0] as f32, b[1] as f32), info.user_to_view(b[2] as f32, b[3] as f32));
let (x0, y0, x1, y1) = (u[0].min(v[0]), u[1].min(v[1]), u[0].max(v[0]), u[1].max(v[1]));
let (w, h) = ((x1 - x0).max(1.0), (y1 - y0).max(1.0));
let scale = (360.0 / w).min(220.0 / h).clamp(0.05, 8.0);
let tile = printcraft_render::Tile {
x: (x0 * scale).floor().max(0.0) as u32,
y: (y0 * scale).floor().max(0.0) as u32,
w: (w * scale).ceil().max(1.0) as u32,
h: (h * scale).ceil().max(1.0) as u32,
};
let config = printcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() };
let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), config);
let out = r.render(printcraft_render::RenderRequest { page, kind: printcraft_render::RequestKind::Pixels, tile: Some(tile), scale, tag: 0 });
if out.error.is_some() || out.width == 0 {
return None;
}
let img = egui::ColorImage::from_rgba_premultiplied([out.width as usize, out.height as usize], &out.rgba);
Some(ctx.load_texture("alt-figure", img, egui::TextureOptions::LINEAR))
}
pub(crate) fn alt_body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> (bool, bool) {
let ctx = ui.ctx().clone();
let doc = app.alt_draft.doc.and_then(|id| app.session.get(id));
let d = &mut app.alt_draft;
ui.label(egui::RichText::new("Set Alternate Text").font(theme::semibold(18.0)));
ui.add_space(8.0);
let n = d.figures.len();
if n == 0 {
ui.label(egui::RichText::new("This document has no tagged figures.").color(t.text_muted));
let mut cancel = false;
ui.with_layout(Layout::right_to_left(Align::Center), |ui| cancel = widgets::pill_button(ui, "Close", true).clicked());
return (false, cancel);
}
d.index = d.index.min(n - 1);
let i = d.index;
if d.preview.as_ref().is_none_or(|(k, _)| *k != i)
&& let Some(doc) = doc
{
d.preview = figure_picture(&ctx, doc, &d.figures[i]).map(|tex| (i, tex));
}
let page = d.figures[i].page.map(|p| format!(" on page {}", p + 1)).unwrap_or_default();
ui.label(egui::RichText::new(format!("Figure {} of {n}{page}", i + 1)).color(t.text_muted));
ui.add_space(6.0);
egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
let (area, _) = ui.allocate_exact_size(egui::vec2(380.0, 220.0), egui::Sense::hover());
match &d.preview {
Some((_, tex)) => {
let s = tex.size_vec2();
let k = (area.width() / s.x).min(area.height() / s.y).min(2.0);
let fit = egui::Rect::from_center_size(area.center(), s * k);
ui.painter().image(tex.id(), fit, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
}
None => {
ui.painter().text(area.center(), egui::Align2::CENTER_CENTER, "No preview", theme::regular(13.0), t.text_faint);
}
}
});
ui.add_space(8.0);
ui.add_enabled_ui(!d.decorative[i], |ui| {
let l = ui.label("Alternate text");
ui.add(egui::TextEdit::multiline(&mut d.texts[i]).desired_rows(3).desired_width(380.0).hint_text("Describe the figure")).labelled_by(l.id);
});
ui.checkbox(&mut d.decorative[i], "Decorative figure");
ui.add_space(10.0);
let (mut save, mut cancel) = (false, false);
ui.horizontal(|ui| {
if ui.add_enabled_ui(i > 0, |ui| icons::button(ui, "chevron-left", 28.0, false, "Previous figure")).inner.clicked() {
d.index -= 1;
}
if ui.add_enabled_ui(i + 1 < n, |ui| icons::button(ui, "chevron-right", 28.0, false, "Next figure")).inner.clicked() {
d.index += 1;
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, "Save & Close", true).clicked() {
save = true;
}
if widgets::pill_button(ui, "Cancel", false).clicked() {
cancel = true;
}
});
});
(save, cancel)
}
impl PrintCraftApp {
/// Prepare for accessibility ▸ Add alternate text.
pub(crate) fn start_alt_text(&mut self) {
let Some((_, id)) = self.active_ids() else { return };
let Some(doc) = self.session.get(id) else { return };
let figures = doc.figures();
self.alt_draft = AltDraft {
doc: Some(id),
texts: figures.iter().map(|f| f.alt.clone().unwrap_or_default()).collect(),
decorative: vec![false; figures.len()],
figures,
index: 0,
preview: None,
};
self.dialog = Some(Dialog::AltText);
}
/// Save & Close: one undo step for every change.
pub(crate) fn save_alt_text(&mut self) {
let d = std::mem::take(&mut self.alt_draft);
let mut edits = Vec::new();
for (k, f) in d.figures.iter().enumerate() {
if d.decorative[k] {
edits.push(printcraft_engine::Edit::MarkDecorative { figure: f.obj.num });
} else if d.texts[k].trim() != f.alt.as_deref().unwrap_or("").trim() {
edits.push(printcraft_engine::Edit::SetAltText { figure: f.obj.num, alt: Some(d.texts[k].clone()) });
}
}
if !edits.is_empty() {
self.apply_edit(printcraft_engine::Edit::Batch { label: "Set alternate text".into(), edits });
}
}
}

View File

@ -224,6 +224,7 @@ impl PrintCraftApp {
"export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)),
"a11y.check" => self.start_accessibility_check(),
"a11y.report" => self.show_accessibility_report(),
"a11y.alt_text" => self.start_alt_text(),
"a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)),
"export.all_images" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::AllImages)),
"edit.text" => {

View File

@ -52,6 +52,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
let mut a11y_now = false;
let mut combine_now = false;
let mut stamp_now = false;
let mut alt_now = false;
let t = Tokens::get(ctx);
let mut close = false;
let mut next = dialog;
@ -759,6 +760,12 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
close = apply || cancel;
return;
}
Dialog::AltText => {
let (save, cancel) = crate::a11y_ui::alt_body(ui, app, &t);
alt_now = save;
close = save || cancel;
return;
}
Dialog::CreateStamp => {
let (save, cancel) = crate::stamps_ui::create_body(ui, app, &t);
stamp_now = save;
@ -1130,6 +1137,9 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
if let Some(n) = open_revision {
app.open_revision(n);
}
if alt_now {
app.save_alt_text();
}
if stamp_now {
app.save_custom_stamp();
}

View File

@ -182,6 +182,8 @@ pub enum Dialog {
Combine,
/// Custom stamps ▸ Create.
CreateStamp,
/// Prepare for accessibility ▸ Add alternate text.
AltText,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -284,6 +286,7 @@ pub struct PrintCraftApp {
pub a11y_options: a11y_ui::A11yOptions,
pub a11y: a11y_ui::A11yState,
pub a11y_skipped: std::collections::BTreeSet<printcraft_engine::a11y::Rule>,
pub alt_draft: a11y_ui::AltDraft,
/// Combine files: the files staged so far.
pub combine_draft: Vec<combine_ui::CombineFile>,
/// The custom stamp library, and the stamp being created.
@ -411,6 +414,7 @@ impl PrintCraftApp {
a11y_options: a11y_ui::A11yOptions::default(),
a11y: a11y_ui::A11yState::default(),
a11y_skipped: Default::default(),
alt_draft: Default::default(),
combine_draft: Vec::new(),
custom_stamps: Vec::new(),
stamp_draft: Default::default(),

View File

@ -86,3 +86,51 @@ fn checking_fixing_skipping_and_reporting() {
let html = std::fs::read_to_string(dir.join("notes Accessibility Report.html")).unwrap();
assert!(html.contains("Accessibility Report") && html.contains("Skipped"));
}
/// Two tagged figures: an image without alternate text and a bar (role-mapped Photo) with.
const FIGURES: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 /Resources << /XObject << /Im 10 0 R >> >> >> endobj
4 0 obj << /Length 97 >> stream
/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC
endstream endobj
5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 9 0 R /RoleMap << /Photo /Figure >> >> endobj
6 0 obj << /S /Document /P 5 0 R /K [7 0 R 8 0 R] >> endobj
7 0 obj << /S /Figure /P 6 0 R /Pg 3 0 R /K 0 >> endobj
8 0 obj << /S /Photo /P 6 0 R /Pg 3 0 R /Alt (A bar) /K 1 >> endobj
9 0 obj << /Nums [0 [7 0 R 8 0 R]] >> endobj
10 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >> stream
\x00
endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn setting_alternate_text_figure_by_figure() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("figures.pdf", None, FIGURES.to_vec()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("a11y.alt_text"));
h.run_steps(2);
h.get_by_label("Set Alternate Text");
h.get_by_label("Figure 1 of 2 on page 1");
h.state_mut().alt_draft.texts[0] = "A grey square".into();
h.run_steps(2);
h.get_by_label("Next figure").click();
h.run_steps(2);
h.get_by_label("Figure 2 of 2 on page 1");
h.get_by_label("Decorative figure").click();
h.run_steps(1);
h.get_by_label("Save & Close").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Set alternate text"));
let f = doc.figures();
assert_eq!(f.len(), 1, "the decorative one left the tags");
assert_eq!(f[0].alt.as_deref(), Some("A grey square"));
}

View File

@ -8839,11 +8839,11 @@ title = "Set alternate text figure by figure (with decorative option)"
area = "M"
tier = "P1"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["a11y.alt_text"]
tools = ["accessibility_figures", "accessibility_set_alt"]
evidence = ["crates/a11y/src/tests.rs::figures_are_listed_with_their_alt_text_and_place", "crates/a11y/src/tests.rs::setting_alt_text_and_marking_decorative", "crates/ui-egui/tests/accessibility.rs::setting_alternate_text_figure_by_figure", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
notes = "Set Alternate Text dialog: each tagged figure (role map honoured) with a preview of where it is drawn, its alternate text, Decorative figure (content becomes /Artifact, element leaves the tags and parent tree), Previous/Next, Save & Close as one undo step"
[[feature]]
id = "a11y.alt-text-suggestions"