Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
39 changes: 39 additions & 0 deletions crates/xfa/src/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
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
207 changes: 207 additions & 0 deletions crates/xfa/src/linked_images.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
//! 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, 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;

/// 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 visited >= MAX_TREE_NODES || out.len() >= MAX_NAMES {
break;
}
// Too deep: this branch is left, the others are still walked.
if depth > MAX_DEPTH {
continue;
}
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>>>,
/// 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