From 2cf0554b4a34e6866e049bfda28b6d3167587120 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 01:05:37 +0800 Subject: [PATCH 1/2] M11: PDF/A verify asks only the fonts something draws with to be embedded A font that only the interactive form's default resources hold (/AcroForm /DR, ISO 32000-1 12.7.2) draws nothing, yet Verify PDF/A reported it as not embedded, so a compliant form failed PDF/A-2b and 3b on clause 6.2.11.4. Only the fonts a resource dictionary names (a page's, a form XObject's or appearance stream's, a pattern's or a Type 3 font's) are now checked; an appearance stream that draws with a /DR font names it in its own /Resources and still has to embed it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 33 +++++++++++++++++++++++++++------ crates/preflight/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..614240c023 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -329,6 +329,24 @@ fn referenced_content(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) out } +/// The fonts the document draws with: those a resource dictionary names (`/Resources`, of a page or +/// a page tree node, a form XObject or an annotation's appearance stream, a pattern or a Type 3 +/// font). PDF/A asks that every font "used for rendering" be embedded (ISO 19005-2, 6.2.11.4.1). A +/// font that only the interactive form's default resources hold (`/AcroForm /DR`, ISO 32000-1 +/// 12.7.2) draws nothing, so it needn't be: an appearance stream that does draw with it names it in +/// its own `/Resources`. +fn drawing_fonts(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + for (_, o) in objs { + let Some(resources) = dict_of(o).and_then(|d| d.get(b"Resources")).map(|r| doc.resolve(r)) else { continue }; + let Some(fonts) = resources.as_dict().and_then(|r| r.get(b"Font")).map(|f| doc.resolve(f)) else { continue }; + if let Some(fonts) = fonts.as_dict() { + out.extend(fonts.iter().filter_map(|(_, f)| f.as_ref())); + } + } + out +} + /// Whether a stream's own dictionary says it holds content: a form XObject, or a stream with a /// `/BBox` and no `/Subtype` (a tiling pattern, or an appearance stream that leaves out /// `/Subtype /Form`). Mesh shadings may carry a `/BBox` too, but their data isn't content. @@ -514,7 +532,8 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } - check_objects(doc, level, &objs, &page_of, &mut issue); + let drawing = drawing_fonts(doc, &objs); + check_objects(doc, level, &objs, &drawing, &page_of, &mut issue); // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); @@ -525,28 +544,29 @@ fn check_objects( doc: &Document, level: Level, objects: &[(ObjRef, std::sync::Arc)], + drawing: &std::collections::HashSet, page_of: &impl Fn(ObjRef) -> Option, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), ) { for (r, o) in objects { let Some(d) = dict_of(o) else { continue }; - check_one_object(doc, level, *r, d, o, page_of, issue); + check_one_object(doc, level, d, o, drawing.contains(r), page_of(*r), issue); } } fn check_one_object( doc: &Document, level: Level, - reference: ObjRef, dict: &Dict, object: &Object, - page_of: &impl Fn(ObjRef) -> Option, + draws: bool, + page: Option, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), ) { let ty = dict.name(b"Type"); let subtype = dict.name(b"Subtype"); check_actions(doc, dict, issue); - if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page_of(reference), issue) { + if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page, issue) { // A forbidden annotation stops every later check for its object, as the scan did before // it was split into helpers: the rest of its own checks and the form field, font, stream // and embedded file below all came after that `continue`. @@ -555,7 +575,8 @@ fn check_one_object( if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { issue("6.5.2", "A form field has additional actions", None, None, true); } - if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() { + // A Type 0 font's descendant is checked through it. + if draws && (ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some()) { check_font(doc, dict, issue); } if let Object::Stream(stream) = object { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..6747fdd62a 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -488,3 +488,31 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +/// PDF/A asks for the fonts "used for rendering" to be embedded (ISO 19005-2, 6.2.11.4.1). A font +/// that only the form's default resources hold (`/AcroForm /DR`, ISO 32000-1 12.7.2) draws +/// nothing, as in veraPDF's 6-4-1-t02-pass-a; one that a widget's appearance stream draws with is +/// named in that stream's `/Resources`, and still has to be embedded. +#[test] +fn only_fonts_something_draws_with_must_be_embedded() { + let fonts = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.11.4").map(|i| i.message).collect() }; + let form = |appearance_fonts: &str| { + build( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 4 0 R /ZaDb 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << >> /Annots [6 0 R] >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /F 4 /Rect [10 10 90 30] /P 3 0 R /AP << /N 7 0 R >> >>", + &format!("<< /Type /XObject /Subtype /Form /BBox [0 0 80 20] /Resources << {appearance_fonts} >> /Length 0 >>\nstream\n\nendstream"), + ], + "", + ) + }; + assert!(fonts(&form("")).is_empty(), "{:?}", fonts(&form(""))); + assert_eq!(fonts(&form("/Font << /Helv 4 0 R >>")), ["The font Helvetica isn't embedded"]); + // A page's own font is checked as before. + assert_eq!(fonts(&messy()), ["The font Helvetica isn't embedded"]); +} From abb409bac63e8f6820b4ccda3ed46374c2002b8a Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 01:25:45 +0800 Subject: [PATCH 2/2] M11: a font named through a chain of references is the font PDF/A checks A /Font entry can point at an object that is nothing but a reference to another (ISO 32000-1 7.3.10). The fonts something draws with were recorded by the entry's own reference, so the font dictionary at the end of such a chain went unchecked. Follow the chain, as resolve does, up to 32 references. (CodeRabbit review on #6172.) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 15 ++++++++++++++- crates/preflight/src/tests.rs | 4 ++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 614240c023..799942fb85 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -341,12 +341,25 @@ fn drawing_fonts(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> s let Some(resources) = dict_of(o).and_then(|d| d.get(b"Resources")).map(|r| doc.resolve(r)) else { continue }; let Some(fonts) = resources.as_dict().and_then(|r| r.get(b"Font")).map(|f| doc.resolve(f)) else { continue }; if let Some(fonts) = fonts.as_dict() { - out.extend(fonts.iter().filter_map(|(_, f)| f.as_ref())); + out.extend(fonts.iter().filter_map(|(_, f)| f.as_ref()).map(|r| font_object(doc, r))); } } out } +/// How many references [`font_object`] follows, as the document's own `resolve` does. +const MAX_REFERENCE_HOPS: usize = 32; + +/// The object a font entry's reference `r` ends at: an object can be nothing but a reference to +/// another (ISO 32000-1 7.3.10), and the font dictionary is the last one in that chain. +fn font_object(doc: &Document, mut r: ObjRef) -> ObjRef { + for _ in 0..MAX_REFERENCE_HOPS { + let Object::Ref(next) = &*doc.get(r) else { break }; + r = *next; + } + r +} + /// Whether a stream's own dictionary says it holds content: a form XObject, or a stream with a /// `/BBox` and no `/Subtype` (a tiling pattern, or an appearance stream that leaves out /// `/Subtype /Form`). Mesh shadings may carry a `/BBox` too, but their data isn't content. diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 6747fdd62a..53f29e677e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -507,12 +507,16 @@ fn only_fonts_something_draws_with_must_be_embedded() { "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /F 4 /Rect [10 10 90 30] /P 3 0 R /AP << /N 7 0 R >> >>", &format!("<< /Type /XObject /Subtype /Form /BBox [0 0 80 20] /Resources << {appearance_fonts} >> /Length 0 >>\nstream\n\nendstream"), + // An object that is nothing but a reference to the Helvetica font (ISO 32000-1 7.3.10). + "4 0 R", ], "", ) }; assert!(fonts(&form("")).is_empty(), "{:?}", fonts(&form(""))); assert_eq!(fonts(&form("/Font << /Helv 4 0 R >>")), ["The font Helvetica isn't embedded"]); + // Named through a reference to it, it is still the font something draws with. + assert_eq!(fonts(&form("/Font << /Helv 8 0 R >>")), ["The font Helvetica isn't embedded"]); // A page's own font is checked as before. assert_eq!(fonts(&messy()), ["The font Helvetica isn't embedded"]); }