diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 96f99b9a9..c7e911fde 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -178,7 +178,7 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Dict { /// than an em from it, starts a new word (the em is the font size times |`Tz`|; see [`new_word`]). /// Text-positioning operators alone don't break words: producers that place every glyph with its /// own `Td` (Chromium) would otherwise read as "C h a p t e r". -fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { +pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let res = sub(doc, page, b"Resources"); let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties")); let mut metrics: HashMap, Metrics> = HashMap::new(); diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 77c08e4d6..ff81cc4c9 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -26,7 +26,7 @@ pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use autotag_fields::tag_form_fields; pub use headings::{Heading, headings}; pub use report::{REPORT_WORDS, report_html}; -pub use tags::{TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tags}; +pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Category { diff --git a/crates/a11y/src/tags.rs b/crates/a11y/src/tags.rs index 926350c3d..565f6620d 100644 --- a/crates/a11y/src/tags.rs +++ b/crates/a11y/src/tags.rs @@ -21,6 +21,14 @@ pub struct TagInfo { pub page: Option, } +/// A marked-content reference directly owned by a structure tag. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TagContentItem { + pub page: Option, + pub mcid: i64, + pub text: String, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TagProperty { Type, @@ -140,6 +148,58 @@ pub fn tags(doc: &Document) -> Vec { .collect() } +/// List marked-content references directly owned by an indirect tag. +pub fn tag_content(doc: &Document, object: ObjRef) -> Result, TagError> { + let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else { return Err(TagError::NotATag(object)) }; + let pages = pdfcraft_model::pages(doc); + let page_index: std::collections::HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let mut marks = Vec::new(); + if let Some(kids) = element.dict.get(b"K") { + direct_marks(doc, kids, element.page, &mut marks); + } + // Each page's content is parsed once, however many of its MCIDs the tag owns. + let mut texts: std::collections::HashMap> = std::collections::HashMap::new(); + Ok(marks + .into_iter() + .map(|(page, mcid)| { + let page = page.and_then(|p| page_index.get(&p).copied()); + let text = page + .and_then(|i| { + let page_texts = match texts.entry(i) { + std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(pages.get(i).map(|p| crate::headings::mcid_texts(doc, &p.dict)).unwrap_or_default()) + } + }; + page_texts.get(&mcid).cloned() + }) + .unwrap_or_default(); + TagContentItem { page, mcid, text } + }) + .collect()) +} + +fn direct_marks(doc: &Document, item: &Object, page: Option, out: &mut Vec<(Option, i64)>) { + match item { + Object::Int(mcid) => out.push((page, *mcid)), + Object::Array(items) => items.iter().for_each(|item| direct_marks(doc, item, page, out)), + Object::Dict(dict) if dict.name(b"Type") == Some(b"MCR") => { + if let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) { + out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); + } + } + Object::Ref(reference) => { + if let Some(dict) = doc.get(*reference).as_dict() + && dict.name(b"Type") == Some(b"MCR") + && let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) + { + out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); + } + } + _ => {} + } +} + /// 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(|e| e.object == object) { diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index a3a14a79b..fcd681efc 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -598,6 +598,16 @@ fn figures_are_listed_with_their_alt_text_and_place() { assert_eq!((finding.object, finding.page, finding.bbox), (Some(f[0].obj), Some(0), f[0].bbox)); } +#[test] +fn tag_content_lists_direct_marked_content() { + let doc = figures_doc(); + let tags = tags(&doc); + let first = tag_content(&doc, tags[1].object).unwrap(); + let second = tag_content(&doc, tags[2].object).unwrap(); + assert_eq!((first[0].page, first[0].mcid, first[0].text.as_str()), (Some(0), 0, "")); + assert_eq!((second[0].page, second[0].mcid, second[0].text.as_str()), (Some(0), 1, "")); +} + #[test] fn sibling_tags_move_in_reading_order_without_changing_content() { let mut doc = figures_doc(); @@ -801,6 +811,15 @@ fn headings_are_found_with_their_levels_titles_and_pages() { ); } +#[test] +fn tag_content_reads_the_text_of_each_marked_content_reference() { + let doc = pdf(&headings_doc(), ""); + let items = |n| tag_content(&doc, ref_(n).unwrap()).unwrap().into_iter().map(|i| (i.page, i.mcid, i.text)).collect::>(); + assert_eq!(items(12), [(Some(0), 0, "Chapter One".to_string())]); + assert_eq!(items(13), [(Some(0), 2, "Sec A".to_string())]); + assert_eq!(items(15), [(Some(1), 1, "AB".to_string())]); +} + /// Helvetica advances (standard 14 metrics, per 1000 em) of the characters the tests below draw. fn helvetica_width(c: char) -> f64 { match c { diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 90d308ea9..1e8d71bd1 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -511,6 +511,16 @@ impl Automation { Ok(json!({ "count": count, "tags": tags, "next": next })) } + pub(crate) fn accessibility_tag_content(&self, a: &Args) -> Result { + let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?; + let generation = + u16::try_from(a.int("generation")?).map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation".into()))?; + let content = self.doc(a)?.tag_content(pdfcraft_cos::ObjRef { num: object, generation }).map_err(failed)?; + Ok( + json!({ "count": content.len(), "content": content.into_iter().map(|item| json!({ "page": item.page.map(|p| p + 1), "mcid": item.mcid, "text": item.text })).collect::>() }), + ) + } + pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result { let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?; if object == 0 { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index b32c22bce..1933c7d0d 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -797,6 +797,7 @@ impl Automation { "accessibility_figures" => self.accessibility_figures(&a)?, "accessibility_set_alt" => self.accessibility_set_alt(&a)?, "accessibility_tags" => self.accessibility_tags(&a)?, + "accessibility_tag_content" => self.accessibility_tag_content(&a)?, "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, "accessibility_move_tag" => self.accessibility_move_tag(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f2bb9611d..cd83fd5b4 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1013,6 +1013,10 @@ pub fn tools() -> Vec { }), &["doc"], )), + t("accessibility_tag_content", "List tag content", "List the marked-content references directly owned by one reachable indirect structure tag, including page, MCID and extracted text.") + .ro() + .cmd("a11y.tags") + .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 } }), &["doc", "object", "generation"])), 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!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 775c82e2a..c85556c23 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -594,6 +594,11 @@ impl Document { self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() } + /// Marked-content items directly owned by one reachable indirect structure tag. + pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { + self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) + } + /// The edit that fixes `rule`, for the rules with an automatic fix: the document language /// (`value` is the language), the title (`value` replaces it; else the current title or the /// file name) and the tab order. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 8c39874d9..92071c249 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -702,7 +702,8 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } RightPanel::Tags => { let tags = tags_state.tags(doc); - let (page, edit) = crate::tags_ui::panel(ui, &t, id, &tags, tags_state, doc.allows_modification()); + let content = tags_state.content(doc); + let (page, edit) = crate::tags_ui::panel(ui, &t, id, &tags, &content, tags_state, doc.allows_modification()); if let Some(page) = page { nav = Some(Nav::Page(page)); } diff --git a/crates/ui-egui/src/tags_ui.rs b/crates/ui-egui/src/tags_ui.rs index b72a6520b..ed019cacb 100644 --- a/crates/ui-egui/src/tags_ui.rs +++ b/crates/ui-egui/src/tags_ui.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use egui::Ui; use pdfcraft_cos::ObjRef; -use pdfcraft_engine::a11y::{TagInfo, TagPlacement, TagProperty}; +use pdfcraft_engine::a11y::{TagContentItem, TagInfo, TagPlacement, TagProperty}; use pdfcraft_engine::{DocId, Edit}; use crate::{icons, theme::Tokens, widgets}; @@ -20,6 +20,9 @@ pub(crate) struct State { /// 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>)>, + /// The selected tag's marked content per (document, edit generation, tag): reading it parses + /// page content, so it isn't repeated every frame either. + content_cache: Option<(DocId, u64, ObjRef, Arc>)>, } #[derive(Clone, Debug, Default)] @@ -64,6 +67,21 @@ impl Draft { } impl State { + /// The selected tag's marked content, read again only after an edit or a new selection. + pub(crate) fn content(&mut self, doc: &pdfcraft_engine::Document) -> Arc> { + let Some(object) = self.selected else { return Arc::default() }; + let generation = doc.edit_generation(); + if let Some((id, g, o, content)) = &self.content_cache + && *id == doc.id + && *g == generation + && *o == object + { + return content.clone(); + } + let content = Arc::new(doc.tag_content(object).unwrap_or_default()); + self.content_cache = Some((doc.id, generation, object, content.clone())); + content + } /// 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(); @@ -102,7 +120,15 @@ impl State { } /// 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) { +pub(crate) fn panel( + ui: &mut Ui, + t: &Tokens, + doc: DocId, + tags: &[TagInfo], + content: &[TagContentItem], + state: &mut State, + can_edit: bool, +) -> (Option, Option) { if state.doc != Some(doc) { state.doc = Some(doc); state.selected = None; @@ -200,6 +226,19 @@ pub(crate) fn panel(ui: &mut Ui, t: &Tokens, doc: DocId, tags: &[TagInfo], state .on_hover_text(tl!("Remove this leaf tag from reading order and mark its direct content as an artifact")) .clicked(); }); + ui.separator(); + for item in content { + let page_label = item.page.map(|p| crate::i18n::fmt(tl!("Go to page {p}"), &[("p", &(p + 1).to_string())])); + let label = match (&page_label, item.text.trim()) { + (Some(page), text) if !text.is_empty() => format!("MCID {} · {page} · {text}", item.mcid), + (Some(page), _) => format!("MCID {} · {page}", item.mcid), + (_, text) if !text.is_empty() => format!("MCID {} · {text}", item.mcid), + _ => format!("MCID {}", item.mcid), + }; + if ui.button(label).clicked() { + page = item.page; + } + } ( page, if save { diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index a37098be8..7d13df6ad 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -327,6 +327,7 @@ fn tags_panel_edits_a_tag_property_and_undoes_it() { h.run_steps(2); h.get_by_label("Figure · 7").click(); h.run_steps(2); + h.get_by_label("MCID 0 · Go to page 1"); let title = h.get_by_role_and_label(Role::TextInput, "Title"); title.focus(); title.type_text("First figure"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 779c81f87..57159d757 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8960,11 +8960,11 @@ title = "Content panel" area = "M" tier = "P2" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["a11y.tags"] +tools = ["accessibility_tag_content"] +evidence = ["crates/a11y/src/tests.rs::tag_content_lists_direct_marked_content", "crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"] +notes = "Selecting a tag in the Tags panel lists its direct marked-content references with MCID, page and extracted text, and selecting an item navigates to the page. The content tree is presented within the Tags panel; editing content objects and arbitrary inline structure nodes remain planned." [[feature]] id = "a11y.create-tag-from-selection"