Merge land/450 into land/414; XFA pictures are found again by a SHA-256 on their XObject

The image cache was a /PCXfaImages map in the catalog, keyed by DefaultHasher
(not stable across Rust releases) and read back from the file. Each image
XObject now records the SHA-256 of its picture's bytes, and a re-layout finds
the XObjects on the pages it replaces by that hash; nothing goes in the catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-09 13:48:39 -04:00
co-authored by Claude Opus 5.5
parent cae6857b3c
commit 8b45f95b92
4 changed files with 61 additions and 36 deletions
Generated
+1
View File
@@ -3849,6 +3849,7 @@ dependencies = [
"pdfcraft-fonts",
"png 0.18.1",
"roxmltree",
"sha2",
"thiserror 2.0.21",
]
+1
View File
@@ -16,5 +16,6 @@ pdfcraft-fonts = { workspace = true }
roxmltree = "0.20"
base64 = "0.22"
png = "0.18"
sha2 = "0.11"
image = { version = "0.25", default-features = false, features = ["gif"] }
thiserror = { workspace = true }
+57 -35
View File
@@ -32,13 +32,25 @@ fn jpeg_frame(data: &[u8]) -> Option<(u32, u32, u8)> {
jpeg_info(data).map(|(w, h, c, _)| (w, h, c))
}
/// A hash of a picture's bytes, to find its XObject again.
fn content_hash(data: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
data.len().hash(&mut h);
data.hash(&mut h);
h.finish()
/// The SHA-256 of a picture's bytes, to find its XObject again.
fn content_hash(data: &[u8]) -> [u8; 32] {
use sha2::Digest;
sha2::Sha256::digest(data).into()
}
fn hex32(h: &[u8; 32]) -> Vec<u8> {
h.iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect()
}
fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> {
let mut out = [0u8; 32];
if s.len() != 64 {
return None;
}
for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) {
*o = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
}
Some(out)
}
/// Width, height, components and whether an Adobe APP14 segment is present (such CMYK JPEGs
@@ -144,17 +156,20 @@ pub struct Written {
pub warnings: Vec<String>,
}
/// The catalog's record of the image XObjects written for pictures, by content hash, so a
/// re-layout (after every script event) reuses them instead of decoding and embedding the
/// same picture again.
const IMAGES_KEY: &[u8] = b"PCXfaImages";
/// On an image XObject written for a picture: the SHA-256 (hex) of the picture's bytes. A
/// re-layout (after every script event) finds the XObjects on the pages it replaces by this,
/// and reuses them instead of decoding and embedding the same picture again. Nothing is kept
/// in the catalog, and a match is by the picture's own hash, never by a map read from the file.
const IMAGE_SHA_KEY: &[u8] = b"PCXfaSHA256";
/// Most pages searched for earlier pictures.
const MAX_PAGES_SEARCHED: usize = 10_000;
/// Most pictures remembered.
const MAX_REMEMBERED_IMAGES: usize = 1000;
struct Emitter<'a> {
doc: &'a mut Document,
/// Image XObjects by content hash: those from earlier passes, then this one's.
images: HashMap<u64, ObjRef>,
images: HashMap<[u8; 32], ObjRef>,
fields: Vec<ObjRef>,
radio_groups: HashMap<String, (ObjRef, Vec<ObjRef>)>,
/// The on state selected in each radio group, from the data.
@@ -408,16 +423,17 @@ impl Emitter<'_> {
{
return Some(r);
}
let r = self.new_image_xobject(data, content_type)?;
let r = self.new_image_xobject(data, content_type, &hash)?;
if self.images.len() < MAX_REMEMBERED_IMAGES {
self.images.insert(hash, r);
}
Some(r)
}
fn new_image_xobject(&mut self, data: &[u8], content_type: &str) -> Option<ObjRef> {
fn new_image_xobject(&mut self, data: &[u8], content_type: &str, hash: &[u8; 32]) -> Option<ObjRef> {
let what = if content_type.is_empty() { "picture" } else { content_type };
let mut d = Dict::new();
d.set(IMAGE_SHA_KEY.to_vec(), Object::String(PdfString::literal(hex32(hash))));
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
@@ -553,6 +569,30 @@ impl Emitter<'_> {
/// Replace the document's pages with the laid-out form and add its fields to the AcroForm. The
/// XFA packets and everything else in the catalog stay.
/// The image XObjects earlier passes wrote for pictures, by the SHA-256 each records: those on
/// the pages under `pages` (the flat page list [`write_form`] writes).
fn earlier_images(doc: &Document, pages: ObjRef) -> HashMap<[u8; 32], ObjRef> {
let mut out = HashMap::new();
let kids = doc.get(pages).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k))).and_then(|k| k.as_array().cloned()).unwrap_or_default();
for page in kids.iter().filter_map(Object::as_ref).take(MAX_PAGES_SEARCHED) {
let page = doc.get(page);
let Some(resources) = page.as_dict().and_then(|p| p.get(b"Resources")).map(|r| doc.resolve(r)) else { continue };
let Some(xobjects) = resources.as_dict().and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)) else { continue };
for r in xobjects.as_dict().into_iter().flat_map(|x| x.iter()).filter_map(|(_, v)| v.as_ref()) {
if out.len() >= MAX_REMEMBERED_IMAGES {
return out;
}
if let Object::Stream(s) = &*doc.get(r)
&& s.dict.name(b"Subtype") == Some(b"Image")
&& let Some(hash) = s.dict.get(IMAGE_SHA_KEY).and_then(Object::as_string).and_then(|h| parse_hex32(&h.bytes))
{
out.insert(hash, r);
}
}
}
out
}
pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError> {
let catalog_ref = doc.root().ok_or_else(|| XfaError::Malformed("the document has no catalog".into()))?;
let mut catalog = doc.get(catalog_ref).as_dict().cloned().ok_or_else(|| XfaError::Malformed("the catalog is not a dictionary".into()))?;
@@ -568,18 +608,8 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError>
r
}
};
// Pictures embedded by earlier passes, by content hash.
let images: HashMap<u64, ObjRef> = catalog
.get(IMAGES_KEY)
.map(|o| doc.resolve(o))
.and_then(|o| o.as_dict().cloned())
.map(|d| {
d.iter()
.filter_map(|(k, v)| Some((u64::from_str_radix(std::str::from_utf8(k).ok()?, 16).ok()?, v.as_ref()?)))
.take(MAX_REMEMBERED_IMAGES)
.collect()
})
.unwrap_or_default();
// Pictures embedded by earlier passes, from the pages this pass replaces.
let images = earlier_images(doc, pages_ref);
let mut em = Emitter {
doc,
images,
@@ -603,15 +633,7 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError>
}
})?;
}
let Emitter { doc, fields, field_count, warnings, images, .. } = em;
if !images.is_empty() {
let mut d = Dict::new();
for (hash, r) in &images {
d.set(format!("{hash:016x}").into_bytes(), Object::Ref(*r));
}
catalog.set(IMAGES_KEY.to_vec(), Object::Dict(d));
catalog_changed = true;
}
let Emitter { doc, fields, field_count, warnings, .. } = em;
let count = kids.len();
doc.update_dict(pages_ref, |d| {
d.set(b"Type".to_vec(), Object::name("Pages"));
+2 -1
View File
@@ -990,8 +990,9 @@ fn pictures_are_embedded_once_across_relayouts_and_bad_data_pictures_are_reporte
)
.count();
assert_eq!(images, 3, "the PNG, its soft mask and the GIF, however many times the form was laid out");
// No private map in the catalog: earlier pictures are found on the pages, by their hash.
let root = doc.get(doc.root().unwrap());
assert!(root.as_dict().unwrap().contains(b"PCXfaImages"));
assert!(!root.as_dict().unwrap().contains(b"PCXfaImages"));
// Pictures in the data that aren't pictures: a warning naming the field, the template's
// picture shown instead.
for (data, what) in [("Qk0AAAAA", "not a JPEG, PNG or GIF"), ("not base64!", "not base64")] {