mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 03:27:21 -06:00
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:
co-authored by
Brandon Thomas
Claude Opus 5.5
parent
980bd90087
commit
b7976ef40e
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user