M12: show structure tag content (#1496)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Earl Co
2026-10-15 03:38:35 -04:00
committed by GitHub
co-authored by Brandon Thomas Claude Opus 5.5
parent 980bd90087
commit b7976ef40e
12 changed files with 150 additions and 10 deletions
+1 -1
View File
@@ -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<i64, String> {
pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
let res = sub(doc, page, b"Resources");
let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties"));
let mut metrics: HashMap<Vec<u8>, Metrics> = HashMap::new();
+1 -1
View File
@@ -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 {
+60
View File
@@ -21,6 +21,14 @@ pub struct TagInfo {
pub page: Option<usize>,
}
/// A marked-content reference directly owned by a structure tag.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TagContentItem {
pub page: Option<usize>,
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<TagInfo> {
.collect()
}
/// List marked-content references directly owned by an indirect tag.
pub fn tag_content(doc: &Document, object: ObjRef) -> Result<Vec<TagContentItem>, 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<ObjRef, usize> = 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<usize, std::collections::HashMap<i64, String>> = 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<ObjRef>, out: &mut Vec<(Option<ObjRef>, 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) {
+19
View File
@@ -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::<Vec<_>>();
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 {
+10
View File
@@ -511,6 +511,16 @@ impl Automation {
Ok(json!({ "count": count, "tags": tags, "next": next }))
}
pub(crate) fn accessibility_tag_content(&self, a: &Args) -> Result<Value> {
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::<Vec<_>>() }),
)
}
pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result<Value> {
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
if object == 0 {
+1
View File
@@ -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)?,
+4
View File
@@ -1013,6 +1013,10 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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 },
+5
View File
@@ -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<Vec<a11y::TagContentItem>, 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.
+2 -1
View File
@@ -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));
}
+41 -2
View File
@@ -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<Vec<TagInfo>>)>,
/// 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<Vec<TagContentItem>>)>,
}
#[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<Vec<TagContentItem>> {
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<Vec<TagInfo>> {
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<usize>, Option<Edit>) {
pub(crate) fn panel(
ui: &mut Ui,
t: &Tokens,
doc: DocId,
tags: &[TagInfo],
content: &[TagContentItem],
state: &mut State,
can_edit: bool,
) -> (Option<usize>, Option<Edit>) {
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 {
+1
View File
@@ -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");
+5 -5
View File
@@ -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"