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:
parent
095a17610f
commit
e9322b597b
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -222,3 +222,60 @@ fn rules_have_ids_and_the_report_lists_them() {
|
|||
assert!(html.contains("a<b>.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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
],
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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}")]
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue