mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 02:58:32 -06:00
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:
@@ -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),
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user