Files
pdfcraft/crates/a11y/src/content.rs
T
a047c6ba09 chore: resolve SonarCloud maintainability issues (#4356)
* chore: resolve SonarCloud maintainability issues

* fix(packaging): use verify_sha256 helper function

* style: cargo fmt

* fix: restore wildcard imports and fix method-reference type errors

Revert the glob-import expansions (they missed SomIndices and left unused
imports), revert char::is_ascii_digit where the iterator yields char by
value, and pass the content slice to prune_doomed_xobjects as &[Vec<u8>].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep XObject decodability check without binding content in interp

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
2026-10-23 23:28:18 -04:00

467 lines
21 KiB
Rust

//! The Document, Page Content and Forms rules: catalog settings, page content (tagged or
//! artifact, text, images, fonts) and annotations.
//!
//! Every sentence a rule here can say is listed in [`crate::WORDINGS`], so interfaces can say it in
//! their own language; a test in this crate keeps that list complete.
use std::collections::{BTreeMap, BTreeSet, HashSet};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object};
use pdfcraft_model::Page;
use crate::{Finding, Rule};
/// What the page content holds, per checked page.
#[derive(Debug, Default)]
pub(crate) struct Scan {
pub has_text: bool,
pub has_images: bool,
/// Page → painting operations outside tagged or artifact content.
pub untagged: BTreeMap<usize, usize>,
/// Page → fonts used.
pub fonts: BTreeMap<usize, BTreeSet<ObjRef>>,
/// Pages read only in part: content or forms past the page's decode budget are missing.
pub incomplete: BTreeSet<usize>,
}
const MAX_FORM_DEPTH: usize = 12;
impl Scan {
pub fn run(doc: &Document, pages: &[Page], list: &[usize]) -> Scan {
let mut s = Scan::default();
for &p in list {
let Some(page) = pages.get(p) else { continue };
let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut budget = DecodeBudget::page();
let data = crate::alt::page_content_within(doc, &page.dict, &mut budget);
if budget.remaining() == 0 {
s.incomplete.insert(p);
}
let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget };
w.content(&data, &res, false, 0);
}
s
}
}
struct Walk<'a> {
doc: &'a Document,
scan: &'a mut Scan,
page: usize,
forms: HashSet<ObjRef>,
/// Decoded bytes and form entries left for this page.
budget: DecodeBudget,
}
fn bdc_marks(doc: &Document, op: &pdfcraft_content::Op, props: &Dict) -> bool {
op.name(0) == Some(b"Artifact")
|| match op.operands.get(1) {
Some(Object::Dict(d)) => d.contains(b"MCID"),
Some(Object::Name(n)) => props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().map(|d| d.contains(b"MCID"))).unwrap_or(false),
_ => false,
}
}
impl Walk<'_> {
fn sub(&self, res: &Dict, key: &[u8]) -> Dict {
res.get(key).map(|x| self.doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default()
}
/// Walk a content stream; `covered`: already inside tagged or artifact content.
fn content(&mut self, data: &[u8], res: &Dict, covered: bool, depth: usize) {
let (fonts, xobjects, props) = (self.sub(res, b"Font"), self.sub(res, b"XObject"), self.sub(res, b"Properties"));
// Marked-content stack: whether each level is tagged or an artifact.
let mut stack: Vec<bool> = Vec::new();
let mut untagged = 0;
for op in pdfcraft_content::parse(data).ops {
let inside = covered || stack.last().copied().unwrap_or(false);
match op.op.as_slice() {
b"BMC" => stack.push(inside || op.name(0) == Some(b"Artifact")),
b"BDC" => stack.push(inside || bdc_marks(self.doc, &op, &props)),
b"EMC" => {
stack.pop();
}
b"Tj" | b"TJ" | b"'" | b"\"" => {
self.scan.has_text = true;
untagged += usize::from(!inside);
}
b"Tf" => {
if let Some(r) = op.name(0).and_then(|n| fonts.get(n)).and_then(Object::as_ref) {
self.scan.fonts.entry(self.page).or_default().insert(r);
}
}
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"sh" => untagged += usize::from(!inside),
b"BI" => {
self.scan.has_images = true;
untagged += usize::from(!inside);
}
b"Do" => {
let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) else { continue };
let obj = self.doc.get(r);
let Object::Stream(x) = &*obj else { continue };
match x.dict.name(b"Subtype") {
Some(b"Image") => {
self.scan.has_images = true;
untagged += usize::from(!inside);
}
Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) => {
if !self.budget.enter_form() {
self.scan.incomplete.insert(self.page);
continue;
}
let data = match self.budget.decode(x) {
Ok(data) => data,
Err(_) => {
if self.budget.exhausted() {
self.scan.incomplete.insert(self.page);
}
continue;
}
};
self.forms.insert(r);
let inner = x
.dict
.get(b"Resources")
.map(|o| self.doc.resolve(o))
.and_then(|o| o.as_dict().cloned())
.unwrap_or_else(|| res.clone());
self.content(&data, &inner, inside, depth + 1);
self.forms.remove(&r);
}
_ => {}
}
}
_ => {}
}
}
if untagged > 0 {
*self.scan.untagged.entry(self.page).or_default() += untagged;
}
}
}
fn catalog(doc: &Document) -> Dict {
doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default()
}
/// The most references one value may be written through. A chain is unusual but legal, and the
/// render inspector follows them (lopdf's `dereference` has a limit of its own), so the two
/// readers of a flag must agree about one.
const MAX_REFERENCE_CHAIN: usize = 32;
/// The value a chain of indirect references ends at. [`Document::resolve`] follows one reference,
/// and an indirect object's value may itself be a reference (ISO 32000-2 §7.3.10): `/Marked
/// 8 0 R` with object 8 holding `9 0 R` holding `true` is a tagged document, which this rule read
/// as untagged while `doc_info` read it as tagged. Bounded and cycle-safe, since a damaged file
/// can point a reference at itself; a chain longer than that, or one that loops, is no value.
fn resolved(doc: &Document, o: &Object) -> std::sync::Arc<Object> {
let mut seen: HashSet<ObjRef> = HashSet::new();
let mut value = doc.resolve(o);
while let Object::Ref(r) = &*value {
if seen.len() >= MAX_REFERENCE_CHAIN || !seen.insert(*r) {
return std::sync::Arc::new(Object::Null);
}
value = doc.get(*r);
}
value
}
fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
}
fn doc_finding(sentence: &'static str) -> Vec<Finding> {
vec![Finding::plain(None, sentence)]
}
pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &Scan) -> Vec<Finding> {
let cat = catalog(doc);
match rule {
Rule::PermissionFlag => match doc.permissions() {
// The file's own setting, whichever password opened it.
Some(p) if (p.bits >> 9) & 1 == 0 => doc_finding("The security settings don't allow screen readers to read the text"),
_ => Vec::new(),
},
Rule::ImageOnly => {
if !scan.has_text && scan.has_images {
doc_finding("No page has text: the document appears to be images of pages")
} else {
Vec::new()
}
}
Rule::TaggedPdf => {
// `/Marked` may be an indirect reference like any other value -- or a chain of
// them -- so it is followed to the value before the test below, which looks for the
// boolean itself (#3671).
let marked =
cat.get(b"MarkInfo").map(|m| resolved(doc, m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").map(|v| resolved(doc, v))));
let mut out = Vec::new();
if !cat.contains(b"StructTreeRoot") {
out.push(Finding::plain(None, "The document has no tags (structure tree)"));
}
if !matches!(marked.as_deref(), Some(Object::Bool(true))) {
out.push(Finding::plain(None, "The document isn't marked as tagged (MarkInfo Marked)"));
}
out
}
Rule::PrimaryLanguage => {
if text_of(doc, &cat, b"Lang").is_none() {
doc_finding("No document language is set")
} else {
Vec::new()
}
}
Rule::Title => {
let mut out = Vec::new();
let info = doc.trailer().get(b"Info").map(|i| doc.resolve(i)).and_then(|i| i.as_dict().cloned()).unwrap_or_default();
if text_of(doc, &info, b"Title").is_none() {
out.push(Finding::plain(None, "The document has no title"));
}
let shows = cat
.get(b"ViewerPreferences")
.map(|v| doc.resolve(v))
.and_then(|v| v.as_dict().and_then(|d| d.get(b"DisplayDocTitle").map(|x| matches!(&*doc.resolve(x), Object::Bool(true)))))
.unwrap_or(false);
if !shows {
out.push(Finding::plain(None, "The window shows the file name, not the title (Initial View > Show)"));
}
out
}
Rule::Bookmarks => {
let has = cat.get(b"Outlines").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().map(|d| d.contains(b"First"))).unwrap_or(false);
if pages.len() > 20 && !has {
vec![Finding::worded(None, "{n} pages and no bookmarks", vec![("{n}", pages.len().to_string())])]
} else {
Vec::new()
}
}
_ => Vec::new(),
}
}
const MULTIMEDIA: &[&[u8]] = &[b"Screen", b"Movie", b"Sound", b"RichMedia", b"3D"];
fn subtype_name(d: &Dict) -> String {
String::from_utf8_lossy(d.name(b"Subtype").unwrap_or(b"Unknown")).into_owned()
}
/// An annotation's rectangle is already in page user space. Ignore missing, malformed and
/// degenerate rectangles rather than sending the checker to an arbitrary place on the page.
fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> {
let rect = annot.get(b"Rect").map(|r| doc.resolve(r))?;
let values = rect.as_array()?;
let [x0, y0, x1, y1] = values.as_slice() else { return None };
let [x0, y0, x1, y1] = [x0, y0, x1, y1].map(|v| doc.resolve(v).as_f64());
let (x0, y0, x1, y1) = (x0?, y0?, x1?, y1?);
if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) || x0 == x1 || y0 == y1 {
return None;
}
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
}
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec<Finding> {
let mut out = Vec::new();
let annots = |p: &Page| -> Vec<(ObjRef, Dict)> {
let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
a.iter()
.filter_map(|x| x.as_ref().and_then(|r| doc.get(r).as_dict().cloned().map(|d| (r, d))))
.filter(|(_, d)| d.name(b"Subtype") != Some(b"Popup"))
.collect()
};
match rule {
Rule::TaggedContent => {
if !tagged && !scan.untagged.is_empty() {
return doc_finding("The document isn't tagged, so none of its content is");
}
for (p, n) in &scan.untagged {
// One item and several items are separate wordings, so a language can agree the
// noun with the number its own way.
let pattern = if *n == 1 { "Page {p}: {n} untagged content item" } else { "Page {p}: {n} untagged content items" };
out.push(Finding::worded(Some(*p), pattern, vec![("{p}", (p + 1).to_string()), ("{n}", n.to_string())]));
}
}
Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => {
for &p in list {
let Some(page) = pages.get(p) else { continue };
for (_, a) in annots(page) {
let st = a.name(b"Subtype").unwrap_or(b"");
let applies = match rule {
Rule::TaggedFormFields => st == b"Widget",
Rule::TaggedMultimedia => MULTIMEDIA.contains(&st),
_ => st != b"Widget" && !MULTIMEDIA.contains(&st) && st != b"PrinterMark",
};
if applies && !(tagged && a.contains(b"StructParent")) {
// A whole sentence each way, rather than a noun put in front of one: a form
// field and an annotation of some subtype read differently in other
// languages. The subtype itself is the PDF's own name and goes in as it is.
let mut values = vec![("{p}", (p + 1).to_string())];
let pattern = if st == b"Widget" {
"Form field on page {p} isn't tagged"
} else {
values.push(("{}", subtype_name(&a)));
"{} annotation on page {p} isn't tagged"
};
let mut finding = Finding::worded(Some(p), pattern, values);
finding.bbox = annotation_rect(doc, &a);
out.push(finding);
}
}
}
}
Rule::TabOrder => {
for &p in list {
let Some(page) = pages.get(p) else { continue };
if !annots(page).is_empty() && page.dict.name(b"Tabs") != Some(b"S") {
out.push(Finding::worded(Some(p), "Page {p} doesn't tab in structure order", vec![("{p}", (p + 1).to_string())]));
}
}
}
Rule::CharacterEncoding => {
let mut seen = HashSet::new();
for (p, fonts) in &scan.fonts {
for r in fonts {
if seen.insert(*r)
&& let Some(f) = doc.get(*r).as_dict()
&& !maps_to_unicode(doc, f)
{
// A font with no name gets its own whole sentence, so another language can
// word that rather than translate a parenthesis. The name itself is the
// document's own and goes in as it stands.
let page = ("{p}", (p + 1).to_string());
out.push(match f.name(b"BaseFont") {
Some(n) => Finding::worded(
Some(*p),
"Font {} on page {p} doesn't map its characters to Unicode",
vec![("{}", String::from_utf8_lossy(n).into_owned()), page],
),
None => Finding::worded(Some(*p), "Font (unnamed) on page {p} doesn't map its characters to Unicode", vec![page]),
});
}
}
}
}
Rule::FieldDescriptions => {
let mut seen = HashSet::new();
for &p in list {
let Some(page) = pages.get(p) else { continue };
for (r, a) in annots(page) {
if a.name(b"Subtype") != Some(b"Widget") {
continue;
}
// The field is the widget itself when it has a name, else its parent.
let field = if a.contains(b"T") { Some(r) } else { a.get(b"Parent").and_then(Object::as_ref) };
let Some(field) = field else { continue };
if !seen.insert(field) {
continue;
}
let fd = doc.get(field).as_dict().cloned().unwrap_or_default();
if text_of(doc, &fd, b"TU").is_none() {
let mut finding = Finding::worded(
Some(p),
"Field \"{}\" on page {p} has no description (tooltip)",
vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())],
);
finding.bbox = annotation_rect(doc, &a);
out.push(finding);
}
}
}
}
_ => {}
}
out
}
fn full_name(doc: &Document, field: ObjRef) -> String {
let mut parts = Vec::new();
let mut node = Some(field);
for _ in 0..64 {
let Some(r) = node else { break };
let Some(d) = doc.get(r).as_dict().cloned() else { break };
if let Some(t) = text_of(doc, &d, b"T") {
parts.push(t);
}
node = d.get(b"Parent").and_then(Object::as_ref);
}
parts.reverse();
parts.join(".")
}
/// Predefined CJK CMaps (ISO 32000-2 §9.7.5.2, Table 116), excluding Identity-H/V.
/// Test complete names: neither a custom CMap nor a nonexistent vertical variant is predefined.
fn predefined_cjk_cmap(name: &[u8]) -> bool {
if matches!(name, b"H" | b"V" | b"83pv-RKSJ-H" | b"90pv-RKSJ-H" | b"KSCpc-EUC-H" | b"UniAKR-UTF8-H" | b"UniAKR-UTF16-H" | b"UniAKR-UTF32-H") {
return true;
}
let Some(base) = name.strip_suffix(b"-H").or_else(|| name.strip_suffix(b"-V")) else { return false };
matches!(
base,
b"GB-EUC"
| b"GBpc-EUC"
| b"GBK-EUC"
| b"GBKp-EUC"
| b"GBK2K"
| b"UniGB-UCS2"
| b"UniGB-UTF16"
| b"B5pc"
| b"HKscs-B5"
| b"ETen-B5"
| b"ETenms-B5"
| b"CNS-EUC"
| b"UniCNS-UCS2"
| b"UniCNS-UTF16"
| b"90ms-RKSJ"
| b"90msp-RKSJ"
| b"Add-RKSJ"
| b"EUC"
| b"Ext-RKSJ"
| b"UniJIS-UCS2"
| b"UniJIS-UCS2-HW"
| b"UniJIS-UTF16"
| b"KSC-EUC"
| b"KSCms-UHC"
| b"KSCms-UHC-HW"
| b"UniKS-UCS2"
| b"UniKS-UTF16"
)
}
/// Whether text in this font declares a Unicode mapping route (ToUnicode, a standard encoding,
/// a predefined CJK CMap, or an Adobe CJK character collection). This does not validate every glyph.
fn maps_to_unicode(doc: &Document, f: &Dict) -> bool {
if f.contains(b"ToUnicode") {
return true;
}
let enc = f.get(b"Encoding").map(|e| doc.resolve(e));
match f.name(b"Subtype") {
Some(b"Type0") => {
// ISO 32000-2 §9.10.2: either a predefined non-Identity CMap or the descendant's
// Adobe CJK collection supplies the route. Identity-H/V do not rule out the latter.
if matches!(enc.as_deref(), Some(Object::Name(n)) if predefined_cjk_cmap(n)) {
return true;
}
let system = f
.get(b"DescendantFonts")
.map(|d| doc.resolve(d))
.and_then(|d| d.as_array().and_then(|a| a.first().cloned()))
.and_then(|d| doc.resolve(&d).as_dict().cloned())
.and_then(|d| d.get(b"CIDSystemInfo").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()));
let Some(system) = system else { return false };
let registry = system.get(b"Registry").map(|r| doc.resolve(r));
let ordering = system.get(b"Ordering").map(|o| doc.resolve(o));
matches!(registry.as_deref(), Some(Object::String(s)) if s.bytes == b"Adobe")
&& matches!(ordering.as_deref(), Some(Object::String(s)) if matches!(s.bytes.as_slice(), b"Japan1" | b"GB1" | b"CNS1" | b"Korea1" | b"KR"))
}
Some(b"Type3") => enc.is_some(),
_ => match enc.as_deref() {
Some(Object::Name(_) | Object::Dict(_)) => true,
_ => {
// No encoding: the font's built-in one, which is standard unless it is symbolic.
let flags = f.get(b"FontDescriptor").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.int(b"Flags"))).unwrap_or(32);
let base = f.name(b"BaseFont").unwrap_or(b"");
flags & 4 == 0 || matches!(base, b"Symbol" | b"ZapfDingbats")
}
},
}
}