Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions crates/xfa/src/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -739,3 +739,47 @@ 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> {
linked_image_shell_holding(root, kid, TINY_PNG)
}

/// [`linked_image_shell_with`], object 8 a stream holding `picture` (a [`tiny_png`] there).
pub fn linked_image_shell_holding(root: &[u8], kid: &[u8], picture: &[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", picture.len()).into_bytes();
png.extend_from_slice(picture);
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)
}
2 changes: 1 addition & 1 deletion crates/xfa/src/layout/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Expand Down
2 changes: 1 addition & 1 deletion crates/xfa/src/layout/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion crates/xfa/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())?;
Expand Down
260 changes: 260 additions & 0 deletions crates/xfa/src/linked_images.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
//! 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;
/// Most name-and-value pairs looked at, a name repeated counted each time (only a new name is
/// kept, so [`MAX_NAMES`] alone doesn't end a `Names` array that repeats one).
const MAX_PAIRS: usize = 4 * MAX_NAMES;
/// Longest name kept, in bytes: a template's link is a short path or URI, and a longer name
/// could only be matched by an equally long link.
const MAX_NAME_LEN: 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. Small in
/// tests, so a test can pass it.
#[cfg(not(test))]
const MAX_IMAGE: usize = 32 << 20;
#[cfg(test)]
const MAX_IMAGE: usize = 512;
/// Most bytes of pictures taken from the file in all, each place a picture is used counted (a
/// picture is held once per place). Small in tests, so a test can reach it.
#[cfg(not(test))]
const MAX_TOTAL: usize = 128 << 20;
#[cfg(test)]
const MAX_TOTAL: usize = 1 << 10;
/// Deepest the template is walked.
const MAX_TEMPLATE_DEPTH: usize = 256;

/// `f` of `o`, a reference followed. A direct object is lent as it is, not copied: a tree's
/// arrays can be as long as the file allows.
fn with_resolved<R>(doc: &Document, o: &Object, f: impl FnOnce(&Object) -> R) -> R {
match o {
Object::Ref(r) => f(&doc.get(*r)),
other => f(other),
}
}

/// The `XFAImages` name tree: each name, as text, and the object it names.
fn names(doc: &Document) -> HashMap<String, Object> {
let mut walk = Walk { doc, out: HashMap::new(), seen: HashSet::new(), visited: 0, pairs: 0 };
let Some(root) = doc.root() else { return walk.out };
let catalog = doc.get(root);
if let Some(names) = catalog.as_dict().and_then(|c| c.get(b"Names")) {
with_resolved(doc, names, |n| {
if let Some(tree) = n.as_dict().and_then(|d| d.get(b"XFAImages")) {
walk.node(tree, 0);
}
});
}
walk.out
}

/// A walk of the name tree, borrowing each node from its resolved owner: nothing of the tree
/// is copied but the names kept, and only the branch being walked is held.
struct Walk<'a> {
doc: &'a Document,
out: HashMap<String, Object>,
seen: HashSet<ObjRef>,
visited: usize,
/// Pairs looked at so far, repeats included.
pairs: usize,
}

impl Walk<'_> {
fn full(&self) -> bool {
self.visited >= MAX_TREE_NODES || self.out.len() >= MAX_NAMES || self.pairs >= MAX_PAIRS
}

fn node(&mut self, node: &Object, depth: usize) {
// Too deep: this branch is left, the others are still walked.
if self.full() || depth > MAX_DEPTH {
return;
}
self.visited += 1;
if let Some(r) = node.as_ref()
&& !self.seen.insert(r)
{
return;
}
let doc = self.doc;
with_resolved(doc, node, |node| {
let Some(d) = node.as_dict() else { return };
if let Some(pairs) = d.get(b"Names") {
with_resolved(doc, pairs, |pairs| self.pairs(pairs));
}
if let Some(kids) = d.get(b"Kids") {
with_resolved(doc, kids, |kids| {
for kid in kids.as_array().map(Vec::as_slice).unwrap_or_default() {
if self.full() {
break;
}
self.node(kid, depth + 1);
}
});
}
});
}

fn pairs(&mut self, pairs: &Object) {
let doc = self.doc;
for pair in pairs.as_array().map(Vec::as_slice).unwrap_or_default().chunks(2) {
// The names and pairs limits only: the node limit ends the walk at the next node, not
// the names of the node it has just come to.
if self.out.len() >= MAX_NAMES || self.pairs >= MAX_PAIRS {
break;
}
self.pairs += 1;
// An over-long name is left before it is turned into text.
if let [key, value] = pair
&& let Some(key) = with_resolved(doc, key, |k| k.as_string().filter(|s| s.bytes.len() <= MAX_NAME_LEN).map(|s| s.to_text()))
{
self.out.entry(key).or_insert_with(|| value.clone());
}
}
}
}

/// 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>>>,
/// Bytes of the pictures in `read`, and of the copies put in places: each at most
/// [`MAX_TOTAL`], however many names the tree holds.
kept: usize,
total: usize,
warnings: Vec<String>,
}

impl Found<'_> {
/// A copy of the picture linked as `href` for one more place, when the file holds it and the
/// pictures kept and the copies so far leave room for it under [`MAX_TOTAL`].
fn get(&mut self, href: &str) -> Option<Vec<u8>> {
if !self.read.contains_key(href) {
// Past the total, a picture not read yet is neither read nor kept.
let data = if self.kept >= MAX_TOTAL { None } else { self.load(href) };
let data = match data {
Some(d) if self.kept.saturating_add(d.len()) > MAX_TOTAL => {
self.over_total();
None
}
Some(d) => {
self.kept = self.kept.saturating_add(d.len());
Some(d)
}
None => {
if self.kept >= MAX_TOTAL && self.names.contains_key(href) {
self.over_total();
}
None
}
};
self.read.insert(href.to_string(), data);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
let len = self.read.get(href).and_then(Option::as_ref).map(Vec::len)?;
if self.total.saturating_add(len) > MAX_TOTAL {
self.over_total();
return None;
}
self.total = self.total.saturating_add(len);
self.read.get(href).cloned().flatten()
}

fn over_total(&mut self) {
self.warnings.push("the pictures the form keeps in the file take too much memory in all; the rest are left out".into());
}

/// Bytes of the pictures read so far and kept for reuse.
#[cfg(test)]
fn kept(&self) -> usize {
self.read.values().flatten().map(Vec::len).sum()
}

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;
}
};
Some(data)
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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> {
resolve_found(tpl, doc).map(|f| f.warnings).unwrap_or_default()
}

fn resolve_found<'a>(tpl: &mut Template, doc: &'a Document) -> Option<Found<'a>> {
let names = names(doc);
if names.is_empty() {
return None;
}
let mut found = Found { doc, names, read: HashMap::new(), kept: 0, total: 0, warnings: Vec::new() };
found.subform(&mut tpl.root, 0);
found.warnings.dedup();
Some(found)
}

/// [`resolve`], and the bytes of pictures it held for reuse at the end.
#[cfg(test)]
pub(crate) fn resolve_keeping(tpl: &mut Template, doc: &Document) -> (Vec<String>, usize) {
resolve_found(tpl, doc).map_or((Vec::new(), 0), |f| (f.warnings.clone(), f.kept()))
}
3 changes: 3 additions & 0 deletions crates/xfa/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion crates/xfa/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() };
Expand Down
4 changes: 3 additions & 1 deletion crates/xfa/src/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading