diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index c23db91f1..095e666c1 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -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, /// 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, } +/// 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 { - 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, Vec) { + /// [`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, Vec) { 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 = 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), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3d347e649..47ad482d4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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})"); } } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 15e1a8548..8d9026600 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -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; diff --git a/crates/engine/tests/it/office_export_images.rs b/crates/engine/tests/it/office_export_images.rs new file mode 100644 index 000000000..4f2ff618f --- /dev/null +++ b/crates/engine/tests/it/office_export_images.rs @@ -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 { + 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"); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index f3030ad58..b4ea9e965 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -35,7 +35,8 @@ pub struct Block { pub struct Image { /// "png" or "jpg". pub ext: &'static str, - pub bytes: Vec, + /// 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"); diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 1a7250d9e..886a4bdbd 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -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");