diff --git a/src/index.html b/src/index.html index f159e88..79e2f6b 100644 --- a/src/index.html +++ b/src/index.html @@ -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 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() {