Speed up PDF export by reusing equal slide backdrops

PDF export rasterises every slide as a backdrop and a content layer and
hands both to the writer. Profiling (gdb stack sampling) showed most of
the main thread outside rendering going to per-pixel work on those
rasters: compositing the backdrop onto white (not vectorised), the
writer hashing every backdrop to deduplicate it, and the straight-alpha
conversion scanning every content pixel.

- Composite the backdrop with saturating adds and skip 16-pixel blocks
  that are already opaque; skip blocks with no partly transparent pixel
  in the straight-alpha conversion. Same formulas, same bytes.
- Keep the last opaque backdrops (up to 32 MiB of pixels) and hand the
  writer the same image again when a slide renders an identical
  backdrop. The writer stored equal backdrops once already; now it also
  skips hashing and compressing the copies. Output is byte-identical.

deckcraft-cli bench, export .pdf step, 15 interleaved runs each on a
16-thread Ryzen (A/A noise +0.9 %):
- sample x4 (32 slides): median 531 -> 322 ms (-39 %), min 529 -> 317 ms
- same deck, every slide a different background: 557 -> 441 ms (-21 %)
- sample (8 slides): 149 -> 103 ms (-31 %)
Exported PDFs are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-27 17:39:31 +01:00
co-authored by Claude Opus 5.5
parent 53e183495b
commit 2f4a6d906a
2 changed files with 110 additions and 12 deletions
+64 -11
View File
@@ -178,7 +178,7 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
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<u32, Font>,
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<u8>, 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) {
+46 -1
View File
@@ -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<u8> = 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<_>>(), [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<u8> = 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<u8> = 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]