diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index a3c03ce2..a508adc9 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -208,7 +208,7 @@ fn bench(args: &[String]) -> Result<(), String> { ("save .deckcraft", time_ms(runs, || save_bytes(&doc, "deckcraft").map(|_| ()).map_err(e))?), ("save .pptx", time_ms(runs, || save_bytes(&doc, "pptx").map(|_| ()).map_err(e))?), ("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?), - ("export .pdf", time_ms(runs.min(3), || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?), + ("export .pdf", time_ms(runs, || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?), ]; let render_total: f64 = per_slide.iter().map(|m| m.0).sum(); let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 1b80ad6a..32da17da 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -178,7 +178,7 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { let pdf = writer.document_mut(); let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0)); - let mut ex = Exporter { pres, opts, fonts: HashMap::new() }; + let mut ex = Exporter { pres, opts, fonts: HashMap::new(), backdrops: Backdrops::default() }; // Page plan: per page, the slides on it with their boxes (page points). let pages = plan_pages(pres, opts, &slides, sw, sh); // Bookmarks and slide → page lookup for internal links. @@ -391,6 +391,7 @@ struct Exporter<'a> { pres: &'a Presentation, opts: &'a PdfOptions, fonts: HashMap, + backdrops: Backdrops, } fn tf(a: Affine) -> Transform { @@ -406,8 +407,19 @@ fn grey(v: u8, opacity: f32) -> Fill { } } -/// Premultiplied RGBA (the renderer's pixels) to the straight RGBA the writer stores. +/// Premultiplied RGBA (the renderer's pixels) to the straight RGBA the writer stores. Runs of +/// pixels that are all fully transparent or fully opaque, most of a slide, are skipped in blocks. fn straight(rgba: &mut [u8]) { + let (blocks, rest) = rgba.as_chunks_mut::<64>(); + for block in blocks { + if block.as_chunks::<4>().0.iter().fold(false, |m, px| m | (px[3].wrapping_sub(1) < 254)) { + straight_px(block); + } + } + straight_px(rest); +} + +fn straight_px(rgba: &mut [u8]) { for px in rgba.as_chunks_mut::<4>().0 { let a = px[3] as u32; if a != 0 && a != 255 { @@ -418,17 +430,58 @@ fn straight(rgba: &mut [u8]) { } } -/// A backdrop raster as one PDF image, composited onto white (slides are normally opaque). Equal -/// backdrops become equal images, which the writer stores once. -fn backdrop(mut img: deckcraft_render::Image) -> Image { - for px in img.pixels.as_chunks_mut::<4>().0 { - let a = px[3] as u32; - for c in &mut px[..3] { - *c = (*c as u32 + (255 - a)).min(255) as u8; +/// A backdrop raster composited onto white (slides are normally opaque, and opaque blocks are skipped). +fn opaque(rgba: &mut [u8]) { + let (blocks, rest) = rgba.as_chunks_mut::<64>(); + for block in blocks { + if !block.as_chunks::<4>().0.iter().fold(true, |m, px| m & (px[3] == 255)) { + opaque_px(block); } + } + opaque_px(rest); +} + +fn opaque_px(rgba: &mut [u8]) { + for px in rgba.as_chunks_mut::<4>().0 { + let k = 255 - px[3]; + px[0] = px[0].saturating_add(k); + px[1] = px[1].saturating_add(k); + px[2] = px[2].saturating_add(k); px[3] = 255; } - Image::from_rgba8(img.pixels, img.width, img.height) +} + +/// Most bytes of opaque backdrop rasters kept to recognise a backdrop seen before. +const BACKDROP_CACHE_BYTES: usize = 32 << 20; + +/// Backdrops already handed to the writer, most recent first, with their pixels. Slides that +/// share a layout render the same backdrop; reusing its image skips hashing and compressing the +/// raster again, and the writer stores it once either way (#845). +#[derive(Default)] +struct Backdrops(Vec<(Vec, u32, u32, Image)>); + +impl Backdrops { + fn image(&mut self, mut img: deckcraft_render::Image) -> Image { + opaque(&mut img.pixels); + let (w, h) = (img.width, img.height); + if let Some(i) = self.0.iter().position(|(px, pw, ph, _)| (*pw, *ph) == (w, h) && *px == img.pixels) { + let hit = self.0.remove(i); + let image = hit.3.clone(); + self.0.insert(0, hit); + return image; + } + if img.pixels.len() > BACKDROP_CACHE_BYTES { + return Image::from_rgba8(img.pixels, w, h); + } + let image = Image::from_rgba8(img.pixels.clone(), w, h); + self.0.insert(0, (img.pixels, w, h, image.clone())); + let mut total = 0; + self.0.retain(|(px, ..)| { + total += px.len(); + total <= BACKDROP_CACHE_BYTES + }); + image + } } /// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters. @@ -470,7 +523,7 @@ impl Exporter<'_> { && let Some(size) = Size::from_wh(b.width() as f32, b.height() as f32) { s.push_transform(&Transform::from_translate(b.x0 as f32, b.y0 as f32)); - s.draw_image(backdrop(back), size); + s.draw_image(self.backdrops.image(back), size); // Taken, not copied: a print-resolution slide is ~16 MB. let mut rgba = front.pixels; if rgba.as_chunks::<4>().0.iter().any(|px| px[3] != 0) { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d135d9f6..dde7a9b7 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -8,7 +8,7 @@ use kurbo::Rect; use super::{ MAX_RASTER_PIXELS, PAPERS, PageLayout, PdfError, PdfOptions, Presentation, export, glyph_text_ranges, handout_boxes, master_text_pieces, - master_texts, notes_frames, notes_slide_box, paper_size, raster_scale, + master_texts, notes_frames, notes_slide_box, opaque, paper_size, raster_scale, straight, }; use deckcraft_model::text::TextBody; @@ -587,6 +587,51 @@ fn slides_share_one_backdrop_raster_and_keep_their_pixels() { let want_alpha: Vec = straight.as_chunks::<4>().0.iter().map(|px| px[3]).collect(); assert_eq!(rgb, want_rgb, "content colors are straight, not premultiplied"); assert_eq!(alpha, Some(want_alpha), "content alpha keeps the shape's coverage"); + + // A backdrop that comes back after another one is still stored once, and every page draws its + // own slide's backdrop. + let mut p = deck(4); + for (i, slide) in p.slides.iter_mut().enumerate() { + let s = Arc::make_mut(slide); + s.shapes.clear(); + let c = if i % 2 == 0 { deckcraft_model::Rgba::rgb(20, 90, 160) } else { deckcraft_model::Rgba::rgb(230, 230, 210) }; + s.background = Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(c)) }); + } + let stored = images(&p); + assert_eq!(stored.iter().map(|(pages, ..)| pages.clone()).collect::>(), [vec![0, 2], vec![1, 3]]); + for (pages, _, rgb, alpha, _) in &stored { + let expect = deckcraft_render::render_slide(&p, pages[0], &RenderOpts { scale: scale(&p), threads: 4, ..Default::default() }); + let want: Vec = expect.pixels.as_chunks::<4>().0.iter().flat_map(|px| px[..3].iter().copied()).collect(); + assert_eq!(rgb, &want, "pages {pages:?} draw their own backdrop"); + assert!(alpha.is_none()); + } +} + +/// The block-skipping pixel conversions give exactly the per-pixel formulas, in blocks that skip +/// and blocks that don't, and in a tail shorter than a block. +#[test] +fn backdrop_and_content_pixels_convert_per_pixel() { + let mut px: Vec = Vec::new(); + for a in 0..=255u8 { + for c in 0..=255u8 { + px.extend([c, c / 2, 255 - c, a]); + } + } + px.extend([0, 0, 0, 0].repeat(32)); + px.extend([9, 8, 7, 255].repeat(32)); + px.extend([1, 2, 3, 128, 4, 5, 6, 0]); + let (mut o, mut s) = (px.clone(), px.clone()); + opaque(&mut o); + straight(&mut s); + for ((p, o), s) in px.chunks(4).zip(o.chunks(4)).zip(s.chunks(4)) { + let a = p[3] as u32; + for k in 0..3 { + assert_eq!(o[k] as u32, (p[k] as u32 + 255 - a).min(255), "{p:?}"); + let want = if a == 0 || a == 255 { p[k] as u32 } else { ((p[k] as u32 * 255 + a / 2) / a).min(255) }; + assert_eq!(s[k] as u32, want, "{p:?}"); + } + assert_eq!((o[3], s[3]), (255, p[3])); + } } #[test]