diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index a57e0cc7..1c354fe6 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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" diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 112c8374..1f203540 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -47,18 +47,28 @@ pub(crate) fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec { t } -/// Indirect structure elements in document order, with their page. -pub(crate) fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option)> { +/// 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, + pub parent: Option, + pub depth: usize, +} + +/// Indirect structure elements in document order, with their page and hierarchy. +pub(crate) fn elements(doc: &Document) -> Vec { 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)> = 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, Option, 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)> { 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
{ let index: HashMap = 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 = 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 = 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 = 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; diff --git a/crates/a11y/src/tags.rs b/crates/a11y/src/tags.rs index 00f178b0..926350c3 100644 --- a/crates/a11y/src/tags.rs +++ b/crates/a11y/src/tags.rs @@ -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, + /// Indentation level among reachable indirect tags. + pub depth: usize, pub kind: String, pub title: Option, pub actual_text: Option, @@ -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 { let page_index: std::collections::HashMap = 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 { /// 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. diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index b890720f..fb515880 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -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::>(), + [(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 }) } diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index f56ed566..40467ef9 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -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), }) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c8725056..53134c90 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -908,8 +908,9 @@ pub fn tools() -> Vec { 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!({ diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f14e913f..df04236a 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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", diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index e47a152f..d0fc66be 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -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")), ], }], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 3b2a3f03..740aab16 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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"), diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 9cfe6bfe..3e3b6b2d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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. diff --git a/crates/ui-egui/src/a11y_ui.rs b/crates/ui-egui/src/a11y_ui.rs index c688c58f..8e21dcbe 100644 --- a/crates/ui-egui/src/a11y_ui.rs +++ b/crates/ui-egui/src/a11y_ui.rs @@ -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, 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) => { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 586ad9d5..d83e1bee 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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]; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index bbff0573..126f5ab1 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -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" => { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 1ab86bce..3eb3786e 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -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 لا يوجد اسم ناتج غير مستخدم diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index dabbb764..fbcce77c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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 няма свободно име за изходния файл diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8fad884b..579695c0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3099069d..399abbc0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e81b61e4..567980f3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 δεν υπάρχει διαθέσιμο όνομα εξόδου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index fa349f77..aaf63e8b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c008269f..00e172ea 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e9ef2cc3..d64a1ccb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 732ddefb..534c9322 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1bc28242..2aeca431 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 使用可能な出力名がありません diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 411804da..38b80238 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -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 бос шығыс атауы жоқ diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d11fe6a9..a1e0a741 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f5a52030..c5df67d7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c023393b..9cd5f124 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 нет свободного имени для выходного файла diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index cf694de6..6b8d1d32 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index ff6c75da..ca89bc67 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -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 ఉపయోగించని అవుట్‌పుట్ పేరు లేదు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 410b5c2a..86c359c8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0815a89d..cb92ce2c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 немає вільної назви для вихідного файлу diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 195720ea..83231dd5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 没有可用的输出文件名 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ed34a881..67d24c58 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 沒有可用的輸出檔名 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4289d30e..5d94e289 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, 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, diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 66509947..5c24862d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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 => { diff --git a/crates/ui-egui/src/tags_ui.rs b/crates/ui-egui/src/tags_ui.rs new file mode 100644 index 00000000..718c3f8b --- /dev/null +++ b/crates/ui-egui/src/tags_ui.rs @@ -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, + selected: Option, + collapsed: BTreeSet, + 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>)>, +} + +#[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 { + 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> { + 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, Option) { + 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 = 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) -> Vec<&'a TagInfo> { + if query.is_empty() { + let mut hidden_after: Option = 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 = 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); + } +} diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index 967dd6a5..5fbccacf 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -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"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 956b6f1b..aab3d5fb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"