mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 12:07:02 -06:00
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:
co-authored by
Claude Opus 5.5
parent
53e183495b
commit
2f4a6d906a
+64
-11
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user