M12: a picture an XFA form links is drawn from the PDF's XFAImages name tree

A template's <image href="…"> without inline data is now found in the
catalog's /Names /XFAImages tree, keyed by the URI the template writes
(XFA 3.3 pp. 257-258, "Storing Images Inside PDF"). Only that tree is
looked in; a link is never followed outside the file. The tree walk is
bounded (depth, nodes, names, a seen-set), and so are the bytes read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-10-30 08:59:03 +08:00
1 parent 726ecf2625
commit a50e1491a4
9 files changed
+267 -5

No files matched your search

+39
View File
@@ -739,3 +739,42 @@ if (this.rawValue == "1") {
"##
.to_string()
}
/// A form whose two draws link their pictures instead of embedding them: `.\logo.png`, which the
/// PDF keeps in its `XFAImages` name tree (a [`tiny_png`], under a `Kids` node, as producers
/// split the tree), and `.\missing.png`, which it doesn't (XFA 3.3 pp. 257–258).
pub fn linked_image_shell() -> Vec<u8> {
linked_image_shell_with(b"<< /Kids [7 0 R] >>", br"<< /Limits [(.\\logo.png) (.\\logo.png)] /Names [(.\\logo.png) 8 0 R] >>")
}
/// [`linked_image_shell`] with its own name tree: `root` is object 6 (the tree the catalog names),
/// `kid` object 7, and object 8 the PNG.
pub fn linked_image_shell_with(root: &[u8], kid: &[u8]) -> Vec<u8> {
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<subform name="page1" layout="tb">
<draw name="logo" w="1in" h="1in"><value><image contentType="image/png" href=".\logo.png"/></value></draw>
<draw name="gone" w="1in" h="1in"><value><image contentType="image/png" href=".\missing.png"/></value></draw>
</subform>
</subform>
</template>
</xdp:xdp>
"##;
let mut png = format!("<< /Length {} >>\nstream\n", TINY_PNG.len()).into_bytes();
png.extend_from_slice(TINY_PNG);
png.extend_from_slice(b"\nendstream");
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> /Names << /XFAImages 6 0 R >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_vec(),
b"<< /Length 44 >>\nstream\nBT /F1 12 Tf 72 700 Td (Please wait...) Tj ET\nendstream".to_vec(),
format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(),
root.to_vec(),
kid.to_vec(),
png,
];
assemble(objs)
}
+1 -1
View File
@@ -233,7 +233,7 @@ impl Layouter<'_> {
let inner = rect.inset(&d.common.margin);
match &d.value {
Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para),
Value::Image { content_type, data } => self.draw_image(data, content_type, inner),
Value::Image { content_type, data, .. } => self.draw_image(data, content_type, inner),
Value::Line { slope_up, edge } => {
if !edge.visible() {
return Ok(());
+1 -1
View File
@@ -95,7 +95,7 @@ impl Layouter<'_> {
let box_ = ui.inset(&f.ui_margin);
match (&from_data, &f.value) {
(Some(d), _) => self.draw_image(d, "", box_)?,
(None, Value::Image { content_type, data }) => self.draw_image(data, content_type, box_)?,
(None, Value::Image { content_type, data, .. }) => self.draw_image(data, content_type, box_)?,
_ => {}
}
// Then the field itself, which clicking gives a new picture.
+3 -1
View File
@@ -11,6 +11,7 @@ pub mod data;
pub mod fixtures;
pub mod image;
pub mod layout;
pub mod linked_images;
pub mod model;
pub mod packets;
pub mod parse;
@@ -156,7 +157,8 @@ pub fn render_into(doc: &mut Document) -> Result<Report, XfaError> {
if packets.xdp.trim().is_empty() {
return Err(XfaError::Malformed("the XFA packets hold no template".into()));
}
let (tpl, parse_warnings) = parse(&packets.xdp)?;
let (mut tpl, mut parse_warnings) = parse(&packets.xdp)?;
parse_warnings.extend(linked_images::resolve(&mut tpl, doc));
let data = parse_datasets(&packets.xdp);
let laid = apply_overrides(&tpl, &overrides(doc));
let mut form = layout(&laid, data.as_ref())?;
+158
View File
@@ -0,0 +1,158 @@
//! Pictures a template links instead of embedding (`<image href="…">`). Since XFA 2.6 a form
//! keeps them in its PDF: each image file is a stream holding the file's bytes, named in the
//! `XFAImages` name tree of the catalog's `Names` dictionary by the URI the template writes (XFA
//! 3.3 pp. 257–258, "Storing Images Inside PDF"). Only that tree is looked in: as the spec warns,
//! a form that isn't trusted must not have its links followed outside the file.
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Document, ObjRef, Object};
use crate::model::{Field, Node, Subform, Template, Value};
/// Most nodes of the name tree visited, and most names kept from it.
const MAX_TREE_NODES: usize = 4096;
const MAX_NAMES: usize = 4096;
/// Deepest the name tree is followed.
const MAX_DEPTH: usize = 32;
/// Largest picture taken from the file, as for a picture written into the template.
const MAX_IMAGE: usize = 32 << 20;
/// Most bytes of pictures taken from the file in all.
const MAX_TOTAL: usize = 128 << 20;
/// Deepest the template is walked.
const MAX_TEMPLATE_DEPTH: usize = 256;
/// The `XFAImages` name tree: each name, as text, and the object it names.
fn names(doc: &Document) -> HashMap<String, Object> {
let mut out = HashMap::new();
let Some(root) = doc.root() else { return out };
let catalog = doc.get(root);
let tree =
catalog.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)).and_then(|n| n.as_dict().and_then(|d| d.get(b"XFAImages")).cloned());
let Some(tree) = tree else { return out };
let mut seen: HashSet<ObjRef> = HashSet::new();
let mut visited = 0usize;
let mut stack = vec![(tree, 0usize)];
while let Some((node, depth)) = stack.pop() {
if depth > MAX_DEPTH || visited >= MAX_TREE_NODES || out.len() >= MAX_NAMES {
break;
}
visited += 1;
if let Some(r) = node.as_ref()
&& !seen.insert(r)
{
continue;
}
let node = doc.resolve(&node);
let Some(d) = node.as_dict() else { continue };
if let Some(pairs) = d.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) {
for pair in pairs.chunks(2) {
if let [key, value] = pair
&& let Some(key) = doc.resolve(key).as_string()
{
out.entry(key.to_text()).or_insert_with(|| value.clone());
if out.len() >= MAX_NAMES {
break;
}
}
}
}
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
stack.extend(kids.into_iter().map(|k| (k, depth + 1)));
}
}
out
}
/// Pictures found so far by their link, read from the file once each.
struct Found<'a> {
doc: &'a Document,
names: HashMap<String, Object>,
read: HashMap<String, Option<Vec<u8>>>,
total: usize,
warnings: Vec<String>,
}
impl Found<'_> {
/// The bytes of the picture linked as `href`, when the file holds it.
fn get(&mut self, href: &str) -> Option<Vec<u8>> {
if let Some(data) = self.read.get(href) {
return data.clone();
}
let data = self.load(href);
self.read.insert(href.to_string(), data.clone());
data
}
fn load(&mut self, href: &str) -> Option<Vec<u8>> {
let object = self.names.get(href)?;
let object = self.doc.resolve(object);
let Object::Stream(s) = &*object else { return None };
let data = match s.decoded_within(MAX_IMAGE) {
Ok(d) => d,
Err(_) => {
self.warnings.push(format!("a picture the form keeps in the file ({href}) could not be read; it is left out"));
return None;
}
};
if self.total.saturating_add(data.len()) > MAX_TOTAL {
self.warnings.push("the pictures the form keeps in the file are larger than 128 MB in all; the rest are left out".into());
return None;
}
self.total = self.total.saturating_add(data.len());
Some(data)
}
fn value(&mut self, v: &mut Value) {
if let Value::Image { data, href: Some(href), .. } = v
&& data.is_empty()
&& let Some(found) = self.get(href)
{
*data = found;
}
}
fn field(&mut self, f: &mut Field) {
self.value(&mut f.value);
if let Some(c) = f.caption.as_mut() {
self.value(&mut c.value);
}
}
fn nodes(&mut self, nodes: &mut [Node], depth: usize) {
if depth > MAX_TEMPLATE_DEPTH {
return;
}
for n in nodes {
match n {
Node::Subform(s) => self.subform(s, depth + 1),
Node::Area(a) => self.nodes(&mut a.children, depth + 1),
Node::Field(f) => self.field(f),
Node::Draw(d) => self.value(&mut d.value),
Node::ExclGroup(g) => g.fields.iter_mut().for_each(|f| self.field(f)),
}
}
}
fn subform(&mut self, s: &mut Subform, depth: usize) {
if let Some(set) = s.page_set.as_mut() {
for area in &mut set.areas {
self.nodes(&mut area.items, depth + 1);
}
}
self.nodes(&mut s.children, depth);
}
}
/// Give the pictures `tpl` links to (and doesn't embed) the data `doc` keeps for them. A link
/// the file holds nothing for stays empty. What couldn't be read is returned as warnings.
pub fn resolve(tpl: &mut Template, doc: &Document) -> Vec<String> {
let names = names(doc);
if names.is_empty() {
return Vec::new();
}
let mut found = Found { doc, names, read: HashMap::new(), total: 0, warnings: Vec::new() };
found.subform(&mut tpl.root, 0);
found.warnings.dedup();
found.warnings
}
+3
View File
@@ -598,6 +598,9 @@ pub enum Value {
Image {
content_type: String,
data: Vec<u8>,
/// Where the template links the picture from (`href`), when it does: its data is then
/// found in the PDF ([`crate::linked_images`]).
href: Option<String>,
},
/// A diagonal line through the box: `\` (top-left to bottom-right) or `/`.
Line {
+2 -1
View File
@@ -401,7 +401,8 @@ fn parse_value(n: Option<XmlNode>, warnings: &mut Vec<String>) -> Value {
warnings.push("an image is larger than 32 MB; it is left out".into());
return Value::Empty;
}
return Value::Image { content_type: c.attribute("contentType").unwrap_or("").to_string(), data };
let href = c.attribute("href").map(str::trim).filter(|h| !h.is_empty()).map(str::to_string);
return Value::Image { content_type: c.attribute("contentType").unwrap_or("").to_string(), data, href };
}
"line" => {
return Value::Line { slope_up: c.attribute("slope") == Some("/"), edge: child(c, "edge").map(parse_edge).unwrap_or_default() };
+3 -1
View File
@@ -656,7 +656,9 @@ pub fn template_of(doc: &Document) -> Result<Template, XfaError> {
if packets.xdp.trim().is_empty() {
return Err(XfaError::Malformed("the XFA packets hold no template".into()));
}
crate::parse(&packets.xdp).map(|(t, _)| t)
let (mut tpl, _) = crate::parse(&packets.xdp)?;
crate::linked_images::resolve(&mut tpl, doc);
Ok(tpl)
}
/// Whether the template field at `som` is multiline text without a fixed height.
+57
View File
@@ -4145,3 +4145,60 @@ fn clear_password_values_clears_static_xfa_password_fields() {
let packets = read_packets(&doc).unwrap().unwrap();
assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp);
}
/// A picture the template links (`href`) is drawn from the `XFAImages` name tree the PDF keeps
/// it in (XFA 3.3 pp. 257–258), through the tree's `Kids`. A link the file holds nothing for
/// stays out, as before, and is never looked for outside the file.
#[test]
fn a_linked_picture_is_drawn_from_the_pdfs_xfa_images() {
let mut doc = Document::open(Arc::new(crate::fixtures::linked_image_shell())).unwrap();
let mut tpl = crate::script::template_of(&doc).unwrap();
let mut pictures = Vec::new();
let mut walk = |nodes: &[Node]| {
for n in nodes {
if let Node::Subform(s) = n {
for c in &s.children {
if let Node::Draw(d) = c
&& let Value::Image { data, href, .. } = &d.value
{
pictures.push((href.clone(), data.len()));
}
}
}
}
};
walk(&tpl.root.children);
assert_eq!(pictures, vec![(Some(r".\logo.png".into()), crate::fixtures::tiny_png().len()), (Some(r".\missing.png".into()), 0)]);
assert!(crate::linked_images::resolve(&mut tpl, &doc).is_empty(), "nothing to say of a link the file doesn't hold");
let report = render_into(&mut doc).unwrap();
let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
let images = doc
.object_numbers()
.into_iter()
.filter(
|n| matches!(&*doc.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")),
)
.count();
assert_eq!(images, 2, "the linked PNG and its soft mask");
assert_eq!(report.warnings.iter().filter(|w| w.contains("not a JPEG, PNG")).count(), 1, "only the missing one: {:?}", report.warnings);
}
/// A hostile `XFAImages` tree neither hangs nor panics: `Kids` that lead back to themselves, a
/// name that isn't a string, and a name for something that isn't a stream. The picture is then
/// left out, as a link the file holds nothing for.
#[test]
fn a_hostile_xfa_images_tree_is_walked_once_and_gives_nothing() {
for (root, kid) in [
(&b"<< /Kids [6 0 R 7 0 R] >>"[..], &b"<< /Kids [6 0 R 7 0 R] /Names [42 8 0 R] >>"[..]),
(&b"<< /Names [(.\\logo.png) 7 0 R] >>"[..], &b"<< /Kids [6 0 R] >>"[..]),
(&b"<< /Kids 7 0 R >>"[..], &b"[6 0 R]"[..]),
] {
let mut doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_with(root, kid))).unwrap();
let mut tpl = crate::script::template_of(&doc).unwrap();
assert!(crate::linked_images::resolve(&mut tpl, &doc).is_empty());
let report = render_into(&mut doc).unwrap();
assert!(report.warnings.iter().any(|w| w.contains("not a JPEG, PNG")), "{:?}", report.warnings);
let images = doc.object_numbers().into_iter().filter(|n| matches!(&*doc.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"))).count();
assert_eq!(images, 0, "no picture drawn");
}
}