Accessibility: add visual tag properties panel (#1216)

* Expose tag parent and depth for hierarchy view

* Accessibility: add visual tag properties panel

* Show collapsible tag hierarchy in accessibility panel

* Open checker findings in the matching structure tag

* Translate Tags panel across supported languages

* Tags panel: walk the structure tree once per edit, not every frame

The Tags panel and checker findings that open a tag share a tag list cached per
document and edit generation.

---------

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
Earl CoandBrandon Thomas authored and GitHub committed 2026-10-12 02:28:56 -04:00
1 parent e4527a855f
commit b1784fc2d9
38 files changed
+695 -61

No files matched your search

+20 -20
View File
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "9825aae49019d4a4bf9c689e71d18a67d764df7e4466eac1ec2fb4d2bdb6c42e"
sha256 = "2200c7b374baffb569253eadf597e9ca0bff39324830cde30011617a5f1ee192"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "da6455ddbec0394c8b6661c2762cab87a0a5df3d6aaa879b4f66b3817f242cb8"
sha256 = "1218460f576264b584b0e8d58b861ccf532a1029b5ec32a1e60a941cdffa3505"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "09db938e6a0a63bb9ecda5fafed65c1bb753112b8af361b99527643492682078"
sha256 = "46a79f58780806f0f581ef8b6100269a9022214f59f288e59a9a2822997d7aeb"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7e28ad71f83126d57386ee95882fec5c64e936f0771aeee9f9e4abf6c8f28f67"
sha256 = "3aee40e8fc1f3961cfdb97c01d6fb60aa567136d8d08b546ffb7c78cf9433811"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "bbcc59d6ef51c2fef0e3f83f6f5f0d0babe5ff348548e0790a3540b4506e732b"
sha256 = "b4d4b329ca741bc097bb52185cc88a2f381a94026fd968123b47c54d943dedb3"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "27290455fc9aacfe708dbf0888e3d65f2500b6fafba8bbb7e287f6e07c13d17c"
sha256 = "64ff3e12c9065505bc47c5d847ed8aaa436de3e1f8442a9d43ccc14b516a7b19"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "b11701fa33d091f31f2d1ce1b7cf9ae0801905da0e26becae779e10e9436224c"
sha256 = "954cb27d741c49741e372cf6a18c22b28214c7fffd1088119b8f6388b27a869b"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "91230f34859577be8709597a4945e621087958b20c1f1db3cd6e77eb80721206"
sha256 = "dc971a2990664c916e65686dfcd0c1e226855573556769bac6cf4ade8845e349"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "095dd072669b1b4b4752de65b1ab225b479980696c3a7ed30d4ea9bc1716b505"
sha256 = "de419eeb6f631ad7ed4b0c409c31ef7a13a6c7bf1c009dbc61eb9a9a1d2663da"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "46e4ac6005b3df5c1cd2b6d14601513825fae478fb6b4a8fbd12fcced0d17f62"
sha256 = "963c2f7ed3a84f4c6a66897d21cded494026a6088304a1cb39c8cef032b02ca9"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e08117f26be8dea744bd073054d111afdda37c016eb8e35ef2a1e969bf5e73ec"
sha256 = "1d3a3cc44812643f8612b3ae921025daad0f52f8c237f2456f4162db955e196b"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "9c40091a92f5449ece985bf601bf614dde98073e356dbfce163531318fd0938e"
sha256 = "c619710b677655a8604c646d2028c2ccc95e756bbb03849727f67205f9fe6992"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "86298f3a6e2da3c1beb58f3eb4af33bf220cb0b6f68f3324297fa50b7e50baeb"
sha256 = "1fe0610a52cb2c9afdeef8b81e3d28aea9b74764b3c147dda6088ed5652de8b7"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d63e207517a18b6088455175801d79dd74b9fe8c34a9f23cbd51aa68a37af130"
sha256 = "61600a2c313484fe773515169eaa114e5057eef8194aa44f862372f727c5d20b"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "02cc19031eac7d49909defed801c01d40b6b9c947c387c773376fea324ca7903"
sha256 = "99d683a50491edcbb8cc941291d8cb9c223cd9f9e4bbc8ae756bbba2ab51fccf"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4bbc430569b27117b053a4655c518daeb83f462c8dec250647aafe01d849129f"
sha256 = "72555257a12e4807df23c6719d22b1a2395c90553ed7fbf17c2c24fbbe108dfc"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "586df1a1d4fc5d670fdc1796509904a6f0fe4820bc0f02c860a8d89e088e6b0b"
sha256 = "4b10d18ba3730cf651b223bd1bd106418729ab9e058aafc92a4ac4808453f050"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "88a2db042818aa78cbd2f0734ee59321cdf4015bfae50b1344501229656c9cc7"
sha256 = "64cf360b242de2043f69b09ff53c61672515dd23f18330323a72a9a57fefbb9e"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "80263c1d792c894c3d90c2158df47a8a7fdddcee1c2041b30d2ff242380d8880"
sha256 = "ed3e33cee1b8a2501b8c3fc89465462049495ff165a49d74a7c6a7b4b90d6d96"
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -3328,4 +3328,4 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "358032976389361b8ec1f63a54f5a2b2f85abffea9c2e1cca046a12d3e853ecc"
sha256 = "c993902f3fd77e1d2db78da1ed4c66a611199127c482119d80e5e6afad50a734"
+25 -15
View File
@@ -47,18 +47,28 @@ pub(crate) fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec<u8> {
t
}
/// Indirect structure elements in document order, with their page.
pub(crate) fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option<ObjRef>)> {
/// An indirect structure element and its nearest indirect parent in tree order.
pub(crate) struct Element {
pub object: ObjRef,
pub dict: Dict,
pub page: Option<ObjRef>,
pub parent: Option<ObjRef>,
pub depth: usize,
}
/// Indirect structure elements in document order, with their page and hierarchy.
pub(crate) fn elements(doc: &Document) -> Vec<Element> {
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() {
let mut stack: Vec<(Object, Option<ObjRef>, Option<ObjRef>, usize)> =
root.get(b"K").map(|k| vec![(k.clone(), None, None, 0)]).unwrap_or_default();
while let Some((k, pg, parent, depth)) = 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::Array(a) => stack.extend(a.into_iter().rev().map(|x| (x, pg, parent, depth))),
Object::Ref(r) => {
if !seen.insert(r) {
continue;
@@ -67,16 +77,16 @@ pub(crate) fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option<ObjRef>)> {
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));
stack.push((kids.clone(), pg, Some(r), depth.saturating_add(1)));
}
out.push((r, d, pg));
out.push(Element { object: r, dict: d, page: pg, parent, depth });
}
}
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));
stack.push((kids.clone(), pg, parent, depth));
}
}
_ => {}
@@ -218,13 +228,13 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
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();
.filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|e| {
let page = e.page.and_then(|p| index.get(&p).copied());
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let ids: HashSet<i64> = mcids(&e.dict).into_iter().collect();
let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids));
Figure { obj, page, alt, bbox }
Figure { obj: e.object, page, alt, bbox }
})
.collect()
}
@@ -258,7 +268,7 @@ 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 = pdfcraft_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));
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops;
let mut changed = false;
+16 -10
View File
@@ -8,6 +8,10 @@ use crate::alt;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TagInfo {
pub object: ObjRef,
/// Nearest reachable indirect parent, if any. Inline ancestors are skipped.
pub parent: Option<ObjRef>,
/// Indentation level among reachable indirect tags.
pub depth: usize,
pub kind: String,
pub title: Option<String>,
pub actual_text: Option<String>,
@@ -81,7 +85,7 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T
return Err(TagError::NotSiblings);
}
let reachable = alt::elements(doc);
if !reachable.iter().any(|e| e.0 == object) || !reachable.iter().any(|e| e.0 == anchor) {
if !reachable.iter().any(|e| e.object == object) || !reachable.iter().any(|e| e.object == anchor) {
return Err(TagError::NotSiblings);
}
let parent_of = |r| doc.get(r).as_dict().and_then(|d| d.get(b"P").and_then(Object::as_ref));
@@ -120,15 +124,17 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
let page_index: std::collections::HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
alt::elements(doc)
.into_iter()
.filter_map(|(object, d, page)| {
.filter_map(|e| {
Some(TagInfo {
object,
kind: String::from_utf8_lossy(d.name(b"S")?).into_owned(),
title: text(doc, &d, b"T"),
actual_text: text(doc, &d, b"ActualText"),
alt: text(doc, &d, b"Alt"),
language: text(doc, &d, b"Lang"),
page: page.and_then(|p| page_index.get(&p).copied()),
object: e.object,
parent: e.parent,
depth: e.depth,
kind: String::from_utf8_lossy(e.dict.name(b"S")?).into_owned(),
title: text(doc, &e.dict, b"T"),
actual_text: text(doc, &e.dict, b"ActualText"),
alt: text(doc, &e.dict, b"Alt"),
language: text(doc, &e.dict, b"Lang"),
page: e.page.and_then(|p| page_index.get(&p).copied()),
})
})
.collect()
@@ -136,7 +142,7 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
/// Change one property of a reachable tag; `None` removes a text property.
pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> {
if !alt::elements(doc).iter().any(|(r, _, _)| *r == object) {
if !alt::elements(doc).iter().any(|e| e.object == object) {
return Err(TagError::NotATag(object));
}
// ActualText may intentionally contain leading or trailing whitespace; preserve it.
+10
View File
@@ -196,6 +196,16 @@ fn reachable_tag_properties_round_trip_and_reject_unrelated_objects() {
assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(4, 1), TagProperty::Title, Some("No")), Err(TagError::NotATag(_))));
}
#[test]
fn tag_hierarchy_keeps_indirect_parent_through_inline_tags() {
let doc = structure_doc(&["<< /S /Document /K 4 0 R >>".into(), "<< /S /Sect /K << /S /Div /K 5 0 R >> >>".into(), "<< /S /Figure >>".into()]);
let listed = tags(&doc);
assert_eq!(
listed.iter().map(|t| (t.object.num, t.parent.map(|p| p.num), t.depth)).collect::<Vec<_>>(),
[(3, None, 0), (4, Some(3), 1), (5, Some(4), 2)]
);
}
fn checked(doc: &Document, rules: &[Rule]) -> Report {
check(doc, &Options { rules: rules.iter().copied().collect(), pages: None })
}
+1
View File
@@ -416,6 +416,7 @@ impl Automation {
.map(|t| {
json!({
"object": t.object.num, "generation": t.object.generation, "type": t.kind,
"parent": t.parent.map(|p| json!({ "object": p.num, "generation": p.generation })), "depth": t.depth,
"title": t.title, "actual_text": t.actual_text, "alternate_text": t.alt,
"language": t.language, "page": t.page.map(|p| p + 1),
})
+2 -1
View File
@@ -908,8 +908,9 @@ pub fn tools() -> Vec<ToolDef> {
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("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, page, type, title, actual text, alternate text and language. Inline tag dictionaries have no stable object reference and are not listed.")
t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language. Inline tag dictionaries have no stable object reference and are not listed.")
.ro()
.cmd("a11y.tags")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.")
.with(schema(json!({
+2
View File
@@ -1724,6 +1724,8 @@ trailer << /Root 1 0 R >>
assert_eq!(tags["count"], 3);
assert_eq!(tags["tags"][1]["object"], 7);
assert_eq!(tags["tags"][1]["type"], "Figure");
assert_eq!(tags["tags"][1]["parent"], json!({ "object": 6, "generation": 0 }));
assert_eq!(tags["tags"][1]["depth"], 1);
let changed = ok(
&mut a,
"accessibility_set_tag_property",
+1
View File
@@ -462,6 +462,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
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", Ready),
item("Tags panel", "tag", "a11y.tags", Ready),
item("Fix reading order", "list-ordered", "a11y.reading_order", Planned("M12")),
],
}],
+1
View File
@@ -324,6 +324,7 @@ pub const COMMANDS: &[CommandSpec] = &[
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("a11y.tags", "Tags panel", None, None, Document, "tag"),
c("export.all_images", "Export all images…", FILE, None, Copying, "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"),
+1 -1
View File
@@ -13,6 +13,7 @@ workspace = true
craft-ui = { workspace = true }
pdfcraft-fonts = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-platform = { workspace = true }
@@ -63,7 +64,6 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE
[dev-dependencies]
pdfcraft-xfa = { workspace = true }
pdfcraft-cos = { workspace = true }
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
image = { version = "0.25", default-features = false, features = ["png"] }
# Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses.
+33 -6
View File
@@ -5,6 +5,7 @@
use std::collections::BTreeSet;
use egui::{Align, Layout};
use pdfcraft_cos::ObjRef;
use pdfcraft_engine::DocId;
use pdfcraft_engine::a11y::{Category, Options, Report, Rule, Status};
@@ -63,6 +64,7 @@ pub(crate) enum PanelAction {
ShowReport,
Options,
GoTo(usize, Option<[f64; 4]>),
GoToTag(ObjRef, Option<usize>, Option<[f64; 4]>),
}
/// Rules with an automatic (or guided) fix.
@@ -314,7 +316,11 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: D
// the sentence is said in the panel's language with those words still in it.
let said = f.said(&crate::i18n::words);
let l = ui.add(egui::Label::new(egui::RichText::new(said).small().color(t.text_muted)).sense(egui::Sense::click()));
if let Some(p) = f.page
if let Some(object) = f.object {
if l.on_hover_text(tl!("Tags")).clicked() {
action = Some(PanelAction::GoToTag(object, f.page, f.bbox));
}
} else if let Some(p) = f.page
&& l.on_hover_text(tl!("Go to the page")).clicked()
{
action = Some(PanelAction::GoTo(p, f.bbox));
@@ -393,16 +399,37 @@ impl PdfCraftApp {
self.write_files(&[(format!("{stem} Accessibility Report.html"), std::sync::Arc::new(html.into_bytes()))], &title);
}
/// Turn to a finding's page and, when the checker located it, flash its box there.
fn a11y_show_on_page(&mut self, index: usize, page: usize, bbox: Option<[f64; 4]>) {
let Some(view) = self.views.get_mut(index) else { return };
view.go_to_page(page);
if let Some(bbox) = bbox {
view.flash = Some((page, bbox.map(|v| v as f32), 0.0));
}
}
pub(crate) fn a11y_action(&mut self, index: usize, action: PanelAction) {
match action {
PanelAction::CheckAgain => self.run_accessibility_check(),
PanelAction::Options => self.dialog = Some(Dialog::AccessibilityOptions),
PanelAction::ShowReport => self.show_accessibility_report(),
PanelAction::GoTo(p, bbox) => {
let view = &mut self.views[index];
view.go_to_page(p);
if let Some(bbox) = bbox {
view.flash = Some((p, bbox.map(|v| v as f32), 0.0));
PanelAction::GoTo(p, bbox) => self.a11y_show_on_page(index, p, bbox),
PanelAction::GoToTag(object, page, bbox) => {
let Some((_, id)) = self.active_ids() else { return };
let Some(tags) = self.session.get(id).map(|doc| self.tags_state.tags(doc)) else { return };
if !tags.iter().any(|tag| tag.object == object) {
if let Some(page) = page {
self.a11y_show_on_page(index, page, bbox);
}
return;
}
if self.tags_state.select(id, &tags, object) {
self.right = Some(RightPanel::Tags);
if let Some(page) = page {
self.a11y_show_on_page(index, page, bbox);
}
} else {
self.notify_tr("Save or revert changes before selecting another tag.");
}
}
PanelAction::Skip(rule) => {
+2
View File
@@ -470,6 +470,7 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
(RightPanel::Attachments, tl!("Attachments")),
(RightPanel::Signatures, tl!("Signatures")),
(RightPanel::Accessibility, tl!("Accessibility Checker")),
(RightPanel::Tags, tl!("Tags")),
(RightPanel::Search, tl!("Search")),
(RightPanel::Compare, tl!("Compare")),
] {
@@ -525,6 +526,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
if has_check {
rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false);
}
rail_button(ui, RightPanel::Tags, "tag", "Tags", false);
// Page navigation cluster at the bottom (as in Acrobat's rail).
let view = &mut app.views[index];
+1
View File
@@ -394,6 +394,7 @@ impl PdfCraftApp {
}
"a11y.report" => self.show_accessibility_report(),
"a11y.alt_text" => self.start_alt_text(),
"a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)),
"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" => {
+13
View File
@@ -2291,6 +2291,19 @@ scroll speed Fast سريعة
Tools panel side جانب لوحة الأدوات
Add comment إضافة تعليق
Change tag properties تغيير خصائص الوسم
# Accessibility structure tags.
Tags panel لوحة الوسوم
Tags الوسوم
Tag type نوع الوسم
Actual text النص الفعلي
Find a tag ابحث عن وسم
Collapse tag طي الوسم
Expand tag توسيع الوسم
Revert changes التراجع عن التغييرات
Save tag properties حفظ خصائص الوسم
This document has no editable structure tags. لا يحتوي هذا المستند على وسوم بنيوية قابلة للتحرير.
{n} tags {n} وسوم
Save or revert changes before selecting another tag. احفظ التغييرات أو تراجع عنها قبل تحديد وسم آخر.
Left out: {names} تم استبعاد: {names}
already a PDF ملف PDF بالفعل
no unused output name لا يوجد اسم ناتج غير مستخدم
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -2233,6 +2233,19 @@ scroll speed Fast Висока
Tools panel side Страна на панела с инструменти
Add comment Добавяне на коментар
Change tag properties Промяна на свойствата на етикета
# Accessibility structure tags.
Tags panel Панел с тагове
Tags Тагове
Tag type Тип на тага
Actual text Действителен текст
Find a tag Намери таг
Collapse tag Свий тага
Expand tag Разгъни тага
Revert changes Отмени промените
Save tag properties Запази свойствата на тага
This document has no editable structure tags. Този документ няма структурни тагове за редактиране.
{n} tags {n} тага
Save or revert changes before selecting another tag. Запазете или отменете промените, преди да изберете друг таг.
Left out: {names} Пропуснати: {names}
already a PDF вече е PDF
no unused output name няма свободно име за изходния файл
Can't render this file because it contains an unexpected character in line 8 and column 16.
+13
View File
@@ -2344,6 +2344,19 @@ scroll speed Fast Rychlá
Tools panel side Strana panelu nástrojů
Add comment Přidat komentář
Change tag properties Změnit vlastnosti značky
# Accessibility structure tags.
Tags panel Panel značek
Tags Značky
Tag type Typ značky
Actual text Skutečný text
Find a tag Najít značku
Collapse tag Sbalit značku
Expand tag Rozbalit značku
Revert changes Vrátit změny
Save tag properties Uložit vlastnosti značky
This document has no editable structure tags. Tento dokument nemá upravitelné značky struktury.
{n} tags {n} značek
Save or revert changes before selecting another tag. Před výběrem jiné značky změny uložte nebo vraťte.
Left out: {names} Vynecháno: {names}
already a PDF již je PDF
no unused output name žádný volný název výstupního souboru
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -2271,6 +2271,19 @@ scroll speed Fast Schnell
Tools panel side Seite des Werkzeugbereichs
Add comment Kommentar hinzufügen
Change tag properties Tag-Eigenschaften ändern
# Accessibility structure tags.
Tags panel Tags-Bereich
Tags Tags
Tag type Tag-Typ
Actual text Tatsächlicher Text
Find a tag Tag suchen
Collapse tag Tag einklappen
Expand tag Tag ausklappen
Revert changes Änderungen verwerfen
Save tag properties Tag-Eigenschaften speichern
This document has no editable structure tags. Dieses Dokument enthält keine bearbeitbaren Struktur-Tags.
{n} tags {n} Tags
Save or revert changes before selecting another tag. Speichere oder verwerfe die Änderungen, bevor du ein anderes Tag auswählst.
Left out: {names} Ausgelassen: {names}
already a PDF bereits ein PDF
no unused output name kein freier Ausgabename
Can't render this file because it contains an unexpected character in line 6 and column 51.
+13
View File
@@ -2285,6 +2285,19 @@ scroll speed Fast Γρήγορη
Tools panel side Πλευρά πλαισίου εργαλείων
Add comment Προσθήκη σχολίου
Change tag properties Αλλαγή ιδιοτήτων ετικέτας
# Accessibility structure tags.
Tags panel Πίνακας ετικετών
Tags Ετικέτες
Tag type Τύπος ετικέτας
Actual text Πραγματικό κείμενο
Find a tag Εύρεση ετικέτας
Collapse tag Σύμπτυξη ετικέτας
Expand tag Ανάπτυξη ετικέτας
Revert changes Αναίρεση αλλαγών
Save tag properties Αποθήκευση ιδιοτήτων ετικέτας
This document has no editable structure tags. Αυτό το έγγραφο δεν έχει επεξεργάσιμες ετικέτες δομής.
{n} tags {n} ετικέτες
Save or revert changes before selecting another tag. Αποθηκεύστε ή αναιρέστε τις αλλαγές πριν επιλέξετε άλλη ετικέτα.
Left out: {names} Παραλείφθηκαν: {names}
already a PDF είναι ήδη PDF
no unused output name δεν υπάρχει διαθέσιμο όνομα εξόδου
Can't render this file because it contains an unexpected character in line 479 and column 12.
+13
View File
@@ -2400,6 +2400,19 @@ scroll speed Fast Rápida
Tools panel side Lado del panel de herramientas
Add comment Añadir comentario
Change tag properties Cambiar propiedades de la etiqueta
# Accessibility structure tags.
Tags panel Panel de etiquetas
Tags Etiquetas
Tag type Tipo de etiqueta
Actual text Texto real
Find a tag Buscar una etiqueta
Collapse tag Contraer etiqueta
Expand tag Expandir etiqueta
Revert changes Descartar cambios
Save tag properties Guardar propiedades de la etiqueta
This document has no editable structure tags. Este documento no tiene etiquetas de estructura editables.
{n} tags {n} etiquetas
Save or revert changes before selecting another tag. Guarda o descarta los cambios antes de seleccionar otra etiqueta.
Left out: {names} Omitidos: {names}
already a PDF ya es un PDF
no unused output name no hay un nombre de salida libre
Can't render this file because it contains an unexpected character in line 6 and column 60.
+13
View File
@@ -2270,6 +2270,19 @@ scroll speed Fast Rapide
Tools panel side Côté du panneau Outils
Add comment Ajouter un commentaire
Change tag properties Modifier les propriétés de la balise
# Accessibility structure tags.
Tags panel Panneau des balises
Tags Balises
Tag type Type de balise
Actual text Texte réel
Find a tag Rechercher une balise
Collapse tag Réduire la balise
Expand tag Développer la balise
Revert changes Annuler les modifications
Save tag properties Enregistrer les propriétés de la balise
This document has no editable structure tags. Ce document ne contient aucune balise de structure modifiable.
{n} tags {n} balises
Save or revert changes before selecting another tag. Enregistrez ou annulez les modifications avant de sélectionner une autre balise.
Left out: {names} Ignorés : {names}
already a PDF déjà un PDF
no unused output name aucun nom de sortie libre
Can't render this file because it contains an unexpected character in line 6 and column 39.
+13
View File
@@ -2269,6 +2269,19 @@ scroll speed Fast Gyors
Tools panel side Az Eszközök panel oldala
Add comment Megjegyzés hozzáadása
Change tag properties Címke tulajdonságainak módosítása
# Accessibility structure tags.
Tags panel Címkék panel
Tags Címkék
Tag type Címke típusa
Actual text Tényleges szöveg
Find a tag Címke keresése
Collapse tag Címke összecsukása
Expand tag Címke kibontása
Revert changes Módosítások visszavonása
Save tag properties Címke tulajdonságainak mentése
This document has no editable structure tags. Ebben a dokumentumban nincsenek szerkeszthető szerkezeti címkék.
{n} tags {n} címke
Save or revert changes before selecting another tag. Másik címke kiválasztása előtt mentse vagy vonja vissza a módosításokat.
Left out: {names} Kihagyva: {names}
already a PDF már PDF
no unused output name nincs szabad kimeneti név
Can't render this file because it contains an unexpected character in line 7 and column 44.
+13
View File
@@ -2279,6 +2279,19 @@ scroll speed Fast Veloce
Tools panel side Lato del pannello Strumenti
Add comment Aggiungi commento
Change tag properties Modifica proprietà del tag
# Accessibility structure tags.
Tags panel Pannello tag
Tags Tag
Tag type Tipo di tag
Actual text Testo effettivo
Find a tag Trova un tag
Collapse tag Comprimi tag
Expand tag Espandi tag
Revert changes Annulla modifiche
Save tag properties Salva proprietà del tag
This document has no editable structure tags. Questo documento non contiene tag strutturali modificabili.
{n} tags {n} tag
Save or revert changes before selecting another tag. Salva o annulla le modifiche prima di selezionare un altro tag.
Left out: {names} Esclusi: {names}
already a PDF è già un PDF
no unused output name nessun nome di output libero
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -2270,6 +2270,19 @@ scroll speed Fast 速い
Tools panel side ツールパネルの位置
Add comment コメントを追加
Change tag properties タグのプロパティを変更
# Accessibility structure tags.
Tags panel タグパネル
Tags タグ
Tag type タグの種類
Actual text 実際のテキスト
Find a tag タグを検索
Collapse tag タグを折りたたむ
Expand tag タグを展開
Revert changes 変更を元に戻す
Save tag properties タグのプロパティを保存
This document has no editable structure tags. この文書には編集可能な構造タグがありません。
{n} tags {n} 個のタグ
Save or revert changes before selecting another tag. 別のタグを選択する前に変更を保存するか元に戻してください。
Left out: {names} 除外: {names}
already a PDF すでに PDF です
no unused output name 使用可能な出力名がありません
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -2267,6 +2267,19 @@ scroll speed Fast Жылдам
Tools panel side Құралдар панелінің жағы
Add comment Түсініктеме қосу
Change tag properties Тег қасиеттерін өзгерту
# Accessibility structure tags.
Tags panel Тегтер панелі
Tags Тегтер
Tag type Тег түрі
Actual text Нақты мәтін
Find a tag Тегті табу
Collapse tag Тегті жинау
Expand tag Тегті жаю
Revert changes Өзгерістерді қайтару
Save tag properties Тег қасиеттерін сақтау
This document has no editable structure tags. Бұл құжатта өңделетін құрылымдық тегтер жоқ.
{n} tags {n} тег
Save or revert changes before selecting another tag. Басқа тегті таңдамас бұрын өзгерістерді сақтаңыз немесе қайтарыңыз.
Left out: {names} Қалдырылды: {names}
already a PDF бұл қазірдің өзінде PDF
no unused output name бос шығыс атауы жоқ
Can't render this file because it contains an unexpected character in line 820 and column 12.
+13
View File
@@ -2250,6 +2250,19 @@ scroll speed Fast Szybka
Tools panel side Strona panelu narzędzi
Add comment Dodaj komentarz
Change tag properties Zmień właściwości znacznika
# Accessibility structure tags.
Tags panel Panel znaczników
Tags Znaczniki
Tag type Typ znacznika
Actual text Tekst rzeczywisty
Find a tag Znajdź znacznik
Collapse tag Zwiń znacznik
Expand tag Rozwiń znacznik
Revert changes Cofnij zmiany
Save tag properties Zapisz właściwości znacznika
This document has no editable structure tags. Ten dokument nie ma edytowalnych znaczników struktury.
{n} tags {n} znaczników
Save or revert changes before selecting another tag. Zapisz lub cofnij zmiany przed wybraniem innego znacznika.
Left out: {names} Pominięto: {names}
already a PDF to już jest PDF
no unused output name brak wolnej nazwy pliku wyjściowego
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -2284,6 +2284,19 @@ scroll speed Fast Rápida
Tools panel side Lado do painel Ferramentas
Add comment Adicionar comentário
Change tag properties Alterar propriedades da tag
# Accessibility structure tags.
Tags panel Painel de tags
Tags Tags
Tag type Tipo de tag
Actual text Texto real
Find a tag Localizar uma tag
Collapse tag Recolher tag
Expand tag Expandir tag
Revert changes Reverter alterações
Save tag properties Salvar propriedades da tag
This document has no editable structure tags. Este documento não tem tags estruturais editáveis.
{n} tags {n} tags
Save or revert changes before selecting another tag. Salve ou reverta as alterações antes de selecionar outra tag.
Left out: {names} Deixados de fora: {names}
already a PDF já é um PDF
no unused output name nenhum nome de saída disponível
Can't render this file because it contains an unexpected character in line 9 and column 16.
+13
View File
@@ -2183,6 +2183,19 @@ scroll speed Fast Высокая
Tools panel side Сторона панели инструментов
Add comment Добавить комментарий
Change tag properties Изменить свойства тега
# Accessibility structure tags.
Tags panel Панель тегов
Tags Теги
Tag type Тип тега
Actual text Фактический текст
Find a tag Найти тег
Collapse tag Свернуть тег
Expand tag Развернуть тег
Revert changes Отменить изменения
Save tag properties Сохранить свойства тега
This document has no editable structure tags. В этом документе нет редактируемых структурных тегов.
{n} tags {n} тегов
Save or revert changes before selecting another tag. Сохраните или отмените изменения перед выбором другого тега.
Left out: {names} Пропущены: {names}
already a PDF уже PDF
no unused output name нет свободного имени для выходного файла
Can't render this file because it contains an unexpected character in line 9 and column 16.
+13
View File
@@ -2274,6 +2274,19 @@ scroll speed Fast Hitro
Tools panel side Stran podokna z orodji
Add comment Dodaj komentar
Change tag properties Spremeni lastnosti oznake
# Accessibility structure tags.
Tags panel Podokno oznak
Tags Oznake
Tag type Vrsta oznake
Actual text Dejansko besedilo
Find a tag Poišči oznako
Collapse tag Strni oznako
Expand tag Razširi oznako
Revert changes Razveljavi spremembe
Save tag properties Shrani lastnosti oznake
This document has no editable structure tags. Ta dokument nima oznak strukture, ki bi jih bilo mogoče urejati.
{n} tags {n} oznak
Save or revert changes before selecting another tag. Preden izberete drugo oznako, shranite ali razveljavite spremembe.
Left out: {names} Izpuščeno: {names}
already a PDF je že PDF
no unused output name ni prostega izhodnega imena
Can't render this file because it contains an unexpected character in line 9 and column 16.
+13
View File
@@ -2183,6 +2183,19 @@ scroll speed Fast వేగంగా
Tools panel side టూల్స్ ప్యానెల్ వైపు
Add comment వ్యాఖ్యను జోడించు
Change tag properties ట్యాగ్ లక్షణాలను మార్చు
# Accessibility structure tags.
Tags panel ట్యాగ్‌ల ప్యానెల్
Tags ట్యాగ్‌లు
Tag type ట్యాగ్ రకం
Actual text వాస్తవ వచనం
Find a tag ట్యాగ్‌ను కనుగొను
Collapse tag ట్యాగ్‌ను కుదించు
Expand tag ట్యాగ్‌ను విస్తరించు
Revert changes మార్పులను రద్దు చేయి
Save tag properties ట్యాగ్ లక్షణాలను సేవ్ చేయి
This document has no editable structure tags. ఈ పత్రంలో సవరించగల నిర్మాణ ట్యాగ్‌లు లేవు.
{n} tags {n} ట్యాగ్‌లు
Save or revert changes before selecting another tag. మరొక ట్యాగ్‌ను ఎంచుకునే ముందు మార్పులను సేవ్ చేయండి లేదా రద్దు చేయండి.
Left out: {names} వదిలివేసినవి: {names}
already a PDF ఇప్పటికే PDF
no unused output name ఉపయోగించని అవుట్‌పుట్ పేరు లేదు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+13
View File
@@ -2265,6 +2265,19 @@ scroll speed Fast Hızlı
Tools panel side Araçlar panelinin yeri
Add comment Yorum ekle
Change tag properties Etiket özelliklerini değiştir
# Accessibility structure tags.
Tags panel Etiketler paneli
Tags Etiketler
Tag type Etiket türü
Actual text Gerçek metin
Find a tag Etiket bul
Collapse tag Etiketi daralt
Expand tag Etiketi genişlet
Revert changes Değişiklikleri geri al
Save tag properties Etiket özelliklerini kaydet
This document has no editable structure tags. Bu belgede düzenlenebilir yapı etiketleri yok.
{n} tags {n} etiket
Save or revert changes before selecting another tag. Başka bir etiket seçmeden önce değişiklikleri kaydedin veya geri alın.
Left out: {names} Dışarıda bırakılanlar: {names}
already a PDF zaten bir PDF
no unused output name kullanılmayan çıktı adı yok
Can't render this file because it contains an unexpected character in line 819 and column 12.
+13
View File
@@ -2292,6 +2292,19 @@ scroll speed Fast Швидка
Tools panel side Бік панелі інструментів
Add comment Додати коментар
Change tag properties Змінити властивості тегу
# Accessibility structure tags.
Tags panel Панель тегів
Tags Теги
Tag type Тип тегу
Actual text Фактичний текст
Find a tag Знайти тег
Collapse tag Згорнути тег
Expand tag Розгорнути тег
Revert changes Скасувати зміни
Save tag properties Зберегти властивості тегу
This document has no editable structure tags. У цьому документі немає структурних тегів, доступних для редагування.
{n} tags {n} тегів
Save or revert changes before selecting another tag. Збережіть або скасуйте зміни, перш ніж вибрати інший тег.
Left out: {names} Пропущено: {names}
already a PDF вже PDF
no unused output name немає вільної назви для вихідного файлу
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -2234,6 +2234,19 @@ scroll speed Fast 快
Tools panel side 工具面板位置
Add comment 添加注释
Change tag properties 更改标签属性
# Accessibility structure tags.
Tags panel 标签面板
Tags 标签
Tag type 标签类型
Actual text 实际文本
Find a tag 查找标签
Collapse tag 折叠标签
Expand tag 展开标签
Revert changes 撤销更改
Save tag properties 保存标签属性
This document has no editable structure tags. 此文档没有可编辑的结构标签。
{n} tags {n} 个标签
Save or revert changes before selecting another tag. 选择其他标签前,请保存或撤销更改。
Left out: {names} 已略过:{names}
already a PDF 已经是 PDF
no unused output name 没有可用的输出文件名
Can't render this file because it contains an unexpected character in line 9 and column 16.
+13
View File
@@ -2144,6 +2144,19 @@ scroll speed Fast 快
Tools panel side 工具面板位置
Add comment 新增註解
Change tag properties 變更標籤屬性
# Accessibility structure tags.
Tags panel 標籤面板
Tags 標籤
Tag type 標籤類型
Actual text 實際文字
Find a tag 尋找標籤
Collapse tag 收合標籤
Expand tag 展開標籤
Revert changes 還原變更
Save tag properties 儲存標籤屬性
This document has no editable structure tags. 此文件沒有可編輯的結構標籤。
{n} tags {n} 個標籤
Save or revert changes before selecting another tag. 選取其他標籤前,請儲存或還原變更。
Left out: {names} 已略過:{names}
already a PDF 已經是 PDF
no unused output name 沒有可用的輸出檔名
Can't render this file because it contains an unexpected character in line 9 and column 16.
+6
View File
@@ -28,6 +28,7 @@ mod appearance;
pub mod canvas;
mod chrome;
mod combine_grid;
mod tags_ui;
pub use combine_grid::ThumbState as CombineThumb;
mod combine_ui;
pub mod docking;
@@ -177,6 +178,8 @@ pub enum RightPanel {
Signatures,
/// Accessibility Checker results.
Accessibility,
/// Reachable PDF structure tags and their properties.
Tags,
/// Advanced Search results.
Search,
/// Compare files: the differences.
@@ -582,6 +585,7 @@ pub struct PdfCraftApp {
pub js_console: js_ui::JsConsole,
pub doc_js: js_ui::DocJsDraft,
pub a11y: a11y_ui::A11yState,
pub(crate) tags_state: tags_ui::State,
pub a11y_skipped: std::collections::BTreeSet<pdfcraft_engine::a11y::Rule>,
pub alt_draft: a11y_ui::AltDraft,
/// List the OS key store's signing identities among the digital IDs (the desktop app).
@@ -859,6 +863,7 @@ impl PdfCraftApp {
js_console: Default::default(),
doc_js: Default::default(),
a11y: a11y_ui::A11yState::default(),
tags_state: Default::default(),
a11y_skipped: Default::default(),
alt_draft: Default::default(),
os_key_store_ids: false,
@@ -1851,6 +1856,7 @@ impl PdfCraftApp {
"attachments" => Some(RightPanel::Attachments),
"signatures" => Some(RightPanel::Signatures),
"accessibility" => Some(RightPanel::Accessibility),
"tags" => Some(RightPanel::Tags),
"search" => Some(RightPanel::Search),
"compare" => Some(RightPanel::Compare),
"none" => None,
+10
View File
@@ -487,6 +487,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let prefs = &app.comment_prefs;
let sig_expanded = &mut app.sig_expanded;
let a11y = &mut app.a11y;
let tags_state = &mut app.tags_state;
let compare = &app.compare;
let comment_allowed = doc.allows_annotation();
// A dialog or the palette owns the keyboard: the panel leaves Escape to it.
@@ -505,6 +506,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
RightPanel::Attachments => ("Attachments", Some(info.attachments.len())),
RightPanel::Signatures => ("Signatures", Some(doc.signatures.iter().filter(|s| s.signed).count())),
RightPanel::Accessibility => ("Accessibility Checker", None),
RightPanel::Tags => ("Tags", None),
RightPanel::Search => ("Search", None),
RightPanel::Compare => ("Compare", compare.as_ref().filter(|c| c.new == id).map(|c| c.result.changes.len())),
};
@@ -667,6 +669,14 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
RightPanel::Accessibility => {
a11y_action = crate::a11y_ui::panel(ui, &t, a11y, id);
}
RightPanel::Tags => {
let tags = tags_state.tags(doc);
let (page, edit) = crate::tags_ui::panel(ui, &t, id, &tags, tags_state, doc.allows_modification());
if let Some(page) = page {
nav = Some(Nav::Page(page));
}
panel_edit = edit;
}
RightPanel::Search => crate::search_ui::panel(ui, &t, view, info.pages.len()),
RightPanel::Compare => compare_action = crate::compare_ui::panel(ui, &t, compare, id),
RightPanel::Attachments => {
+228
View File
@@ -0,0 +1,228 @@
//! A visual editor for properties of reachable indirect structure tags.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc;
use egui::Ui;
use pdfcraft_cos::ObjRef;
use pdfcraft_engine::a11y::{TagInfo, TagProperty};
use pdfcraft_engine::{DocId, Edit};
use crate::{icons, theme::Tokens, widgets};
#[derive(Clone, Debug, Default)]
pub(crate) struct State {
doc: Option<DocId>,
selected: Option<ObjRef>,
collapsed: BTreeSet<ObjRef>,
query: String,
draft: Draft,
/// The tag list per (document, edit generation): walking the structure tree is too slow
/// to repeat every frame on a large tagged PDF.
cache: Option<(DocId, u64, Arc<Vec<TagInfo>>)>,
}
#[derive(Clone, Debug, Default)]
struct Draft {
kind: String,
title: String,
actual_text: String,
alt: String,
language: String,
}
impl Draft {
fn from_tag(tag: &TagInfo) -> Self {
Self {
kind: tag.kind.clone(),
title: tag.title.clone().unwrap_or_default(),
actual_text: tag.actual_text.clone().unwrap_or_default(),
alt: tag.alt.clone().unwrap_or_default(),
language: tag.language.clone().unwrap_or_default(),
}
}
fn edit(&self, tag: &TagInfo) -> Option<Edit> {
let mut edits = Vec::new();
let mut add = |property, prior: Option<&str>, next: &str| {
let value = (!next.is_empty()).then(|| next.to_owned());
if prior != value.as_deref() {
edits.push(Edit::SetTagProperty { object: tag.object, property, value });
}
};
add(TagProperty::Type, Some(&tag.kind), &self.kind);
add(TagProperty::Title, tag.title.as_deref(), &self.title);
add(TagProperty::ActualText, tag.actual_text.as_deref(), &self.actual_text);
add(TagProperty::AlternateText, tag.alt.as_deref(), &self.alt);
add(TagProperty::Language, tag.language.as_deref(), &self.language);
match edits.len() {
0 => None,
1 => edits.pop(),
_ => Some(Edit::Batch { label: "Change tag properties".into(), edits }),
}
}
}
impl State {
/// The document's tags, walked again only after an edit.
pub(crate) fn tags(&mut self, doc: &pdfcraft_engine::Document) -> Arc<Vec<TagInfo>> {
let generation = doc.edit_generation();
if let Some((id, g, tags)) = &self.cache
&& *id == doc.id
&& *g == generation
{
return tags.clone();
}
let tags = Arc::new(doc.tags());
self.cache = Some((doc.id, generation, tags.clone()));
tags
}
/// Select a checker finding's tag without discarding an unfinished edit.
pub(crate) fn select(&mut self, doc: DocId, tags: &[TagInfo], object: ObjRef) -> bool {
let Some(tag) = tags.iter().find(|tag| tag.object == object) else { return false };
if self.doc == Some(doc)
&& self
.selected
.and_then(|selected| tags.iter().find(|tag| tag.object == selected))
.is_some_and(|current| self.draft.edit(current).is_some())
{
return false;
}
if self.doc != Some(doc) {
self.collapsed.clear();
}
self.doc = Some(doc);
self.selected = Some(object);
self.draft = Draft::from_tag(tag);
self.query.clear();
self.collapsed.clear();
true
}
}
/// The chosen page and an edit to apply after the panel releases its document borrow.
pub(crate) fn panel(ui: &mut Ui, t: &Tokens, doc: DocId, tags: &[TagInfo], state: &mut State, can_edit: bool) -> (Option<usize>, Option<Edit>) {
if state.doc != Some(doc) {
state.doc = Some(doc);
state.selected = None;
state.draft = Draft::default();
state.query.clear();
state.collapsed.clear();
}
if tags.is_empty() {
ui.label(tl!("This document has no editable structure tags."));
return (None, None);
}
if state.selected.is_none_or(|r| !tags.iter().any(|t| t.object == r)) {
state.selected = Some(tags[0].object);
state.draft = Draft::from_tag(&tags[0]);
}
let search_label = ui.label(tl!("Find a tag"));
ui.add(egui::TextEdit::singleline(&mut state.query).hint_text(tl!("Find a tag"))).labelled_by(search_label.id);
ui.add_space(4.0);
let query = state.query.to_lowercase();
let matching = visible_tags(tags, &query, &state.collapsed);
let parents: HashSet<ObjRef> = tags.iter().filter_map(|tag| tag.parent).collect();
let dirty = state.selected.and_then(|r| tags.iter().find(|t| t.object == r)).is_some_and(|tag| state.draft.edit(tag).is_some());
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{n} tags"), &[("n", &matching.len().to_string())])).small().color(t.text_muted));
let mut page = None;
egui::ScrollArea::vertical().max_height(100.0).show_rows(ui, 26.0, matching.len(), |ui, range| {
for tag in &matching[range] {
let title = tag.title.as_deref().filter(|s| !s.is_empty()).unwrap_or(&tag.kind);
let label = format!("{title} · {}", tag.object.num);
ui.horizontal(|ui| {
ui.add_space((tag.depth.min(8) as f32) * 12.0);
if parents.contains(&tag.object) && query.is_empty() {
let closed = state.collapsed.contains(&tag.object);
if icons::button(
ui,
if closed { "chevron-right" } else { "chevron-down" },
18.0,
false,
if closed { tl!("Expand tag") } else { tl!("Collapse tag") },
)
.clicked()
{
if closed {
state.collapsed.remove(&tag.object);
} else {
state.collapsed.insert(tag.object);
}
}
} else {
ui.add_space(18.0);
}
if ui
.add_enabled(!dirty || state.selected == Some(tag.object), egui::Button::selectable(state.selected == Some(tag.object), label))
.clicked()
{
state.selected = Some(tag.object);
state.draft = Draft::from_tag(tag);
page = tag.page;
}
});
}
});
ui.separator();
let Some(tag) = state.selected.and_then(|r| tags.iter().find(|t| t.object == r)) else { return (page, None) };
ui.label(egui::RichText::new(format!("{} {} R", tag.object.num, tag.object.generation)).small().color(t.text_faint));
if let Some(p) = tag.page
&& ui.button(crate::i18n::fmt(tl!("Go to page {p}"), &[("p", &(p + 1).to_string())])).clicked()
{
page = Some(p);
}
ui.add_enabled_ui(can_edit, |ui| {
field(ui, tl!("Tag type"), &mut state.draft.kind, false);
field(ui, tl!("Title"), &mut state.draft.title, false);
field(ui, tl!("Actual text"), &mut state.draft.actual_text, true);
field(ui, tl!("Alternate text"), &mut state.draft.alt, true);
field(ui, tl!("Language"), &mut state.draft.language, false);
});
let edit = state.draft.edit(tag);
let mut save = false;
ui.horizontal(|ui| {
save = ui.add_enabled(can_edit && !state.draft.kind.is_empty() && edit.is_some(), egui::Button::new(tl!("Save tag properties"))).clicked();
if ui.add_enabled(dirty, egui::Button::new(tl!("Revert changes"))).clicked() {
state.draft = Draft::from_tag(tag);
}
});
(page, if save { edit } else { None })
}
fn visible_tags<'a>(tags: &'a [TagInfo], query: &str, collapsed: &BTreeSet<ObjRef>) -> Vec<&'a TagInfo> {
if query.is_empty() {
let mut hidden_after: Option<usize> = None;
return tags
.iter()
.filter(|tag| {
if hidden_after.is_some_and(|level| tag.depth > level) {
return false;
}
hidden_after = collapsed.contains(&tag.object).then_some(tag.depth);
true
})
.collect();
}
let by_object: HashMap<ObjRef, &TagInfo> = tags.iter().map(|tag| (tag.object, tag)).collect();
let mut show = HashSet::new();
for tag in
tags.iter().filter(|tag| tag.kind.to_lowercase().contains(query) || tag.title.as_ref().is_some_and(|v| v.to_lowercase().contains(query)))
{
let mut next = Some(tag.object);
for _ in 0..=256 {
let Some(object) = next.filter(|o| show.insert(*o)) else { break };
next = by_object.get(&object).and_then(|t| t.parent);
}
}
tags.iter().filter(|tag| show.contains(&tag.object)).collect()
}
fn field(ui: &mut Ui, label: &str, value: &mut String, multiline: bool) {
let l = ui.label(label);
if multiline {
ui.add(widgets::block(value).desired_rows(2).desired_width(f32::INFINITY)).labelled_by(l.id);
} else {
ui.add(egui::TextEdit::singleline(value).desired_width(f32::INFINITY)).labelled_by(l.id);
}
}
+68
View File
@@ -1,6 +1,7 @@
//! Prepare for accessibility ▸ Check for accessibility in the real shell (egui_kittest): the
//! options dialog, the results panel, Fix, Skip Rule and the report.
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_engine::a11y::{Rule, Status};
@@ -158,3 +159,70 @@ fn setting_alternate_text_figure_by_figure() {
assert_eq!(f.len(), 1, "the decorative one left the tags");
assert_eq!(f[0].alt.as_deref(), Some("A grey square"));
}
#[test]
fn tags_panel_edits_a_tag_property_and_undoes_it() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("figures.pdf", None, FIGURES.to_vec()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("a11y.tags"));
h.run_steps(2);
assert_eq!(h.state().right, Some(RightPanel::Tags));
h.get_by_label("Collapse tag").click();
h.run_steps(2);
assert!(h.query_by_label("Figure · 7").is_none());
h.get_by_label("Expand tag").click();
h.run_steps(2);
h.get_by_label("Figure · 7").click();
h.run_steps(2);
let title = h.get_by_role_and_label(Role::TextInput, "Title");
title.focus();
title.type_text("First figure");
h.run_steps(1);
h.get_by_label("Save tag properties").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.tags()[1].title.as_deref(), Some("First figure"));
assert_eq!(doc.can_undo(), Some("Change tag properties"));
let search = h.get_by_role_and_label(Role::TextInput, "Find a tag");
search.focus();
search.type_text("Photo");
h.run_steps(2);
h.get_by_label("Document · 6");
h.get_by_label("Photo · 8");
assert!(h.query_by_label("First figure · 7").is_none());
}
#[test]
fn checker_finding_opens_its_structure_tag() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("figures.pdf", None, FIGURES.to_vec()).unwrap();
app
});
h.run_steps(4);
h.state_mut().run_accessibility_check();
h.state_mut().a11y.expanded.insert(Rule::FiguresAltText);
h.run_steps(3);
let finding = h.state().a11y.report.as_ref().unwrap().1.result(Rule::FiguresAltText).unwrap().findings[0].clone();
assert_eq!(finding.object.map(|o| o.num), Some(7));
h.get_by_label(&finding.message).click();
h.run_steps(3);
assert_eq!(h.state().right, Some(RightPanel::Tags));
h.get_by_label("7 0 R");
let title = h.get_by_role_and_label(Role::TextInput, "Title");
title.focus();
title.type_text("Unsaved title");
h.run_steps(1);
h.state_mut().right = Some(RightPanel::Accessibility);
h.run_steps(2);
h.get_by_label(&finding.message).click();
h.run_steps(2);
assert_eq!(h.state().right, Some(RightPanel::Accessibility), "an unfinished tag edit is preserved");
}
+8 -8
View File
@@ -8843,11 +8843,11 @@ title = "Tags panel (new, delete, find, change to artifact)"
area = "M"
tier = "P1"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["a11y.tags"]
tools = ["accessibility_tags"]
evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it", "crates/ui-egui/tests/accessibility.rs::checker_finding_opens_its_structure_tag"]
notes = "The right-side Tags panel lists reachable indirect tags as a collapsible hierarchy, filters by type or title with ancestors, navigates to their page and edits properties. Checker findings linked to indirect tags open the matching tag without losing unsaved edits. Inline tags, adding/deleting tags, artifact marking and finding content remain planned in #982."
[[feature]]
id = "a11y.tag-properties"
@@ -8856,10 +8856,10 @@ area = "M"
tier = "P1"
milestone = "M12"
status = "partial"
commands = []
commands = ["a11y.tags"]
tools = ["accessibility_tags", "accessibility_set_tag_property"]
evidence = ["crates/a11y/src/tests.rs::reachable_tag_properties_round_trip_and_reject_unrelated_objects", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
notes = "The engine and headless tools list reachable indirect tags and edit type, title, actual text, alternate text and language with undo. An on-screen tag properties editor and inline tag dictionary support remain planned with the Tags Panel (#982)."
evidence = ["crates/a11y/src/tests.rs::reachable_tag_properties_round_trip_and_reject_unrelated_objects", "crates/a11y/src/tests.rs::tag_hierarchy_keeps_indirect_parent_through_inline_tags", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"]
notes = "The engine, headless tools and right-side editor list reachable indirect tags with parent and depth and edit type, title, actual text, alternate text and language with undo. Inline tag dictionaries and structural operations remain planned with the Tags Panel (#982)."
[[feature]]
id = "a11y.role-map"