diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 6a0e6a195b..60c1d2b3f3 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -202,6 +202,10 @@ fn review_html(record: &Value) -> Result { Ok(html) } +/// Tags in one page of `accessibility_tags`, by default and at most. +const TAG_PAGE: usize = 200; +const MAX_TAG_PAGE: usize = 1000; + impl Automation { pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; @@ -441,11 +445,39 @@ impl Automation { self.accessibility_figures(a) } + /// The structure tags, one page at a time (`offset`, `limit`), optionally only some types, pages + /// (1-based) or depths. `count` is how many match; `next` is where the following page starts. pub(crate) fn accessibility_tags(&self, a: &Args) -> Result { - let tags: Vec = self + let offset = usize::try_from(a.opt_int("offset")?.unwrap_or(0)).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; + let limit = a.opt_int("limit")?.unwrap_or(TAG_PAGE as i64); + let limit = usize::try_from(limit) + .ok() + .filter(|l| (1..=MAX_TAG_PAGE).contains(l)) + .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_TAG_PAGE}")))?; + let types: Option> = a.get("types").map(|_| a.strs("types")).transpose()?; + let pages: Option> = a + .opt_ints("pages")? + .map(|p| p.into_iter().map(|n| usize::try_from(n).ok().filter(|n| *n >= 1).map(|n| n - 1)).collect::>>()) + .map(|p| p.ok_or_else(|| ToolError::InvalidArgs("pages must be 1-based page numbers".into()))) + .transpose()?; + let max_depth = a + .opt_int("max_depth")? + .map(|d| usize::try_from(d).map_err(|_| ToolError::InvalidArgs("max_depth must be 0 or more".into()))) + .transpose()?; + let matching: Vec<_> = self .doc(a)? .tags() .into_iter() + .filter(|t| types.as_ref().is_none_or(|ty| ty.contains(&t.kind.as_str()))) + .filter(|t| pages.as_ref().is_none_or(|p| t.page.is_some_and(|page| p.contains(&page)))) + .filter(|t| max_depth.is_none_or(|d| t.depth <= d)) + .collect(); + let count = matching.len(); + let next = offset.checked_add(limit).filter(|n| *n < count); + let tags: Vec = matching + .into_iter() + .skip(offset) + .take(limit) .map(|t| { json!({ "object": t.object.num, "generation": t.object.generation, "type": t.kind, @@ -455,7 +487,7 @@ impl Automation { }) }) .collect(); - Ok(json!({ "count": tags.len(), "tags": tags })) + Ok(json!({ "count": count, "tags": tags, "next": next })) } pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 78476167fc..4b61820271 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -992,10 +992,20 @@ 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, 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.") + 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, one page at a time: count is how many tags match, next the offset of the following page (null at the end). types, pages and max_depth keep only some tags. Inline tag dictionaries have no stable object reference and are not listed.") .ro() .cmd("a11y.tags") - .with(schema(json!({ "doc": doc() }), &["doc"])), + .with(schema( + json!({ + "doc": doc(), + "offset": { "type": "integer", "minimum": 0, "description": "Where the page starts, counted from 0 (the previous page's next)" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Tags in the page (default 200)" }, + "types": { "type": "array", "items": { "type": "string" }, "description": "Only tags of these types, as listed in type (such as H1, H2, Table)" }, + "pages": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "description": "Only tags on these pages" }, + "max_depth": { "type": "integer", "minimum": 0, "description": "Only tags at most this deep (0 = top level)" } + }), + &["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!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 95d7269f96..d8fe3ed4c9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3362,6 +3362,67 @@ fn an_applied_redaction_at_the_end_of_a_line_audits_clean() { assert_eq!(r["count"], 0, "{r}"); } +/// A long tagged document lists its tags a page at a time, and only the types, pages or depths +/// asked for: a 20-page guide has hundreds of tags, more than one tool result should hold. +#[test] +fn structure_tags_are_listed_a_page_at_a_time() { + let dir = workdir("tags-paging"); + // A Document holding, on each of 3 pages, one H1 and 149 P: 451 tags. + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R /MarkInfo << /Marked true >> >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 200 200] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + String::new(), // 6: the root, below + String::new(), // 7: the Document, below + ]; + let mut kids = Vec::new(); + for page in 0..3 { + for i in 0..150 { + let n = objs.len() + 1; + kids.push(format!("{n} 0 R")); + objs.push(format!("<< /S /{} /P 7 0 R /Pg {} 0 R >>", if i == 0 { "H1" } else { "P" }, 3 + page)); + } + } + objs[5] = "<< /Type /StructTreeRoot /K 7 0 R >>".into(); + objs[6] = format!("<< /S /Document /P 6 0 R /K [{}] >>", kids.join(" ")); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("long.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].as_u64().unwrap(); + let first = ok(&mut a, "accessibility_tags", json!({ "doc": doc })); + assert_eq!((first["count"].as_u64(), first["tags"].as_array().map(Vec::len), first["next"].as_u64()), (Some(451), Some(200), Some(200))); + assert_eq!(first["tags"][0]["type"], "Document"); + let last = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "offset": 400, "limit": 100 })); + assert_eq!((last["tags"].as_array().map(Vec::len), last["next"].clone()), (Some(51), Value::Null)); + let headings = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "types": ["H1"] })); + let pages: Vec = headings["tags"].as_array().unwrap().iter().map(|t| t["page"].as_u64().unwrap()).collect(); + assert_eq!((headings["count"].as_u64(), pages), (Some(3), vec![1, 2, 3])); + let page2 = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "pages": [2], "types": ["H1", "P"], "limit": 1000 })); + assert_eq!((page2["count"].as_u64(), page2["next"].clone()), (Some(150), Value::Null)); + assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": doc, "max_depth": 0 }))["count"], 1, "the Document alone"); + for bad in [ + json!({ "doc": doc, "limit": 0 }), + json!({ "doc": doc, "limit": 1001 }), + json!({ "doc": doc, "offset": -1 }), + json!({ "doc": doc, "pages": [0] }), + ] { + assert!(matches!(a.call("accessibility_tags", &bad), Err(ToolError::InvalidArgs(_))), "{bad}"); + } +} + #[test] fn printing_through_tools() { let dir = workdir("print"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f1dc35ff62..61a2fb5afb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8903,8 +8903,8 @@ milestone = "M12" 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." +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", "crates/automation/tests/automation.rs::structure_tags_are_listed_a_page_at_a_time"] +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. accessibility_tags lists them a page at a time (offset, limit, next) and can keep only some types, pages or depths. 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"