From 887e868a467e70c80982f7a5c011a9b2775f9c88 Mon Sep 17 00:00:00 2001 From: Fabian <80212635+FabPei@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:52:54 +0200 Subject: [PATCH] fix: DeckCraft upstream #845 PDF export rasterized every slide as one full-page bitmap, so the background, master and layout graphics a deck repeats were stored once per slide. Draw each slide as its backdrop plus the slide's own shapes; equal backdrops become equal images and the writer stores each once. --- crates/pdf/src/lib.rs | 60 +++++++++++++++++++-------- crates/pdf/src/tests.rs | 89 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 17 deletions(-) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 3faec84d..22dc42d3 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -398,6 +398,31 @@ fn grey(v: u8, opacity: f32) -> Fill { } } +/// Premultiplied RGBA (the renderer's pixels) to the straight RGBA the writer stores. +fn straight(rgba: &mut [u8]) { + for px in rgba.as_chunks_mut::<4>().0 { + let a = px[3] as u32; + if a != 0 && a != 255 { + for c in &mut px[..3] { + *c = (((*c as u32 * 255 + a / 2) / a).min(255)) as 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; + } + px[3] = 255; + } + Image::from_rgba8(img.pixels, img.width, img.height) +} + impl Exporter<'_> { fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Result { if let Some(font) = self.fonts.get(&face.id()) { @@ -415,25 +440,26 @@ impl Exporter<'_> { let k = b.width() / sw; // Raster at the requested dpi relative to the box size on paper. let scale = (b.width() / 72.0 * self.opts.dpi.clamp(36.0, 600.0)) / sw; - let img = deckcraft_render::render_slide(pres, i, &RenderOpts { scale, threads: 4, ..Default::default() }); - if img.width > 0 && img.height > 0 { - let (w, h) = (img.width, img.height); + let ropts = RenderOpts { scale, threads: 4, ..Default::default() }; + // Two rasters per slide: the backdrop (background, master and layout graphics) is the same + // on every slide that shares its layout, so the writer stores it once, and the slide's own + // shapes draw over it (#845). + let back = deckcraft_render::render_slide_part(pres, i, deckcraft_render::SlidePart::Backdrop, &ropts); + let front = deckcraft_render::render_slide_part(pres, i, deckcraft_render::SlidePart::Content, &ropts); + let (w, h) = (back.width, back.height); + if w > 0 + && h > 0 + && 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); // Taken, not copied: a print-resolution slide is ~16 MB. - let mut rgba = img.pixels; - for px in rgba.as_chunks_mut::<4>().0 { - // Composite onto white (slides are normally opaque). - let a = px[3] as u32; - for c in &mut px[..3] { - *c = (*c as u32 + (255 - a)).min(255) as u8; - } - px[3] = 255; - } - let image = Image::from_rgba8(rgba, w, h); - if 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(image, size); - s.pop(); + let mut rgba = front.pixels; + if rgba.as_chunks::<4>().0.iter().any(|px| px[3] != 0) { + straight(&mut rgba); + s.draw_image(Image::from_rgba8(rgba, w, h), size); } + s.pop(); } if self.opts.frame_slides && self.opts.layout != PageLayout::Slides { let p = kurbo::Shape::to_path(&b, 0.1); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index fd858884..2bc5c41a 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -432,6 +432,95 @@ fn master_text_wraps_and_keeps_mixed_formatting() { assert!(export(&p, &PdfOptions { layout: PageLayout::Notes, ..Default::default() }).unwrap().starts_with(b"%PDF")); } +/// Slides that share their layout store their backdrop raster once in the exported PDF, over which +/// each slide's own shapes draw as their own raster (#845). +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn slides_share_one_backdrop_raster_and_keep_their_pixels() { + use deckcraft_model::{Shape, ShapeId, Xfrm}; + use hayro_syntax::object::{Dict, Number, Stream}; + + /// The image XObjects the export stores, one entry per distinct object: the pages that draw + /// it, its filters, color channel, alpha channel (the SMask) and pixel size. + fn images(p: &Presentation) -> Vec<(Vec, Vec, Vec, Option>, (u32, u32))> { + let bytes = export(p, &opts(PageLayout::Slides)).expect("export"); + let pdf = hayro_syntax::Pdf::new(bytes).expect("pdf"); + let mut out: Vec<(String, Vec, Vec, Vec, Option>, (u32, u32))> = Vec::new(); + for (n, page) in pdf.pages().iter().enumerate() { + let resources = page.raw().get::(b"Resources").expect("resources"); + let xobjects = resources.get::(b"XObject").expect("xobjects"); + for key in xobjects.keys() { + let stream = xobjects.get::(key).expect("stream"); + let id = format!("{:?}", stream.obj_id()); + match out.iter_mut().find(|(seen, ..)| *seen == id) { + Some(entry) => entry.1.push(n), + None => { + let dict = stream.dict(); + let alpha = dict.get::(b"SMask").map(|mask| mask.decoded().expect("decoded").into_owned()); + let size = ( + dict.get::(b"Width").expect("width").as_f64() as u32, + dict.get::(b"Height").expect("height").as_f64() as u32, + ); + out.push((id, vec![n], stream.filters().into_vec(), stream.decoded().expect("decoded").into_owned(), alpha, size)); + } + } + } + } + out.into_iter().map(|(_, pages, filters, rgb, alpha, size)| (pages, filters, rgb, alpha, size)).collect() + } + + // The raster scale the exporter uses for one slide per page. + let scale = |p: &Presentation| (p.slide_size.width / 72.0 * opts(PageLayout::Slides).dpi) / p.slide_size.width; + + // Three slides without shapes: every page is pure backdrop and shares one raster, which is + // exactly what the renderer draws. + let mut p = deck(3); + for slide in &mut p.slides { + Arc::make_mut(slide).shapes.clear(); + } + let stored = images(&p); + assert_eq!(stored.len(), 1, "all three pages must share one backdrop raster"); + let (pages, filters, rgb, alpha, size) = stored.into_iter().next().expect("one image"); + assert_eq!(pages, [0, 1, 2]); + assert_eq!(filters, [hayro_syntax::Filter::FlateDecode], "backdrop artwork stays lossless"); + assert!(alpha.is_none(), "the backdrop is opaque and needs no alpha mask"); + let expect = deckcraft_render::render_slide(&p, 0, &RenderOpts { scale: scale(&p), threads: 4, ..Default::default() }); + assert_eq!(size, (expect.width, expect.height)); + let want: Vec = expect.pixels.as_chunks::<4>().0.iter().flat_map(|px| px[..3].iter().copied()).collect(); + assert_eq!(rgb, want, "the shared raster is exactly what the renderer draws"); + + // Slides with different text still share the backdrop: one raster on every page plus one per + // slide's own shapes. + let stored = images(&deck(3)); + assert_eq!(stored.len(), 4, "one shared backdrop plus three content rasters"); + assert_eq!(stored.iter().filter(|(pages, ..)| pages.len() == 3).count(), 1, "the backdrop is the only raster every page draws"); + + // A slide's own shapes draw over the shared backdrop as a second raster, stored as straight + // RGBA (color channel plus alpha mask), never the renderer's premultiplied pixels. + let mut p = deck(1); + Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { + id: ShapeId(9002), + xfrm: Some(Xfrm::new(20.0, 20.0, 400.0, 300.0)), + fill: Some(deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_model::Rgba::rgb(200, 30, 30)))), + ..Default::default() + }); + let stored = images(&p); + assert_eq!(stored.len(), 2, "shared backdrop plus the slide's content"); + let (_, filters, rgb, alpha, _) = stored.into_iter().find(|(_, _, _, alpha, _)| alpha.is_some()).expect("content raster"); + assert_eq!(filters, [hayro_syntax::Filter::FlateDecode]); + let expect = deckcraft_render::render_slide_part( + &p, + 0, + deckcraft_render::SlidePart::Content, + &RenderOpts { scale: scale(&p), threads: 4, ..Default::default() }, + ); + let straight = expect.to_straight(); + let want_rgb: Vec = straight.as_chunks::<4>().0.iter().flat_map(|px| px[..3].iter().copied()).collect(); + 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"); +} + #[test] fn glyph_ranges_follow_shaping_clusters() { // "fl" ligature: its glyph maps to both characters, the rest stay one to one (#164).