fix(engine): encode each image once for Office export

Office export encoded and kept a separate copy of an image for every place
a page drew it, so a short content stream repeating Do on one large image
multiplied it into gigabytes before the writers' limits applied. Each image
object is now encoded once and its bytes shared (pdfcraft_export::Image
holds an Arc<[u8]>), distinct pictures share a 512 MiB budget whose
overflow lists the page as truncated, and RTF, which leaves pictures out,
prepares none.
This commit is contained in:
aiongg
2026-10-22 18:02:33 +00:00
parent 90982ba9a8
commit 467c8556ec
6 changed files with 97 additions and 18 deletions
+39 -11
View File
@@ -1,8 +1,11 @@
//! Compare files: the text differences between two open documents, a report, and the
//! differences marked as comments in the newer one.
use std::collections::HashMap;
use std::sync::Arc;
use pdfcraft_cos::ObjRef;
pub use pdfcraft_compare::{Change, Comparison, Kind, Side};
// How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages
// that draw more.
@@ -10,6 +13,10 @@ pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape};
/// Most bytes of encoded pictures one Office export prepares, counting each image object once
/// (the Word and PowerPoint writers keep at most 512 MiB of them as well).
pub const MAX_EXPORT_IMAGE_BYTES: usize = 512 << 20;
/// Acrobat's compare colours: replaced blue, inserted green, deleted red.
pub fn colour(kind: Kind) -> crate::Rgb {
match kind {
@@ -163,23 +170,32 @@ pub struct OfficeExport {
/// The exported file.
pub bytes: Vec<u8>,
/// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them
/// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so
/// the text and images of those forms are missing from `bytes`.
/// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops
/// reading there to stay bounded on hostile files, so the text and images of those forms, or
/// those pictures, are missing from `bytes`.
pub truncated_pages: Vec<usize>,
}
/// An image object prepared for Office export: its file extension and encoded bytes, or why it is
/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded).
type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>;
impl crate::Document {
/// The pages as paragraphs and images (for Word, HTML and RTF export), including what
/// form XObjects draw.
pub fn export_pages(&self) -> Vec<pdfcraft_export::Page> {
self.export_pages_checked().0
self.export_pages_checked(true).0
}
/// [`Self::export_pages`], and the pages whose forms were left partly unread
/// ([`OfficeExport::truncated_pages`]).
fn export_pages_checked(&self) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
/// [`Self::export_pages`], and the pages whose forms were left partly unread or whose
/// pictures went past [`MAX_EXPORT_IMAGE_BYTES`] ([`OfficeExport::truncated_pages`]). Without
/// `images` no picture is prepared.
fn export_pages_checked(&self, images: bool) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) };
let mut truncated = Vec::new();
// Each image object is encoded once and shared by every place that draws it.
let mut encoded: HashMap<ObjRef, PreparedImage> = HashMap::new();
let mut image_bytes = MAX_EXPORT_IMAGE_BYTES;
let pages = self
.info
.pages
@@ -217,13 +233,21 @@ impl crate::Document {
}
})
.collect();
let images = pdfcraft_edit::reading_images(cos, i)
.unwrap_or_default()
let placed = if images { pdfcraft_edit::reading_images(cos, i).unwrap_or_default() } else { Vec::new() };
let mut over_budget = false;
let images = placed
.iter()
.filter_map(|im| {
let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?;
let object = im.object?;
let prepared = encoded.entry(object).or_insert_with(|| {
let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?;
image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?;
Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes)))
});
over_budget |= matches!(prepared, Err(true));
let (ext, bytes) = prepared.clone().ok()?;
Some(pdfcraft_export::Image {
ext: if ext == "jpg" { "jpg" } else { "png" },
ext,
bytes,
rect: visible_rect(im.rect),
size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit],
@@ -232,6 +256,9 @@ impl crate::Document {
})
.collect();
// The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one.
if over_budget {
truncated.push(i);
}
let background = pdfcraft_edit::page_background(cos, i).ok().flatten();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background }
})
@@ -247,7 +274,8 @@ impl crate::Document {
/// [`Self::export_office`], and the pages whose text it had to leave out
/// ([`OfficeExport::truncated_pages`]).
pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport {
let (pages, truncated_pages) = self.export_pages_checked();
// RTF leaves pictures out.
let (pages, truncated_pages) = self.export_pages_checked(format != OfficeFormat::Rtf);
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
let bytes = match format {
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
+1 -1
View File
@@ -6645,7 +6645,7 @@ fn exporting_a_jpeg_logo_to_word_keeps_the_image() {
assert!(jpeg.starts_with(&[0xFF, 0xD8]));
let docx = d.export_office(compare::OfficeFormat::Docx);
assert!(docx.windows(21).any(|w| w == b"word/media/image1.jpg"), "overlap {overlaps}");
assert!(docx.windows(jpeg.len()).any(|w| w == jpeg.as_slice()), "the JPEG is stored unchanged (overlap {overlaps})");
assert!(docx.windows(jpeg.len()).any(|w| w == &jpeg[..]), "the JPEG is stored unchanged (overlap {overlaps})");
}
}
+1
View File
@@ -8,5 +8,6 @@ mod document_fonts;
mod form_rotation;
mod marked_content_isolation;
mod ocr_edit;
mod office_export_images;
mod private_image_resize;
mod private_text_glyphs;
@@ -0,0 +1,43 @@
//! Office export of pages that draw one image many times.
use std::sync::Arc;
use pdfcraft_engine::Session;
fn pdf(objects: &[String]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
/// One image drawn many times is prepared once: every placement shares its encoded bytes.
#[test]
fn an_image_drawn_many_times_is_encoded_once() {
let placements = 500;
let content = "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(placements);
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{40}\u{80}\u{ff}\nendstream".into(),
]);
let mut s = Session::new();
let id = s.open("many.pdf", None, Arc::new(bytes), None).unwrap();
let pages = s.get(id).unwrap().export_pages();
let images = &pages[0].images;
assert_eq!(images.len(), placements);
let first = images[0].bytes.as_ptr();
assert!(!images[0].bytes.is_empty());
assert!(images.iter().all(|im| im.bytes.as_ptr() == first), "each placement holds its own copy of the image");
}
+5 -4
View File
@@ -35,7 +35,8 @@ pub struct Block {
pub struct Image {
/// "png" or "jpg".
pub ext: &'static str,
pub bytes: Vec<u8>,
/// Shared: a picture drawn in many places is held once.
pub bytes: std::sync::Arc<[u8]>,
pub rect: [f64; 4],
/// Width and height before rotation, in points.
pub size: [f64; 2],
@@ -932,7 +933,7 @@ mod tests {
],
images: vec![Image {
ext: "png",
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(),
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(),
rect: [72.0, 450.0, 272.0, 600.0],
size: [200.0, 150.0],
rotation: 0.0,
@@ -1374,7 +1375,7 @@ mod tests {
/// file in the package, with a content type; JPEG pictures too, inline or placed.
#[test]
fn word_pictures_resolve_to_media_in_the_package() {
let jpeg = Image { ext: "jpg", bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], ..page().images[0].clone() };
let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() };
for placed in [false, true] {
let mut p = page();
p.images = vec![jpeg.clone()];
@@ -1401,7 +1402,7 @@ mod tests {
fn word_export_bounds_the_number_of_pictures_and_skips_empty_ones() {
let mut p = page();
let one = p.images[0].clone();
p.images = vec![Image { bytes: Vec::new(), ..one.clone() }];
p.images = vec![Image { bytes: [].as_slice().into(), ..one.clone() }];
p.images.extend(std::iter::repeat_n(one, MAX_MEDIA + 10));
let zip = docx(&[p], "T");
let rels = part(&zip, "word/_rels/document.xml.rels");
+8 -2
View File
@@ -391,7 +391,7 @@ mod tests {
],
images: vec![Image {
ext: "png",
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(),
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(),
rect: [72.0, 150.0, 300.0, 300.0],
size: [228.0, 150.0],
rotation: 0.0,
@@ -619,7 +619,13 @@ mod tests {
blocks.push(huge("inf", f64::INFINITY, 200.0 - k as f64));
}
let images = (0..40)
.map(|k| Image { ext: "png", bytes: vec![0; 4], rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], size: [1.0, 1.0], rotation: 0.0 })
.map(|k| Image {
ext: "png",
bytes: [0; 4].as_slice().into(),
rect: [0.0, -1e300, 1e-300, 1e300 - k as f64],
size: [1.0, 1.0],
rotation: 0.0,
})
.collect();
let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None };
let bytes = pptx(&[p], "Hostile");