use crate::content::structure_detection::{BlockRole, TextBlock};
use crate::core::objects::{Dictionary, IndirectRef, Object, PdfName, PdfString, StringFormat};
use crate::document::Document;
use crate::error::PdfResult;
fn lang_string(language: &str) -> Object {
Object::String(PdfString {
bytes: language.as_bytes().to_vec(),
format: StringFormat::Literal,
})
}
pub fn auto_tag_from_blocks(
doc: &mut Document,
blocks: &[TextBlock],
language: &str,
) -> PdfResult<()> {
if blocks.is_empty() {
return Ok(());
}
let mut elem_refs: Vec<Object> = Vec::new();
for (mcid, block) in blocks.iter().enumerate() {
let role_name = match &block.role {
BlockRole::Heading(n) => format!("H{n}"),
BlockRole::Paragraph => "P".to_string(),
BlockRole::ListItem => "LI".to_string(),
BlockRole::Code => "Code".to_string(),
BlockRole::TableCell => "TD".to_string(),
BlockRole::Unknown => "P".to_string(),
};
let actual_text: String = block
.runs
.iter()
.map(|r| r.text.as_str())
.collect::<Vec<_>>()
.join(" ");
let mut elem_dict = Dictionary::new();
elem_dict.insert(
PdfName::new("Type"),
Object::Name(PdfName::new("StructElem")),
);
elem_dict.insert(PdfName::new("S"), Object::Name(PdfName::new(&role_name)));
elem_dict.insert(PdfName::new("K"), Object::Integer(mcid as i64));
elem_dict.insert(
PdfName::new("ActualText"),
Object::String(PdfString {
bytes: actual_text.into_bytes(),
format: StringFormat::Literal,
}),
);
let elem_id = doc.add_object(Object::Dictionary(elem_dict));
elem_refs.push(Object::Reference(IndirectRef::new(elem_id.0, elem_id.1)));
}
let mut doc_elem = Dictionary::new();
doc_elem.insert(
PdfName::new("Type"),
Object::Name(PdfName::new("StructElem")),
);
doc_elem.insert(PdfName::new("S"), Object::Name(PdfName::new("Document")));
doc_elem.insert(PdfName::new("K"), Object::Array(elem_refs.clone()));
doc_elem.insert(PdfName::new("Lang"), lang_string(language));
let doc_elem_id = doc.add_object(Object::Dictionary(doc_elem));
let mut parent_tree = Dictionary::new();
parent_tree.insert(
PdfName::new("Type"),
Object::Name(PdfName::new("NumberTree")),
);
let mut nums: Vec<Object> = Vec::new();
for (i, entry) in elem_refs.iter().enumerate() {
nums.push(Object::Integer(i as i64));
nums.push(entry.clone());
}
parent_tree.insert(PdfName::new("Nums"), Object::Array(nums));
let parent_tree_id = doc.add_object(Object::Dictionary(parent_tree));
let mut struct_root = Dictionary::new();
struct_root.insert(
PdfName::new("Type"),
Object::Name(PdfName::new("StructTreeRoot")),
);
struct_root.insert(
PdfName::new("K"),
Object::Reference(IndirectRef::new(doc_elem_id.0, doc_elem_id.1)),
);
struct_root.insert(
PdfName::new("ParentTree"),
Object::Reference(IndirectRef::new(parent_tree_id.0, parent_tree_id.1)),
);
struct_root.insert(PdfName::new("Lang"), lang_string(language));
let struct_root_id = doc.add_object(Object::Dictionary(struct_root));
let catalog_id = doc.catalog_object_id().unwrap_or((1, 0));
if let Some(Object::Dictionary(catalog)) = doc.get_object_mut(catalog_id) {
catalog.insert(
PdfName::new("StructTreeRoot"),
Object::Reference(IndirectRef::new(struct_root_id.0, struct_root_id.1)),
);
let mut mark_info = Dictionary::new();
mark_info.insert(PdfName::new("Marked"), Object::Boolean(true));
catalog.insert(PdfName::new("MarkInfo"), Object::Dictionary(mark_info));
if catalog.get(&PdfName::new("Lang")).is_none() {
catalog.insert(PdfName::new("Lang"), lang_string(language));
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub struct TagIssue {
pub description: String,
pub suggestion: String,
pub severity: String,
}
pub fn check_tag_quality(
doc: &Document,
blocks: &[TextBlock],
_page_index: usize,
) -> Vec<TagIssue> {
let mut issues = Vec::new();
let tree = match doc.structure_tree() {
Some(t) => t,
None => {
issues.push(TagIssue {
description: "Document has no structure tree (untagged)".to_string(),
suggestion: "Run auto_tag_from_blocks to add tags".to_string(),
severity: "error".to_string(),
});
return issues;
}
};
if tree.lang.is_none() {
issues.push(TagIssue {
description: "No /Lang attribute on structure tree".to_string(),
suggestion: "Set document language (e.g., 'en-US')".to_string(),
severity: "error".to_string(),
});
}
let detected_headings = blocks
.iter()
.filter(|b| matches!(b.role, BlockRole::Heading(_)))
.count();
let tagged_headings = count_tagged_headings(&tree.children);
if detected_headings > 0 && tagged_headings == 0 {
issues.push(TagIssue {
description: format!(
"Detected {detected_headings} headings but structure tree has none"
),
suggestion: "Add heading tags (H1-H6) to the structure tree".to_string(),
severity: "warning".to_string(),
});
}
let missing_alt = count_figures_without_alt(&tree.children);
if missing_alt > 0 {
issues.push(TagIssue {
description: format!("{missing_alt} Figure element(s) missing alt text"),
suggestion: "Add /Alt attribute to Figure structure elements".to_string(),
severity: "error".to_string(),
});
}
let heading_levels = collect_heading_levels(&tree.children);
for window in heading_levels.windows(2) {
if window[1] > window[0] + 1 {
issues.push(TagIssue {
description: format!(
"Heading level skip: H{} followed by H{}",
window[0], window[1]
),
suggestion: "Heading levels should not skip (H1 → H3 without H2)".to_string(),
severity: "warning".to_string(),
});
}
}
issues
}
fn count_tagged_headings(children: &[crate::accessibility::StructElem]) -> usize {
let mut count = 0;
for child in children {
if child.struct_type.starts_with('H') && child.struct_type.len() == 2 {
count += 1;
}
count += count_tagged_headings(&child.children);
}
count
}
fn count_figures_without_alt(children: &[crate::accessibility::StructElem]) -> usize {
let mut count = 0;
for child in children {
if child.struct_type == "Figure" && child.alt_text.is_none() {
count += 1;
}
count += count_figures_without_alt(&child.children);
}
count
}
fn collect_heading_levels(children: &[crate::accessibility::StructElem]) -> Vec<u8> {
let mut levels = Vec::new();
for child in children {
if child.struct_type.starts_with('H') && child.struct_type.len() == 2 {
if let Ok(level) = child.struct_type[1..].parse::<u8>() {
levels.push(level);
}
}
levels.extend(collect_heading_levels(&child.children));
}
levels
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content::analysis::TextRun;
use crate::content::structure_detection::{BlockRole, Rect, TextBlock};
fn make_block(text: &str, role: BlockRole) -> TextBlock {
TextBlock {
runs: vec![TextRun {
text: text.to_string(),
font_name: "Helvetica".to_string(),
font_size: 12.0,
x: 100.0,
y: 700.0,
width: 200.0,
height: 12.0,
color: [0.0, 0.0, 0.0, 1.0],
rendering_mode: 0,
is_bold: false,
is_italic: false,
is_monospaced: false,
}],
role,
bbox: Rect {
x: 100.0,
y: 700.0,
width: 200.0,
height: 12.0,
},
indent: 100.0,
}
}
#[test]
fn auto_tag_creates_structure_tree() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let blocks = vec![
make_block("Chapter Title", BlockRole::Heading(1)),
make_block("Body paragraph", BlockRole::Paragraph),
make_block("1. First item", BlockRole::ListItem),
];
auto_tag_from_blocks(&mut doc, &blocks, "en-US").unwrap();
let tree = doc.structure_tree();
assert!(tree.is_some(), "Should have structure tree after auto-tag");
let tree = tree.unwrap();
let doc_elem = &tree.children[0];
assert_eq!(doc_elem.struct_type, "Document");
assert!(doc_elem.children.len() >= 3, "Should have 3 children");
}
#[test]
fn auto_tag_preserves_heading_levels() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let blocks = vec![
make_block("H1 Title", BlockRole::Heading(1)),
make_block("H2 Section", BlockRole::Heading(2)),
make_block("Body", BlockRole::Paragraph),
];
auto_tag_from_blocks(&mut doc, &blocks, "en").unwrap();
let tree = doc.structure_tree().unwrap();
let doc_elem = &tree.children[0];
assert_eq!(doc_elem.children[0].struct_type, "H1");
assert_eq!(doc_elem.children[1].struct_type, "H2");
assert_eq!(doc_elem.children[2].struct_type, "P");
}
#[test]
fn auto_tag_empty_blocks_is_noop() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
auto_tag_from_blocks(&mut doc, &[], "en").unwrap();
let tree = doc.structure_tree();
assert!(
tree.is_none(),
"Empty blocks should not create structure tree"
);
}
#[test]
fn check_quality_detects_untagged() {
let doc = Document::new();
let issues = check_tag_quality(&doc, &[], 0);
assert!(
issues.iter().any(|i| i.description.contains("untagged")),
"Should detect untagged document"
);
}
#[test]
fn check_quality_detects_missing_language() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let blocks = vec![make_block("Text", BlockRole::Paragraph)];
auto_tag_from_blocks(&mut doc, &blocks, "").unwrap();
let issues = check_tag_quality(&doc, &blocks, 0);
assert!(
issues.is_empty()
|| issues
.iter()
.all(|i| i.severity != "error" || i.description.contains("Lang"))
);
}
#[test]
fn check_quality_detects_heading_mismatch() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let blocks_for_tree = vec![
make_block("Text 1", BlockRole::Paragraph),
make_block("Text 2", BlockRole::Paragraph),
];
auto_tag_from_blocks(&mut doc, &blocks_for_tree, "en").unwrap();
let detected = vec![
make_block("Title", BlockRole::Heading(1)),
make_block("Body", BlockRole::Paragraph),
];
let issues = check_tag_quality(&doc, &detected, 0);
assert!(
issues.iter().any(|i| i.description.contains("heading")),
"Should detect heading mismatch"
);
}
}