Deck PDF export: page = slide canvas size + wait for images to load

Two bugs in the first deck-PDF cut:

1. PDF page didn't match the slide canvas. The page was sized to the
   PREVIEW_BASE_PX-wide preview (7.5in × 4.22in for a 16:9 slide),
   which gave a tiny PDF page that the browser sometimes scaled
   awkwardly. Now the page is sized to the slide's natural design
   dimensions (wPx / 96 × hPx / 96 inches — e.g. 20 × 11.25in for
   a default 1920×1080 slide), so the PDF is 1:1 with the canvas.
   A CSS `transform: scale(wPx / PREVIEW_BASE_PX)` enlarges the
   PREVIEW_BASE_PX-wide preview to fill the larger page; transform
   scales text proportionally, so font sizes stay correct.

2. Some images weren't rendering. The export called window.print()
   one RAF after attaching the print container — slower remote
   image URLs hadn't finished loading yet, so they missed the
   print snapshot. Now we await every <img>'s load/error event
   (with a 5s per-image cap so one hung URL can't block forever)
   before calling print.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-26 14:31:50 -04:00
1 parent 84ad374f2d
commit f3c697fcb1
1 file changed
+52 -26
+52 -26
View File
@@ -14570,37 +14570,43 @@
// Export the current deck as a multi-page PDF — one page per slide.
// Each slide is rendered via the same `renderCustomArtPreviewDOM` the
// Deck Builder thumbnails use (themed at render time, slide source is
// Deck Builder thumbnails use (themed at render time; slide source is
// never mutated). The browser handles the actual PDF emission via its
// Save-as-PDF print path.
function exportDeckAsPdf() {
//
// Page sizing matches the slide canvas's natural design dimensions:
// a 1920×1080 slide → 20in × 11.25in PDF page at the CSS 96 dpi
// baseline, so the PDF is the slide at 1:1 scale. The preview itself
// is always rendered at PREVIEW_BASE_PX wide internally, so we apply
// a CSS `transform: scale(...)` to enlarge it to fill the page.
async function exportDeckAsPdf() {
const deck = ensureDeck();
const slides = deck?.slides || [];
if (!slides.length) { toastError('No slides to export.'); return; }
// Match the print page to the preview's native size (PREVIEW_BASE_PX
// CSS pixels wide at the slide's aspect). At the browser's 96 dpi
// assumption, PREVIEW_BASE_PX = 720 → 7.5 inches wide, with height
// derived from the first slide's aspect ratio. Mixed-aspect decks
// still print on this page size — non-matching slides letterbox.
const firstSlide = slides[0].slide || {};
const wPx = Math.max(1, Number(firstSlide.width) || 1920);
const hPx = Math.max(1, Number(firstSlide.height) || 1080);
const pageWidthIn = PREVIEW_BASE_PX / 96;
const pageHeightIn = (PREVIEW_BASE_PX * hPx / wPx) / 96;
// CSS uses a 96 dpi baseline, so px / 96 → in.
const pageW = wPx / 96;
const pageH = hPx / 96;
// The preview renders at PREVIEW_BASE_PX wide; scale it up so it
// fills the page exactly.
const previewScale = wPx / PREVIEW_BASE_PX;
const fmt = (n) => Math.round(n * 1000) / 1000;
const container = document.createElement('div');
container.id = 'deckPrintContainer';
// The container is hidden from normal flow (taken out of the body
// visual layout) but stays in the DOM so print can find it.
container.style.cssText = 'position:fixed;left:-99999px;top:0;';
slides.forEach((entry, i) => {
const page = document.createElement('div');
page.className = 'deckPrintPage';
// Page break after each slide except the last — gives N pages for
// N slides without a trailing blank.
if (i < slides.length - 1) page.style.pageBreakAfter = 'always';
// Inner wrapper hosts the transform; the preview itself is
// appended inside it.
const inner = document.createElement('div');
inner.className = 'deckPrintInner';
const composed = composeSlideForDeck(entry, deck.theme || {});
renderCustomArtPreviewDOM(page, composed);
renderCustomArtPreviewDOM(inner, composed);
page.appendChild(inner);
container.appendChild(page);
});
document.body.appendChild(container);
@@ -14608,18 +14614,27 @@
styleEl.id = 'deckPrintStyle';
styleEl.textContent = `
@media print {
@page { size: ${fmt(pageWidthIn)}in ${fmt(pageHeightIn)}in; margin: 0; }
@page { size: ${fmt(pageW)}in ${fmt(pageH)}in; margin: 0; }
body > *:not(#deckPrintContainer) { display: none !important; }
#deckPrintContainer { position: static !important; left: 0 !important; top: 0 !important; }
.deckPrintPage {
width: ${fmt(pageWidthIn)}in; height: ${fmt(pageHeightIn)}in;
display: flex; align-items: center; justify-content: center;
width: ${fmt(pageW)}in; height: ${fmt(pageH)}in;
position: relative;
box-sizing: border-box; padding: 0; margin: 0;
background: #fff; overflow: hidden;
}
/* The preview wrapper inside each page — strip caps and borders so
the slide fills the page exactly. */
.deckPrintPage > div {
/* Scale the natively-PREVIEW_BASE_PX-wide preview up to the
slide's natural design dimensions. transform-origin: top left
keeps everything anchored at (0,0). */
.deckPrintInner {
position: absolute; top: 0; left: 0;
transform: scale(${fmt(previewScale)});
transform-origin: top left;
}
/* The preview's own root div has inline width/aspect-ratio rules
and a border + box-shadow for the editor view — strip the
decoration in print. */
.deckPrintInner > div {
margin: 0 !important; border: 0 !important; box-shadow: none !important;
}
}
@@ -14634,12 +14649,23 @@
window.removeEventListener('afterprint', cleanup);
};
window.addEventListener('afterprint', cleanup);
// One animation frame lets the preview DOM layout settle before the
// print snapshot is taken.
requestAnimationFrame(() => {
try { window.print(); }
catch (e) { cleanup(); toastError('Could not open the print dialog.'); }
});
// Wait for every <img> in the print container to finish loading
// (or error). Without this, slow remote images miss the print
// snapshot and the PDF page renders without them. Cap each wait at
// 5s so one hung URL can't block the export forever.
const imgs = Array.from(container.querySelectorAll('img'));
await Promise.all(imgs.map(img => {
if (img.complete && img.naturalWidth) return Promise.resolve();
return new Promise(resolve => {
let done = false;
const finish = () => { if (done) return; done = true; resolve(); };
img.addEventListener('load', finish);
img.addEventListener('error', finish);
setTimeout(finish, 5000);
});
}));
try { window.print(); }
catch (e) { cleanup(); toastError('Could not open the print dialog.'); }
}
function printWithCurrentFilename() {