From 01fe67010d4dc5fdb2187a837d39b9830775e321 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 12:19:46 +0100 Subject: [PATCH] PDF: look up page images by hash instead of scanning every earlier image Page::image_rgba compared each new picture with all earlier pictures on the page, so N distinct pictures cost N(N-1)/2 comparisons. Keep a page-local hash-to-index map (pixels still compared with ImageData::same) and add a regression test with 3,000 distinct pictures. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/lib.rs | 21 ++++++++++++++++++++- crates/pdf/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 9fcb6bd75..b94aaf6a7 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -434,6 +434,11 @@ pub struct Page { content: String, fonts: [bool; FONT_SLOTS], images: Vec, + /// Indexes into `images`, by content hash; a hash match is confirmed with [`ImageData::same`]. + image_index: HashMap>, + /// Pixel comparisons made while looking up duplicate pictures (test instrumentation). + #[cfg(test)] + image_comparisons: usize, /// Style operators already in effect at the end of `content`. style: Style, /// One entry per accepted [`Page::save`], restored by [`Page::restore`]. @@ -793,9 +798,20 @@ impl Page { } let img = ImageData::new(pw, ph, rgb, alpha); // The same picture drawn again on this page shares one XObject. - let idx = match self.images.iter().position(|i| i.same(&img)) { + let candidates = self.image_index.entry(img.hash).or_default(); + #[cfg(test)] + let comparisons = &mut self.image_comparisons; + let known = candidates.iter().copied().find(|&i| { + #[cfg(test)] + { + *comparisons += 1; + } + self.images.get(i).is_some_and(|o| o.same(&img)) + }); + let idx = match known { Some(i) => i, None => { + candidates.push(self.images.len()); self.images.push(img); self.images.len() - 1 } @@ -934,6 +950,9 @@ impl PdfDoc { content: String::new(), fonts: [false; FONT_SLOTS], images: Vec::new(), + image_index: HashMap::new(), + #[cfg(test)] + image_comparisons: 0, links: Vec::new(), style: Style::default(), saved: Vec::new(), diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d5054610c..b86300ff0 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -571,6 +571,26 @@ fn different_images_are_not_merged() { assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 6, "three distinct pictures, each with a soft mask (alpha 50)"); } +/// O4: many distinct pictures on one page are looked up by hash, not by comparing each new +/// picture with every earlier one (which made dense picture exports quadratic). +#[test] +fn many_distinct_images_on_one_page_are_not_compared_pairwise() { + const N: u32 = 3000; + let mut d = PdfDoc::new(); + let p = d.add_page(612.0, 792.0); + for i in 0..N { + let [a, b, c, _] = i.to_le_bytes(); + p.image_rgba(Rect::new(0.0, 0.0, 1.0, 1.0), &[a, b, c, 255], [1, 1]); + } + // A repeat of the first picture still shares its XObject. + p.image_rgba(Rect::new(5.0, 5.0, 1.0, 1.0), &[0, 0, 0, 255], [1, 1]); + assert_eq!(p.images.len(), N as usize); + assert!(p.content.ends_with("/Im1 Do Q\n"), "the repeated picture reuses /Im1"); + assert!(p.image_comparisons <= 1, "{} pixel comparisons for {N} distinct pictures", p.image_comparisons); + let pdf = d.finish(); + assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), N as usize); +} + /// O4: equal hashes alone never merge images; the pixels are compared. #[test] fn hash_collisions_do_not_merge_images() {