diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts index fee038d5cb..5b39cc0695 100644 --- a/packages/api/src/files/documents/html.spec.ts +++ b/packages/api/src/files/documents/html.spec.ts @@ -459,6 +459,32 @@ describe('Office HTML producers', () => { test('size-fallback threshold is the documented 350 KB', () => { expect(_internal.MAX_PPTX_CDN_BINARY_BYTES).toBe(350 * 1024); }); + + test('bootstrap wraps + scales each slide so it fits the iframe width', async () => { + /* pptx-preview emits slides at the init dimensions (960×540 + * by default). Without post-processing, narrow artifact panels + * scroll horizontally and slides spill outside the viewport. + * The bootstrap wraps each rendered slide in `.lc-slide-wrap` + * and applies `transform: scale(panel_width / 960)` so the + * panel always fits — manual e2e feedback on PR #12934. */ + const pptx = await buildPptx([{ title: 'A' }, { title: 'B' }]); + const html = await _internal.pptxToHtmlViaCdn(pptx); + /* The wrapper class — used by the CSS rules that own the + * sized container. */ + expect(html).toContain('lc-slide-wrap'); + /* The bootstrap script's wrap function name + the scale + * computation derived from `clientWidth / SLIDE_W`. */ + expect(html).toContain('wrapAndFit'); + expect(html).toContain('SLIDE_W'); + expect(html).toContain('available / SLIDE_W'); + /* MutationObserver watches for streaming slide insertions; + * ResizeObserver re-fits on panel resize. */ + expect(html).toContain('MutationObserver'); + expect(html).toContain('ResizeObserver'); + /* Native dimensions cached on the slide so re-fitting on + * resize doesn't measure the already-transformed box. */ + expect(html).toContain('lcNativeW'); + }); }); describe('OFFICE_PREVIEW_DISABLE_CDN escape hatch', () => { diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts index d4373d3d57..bd6d6ece88 100644 --- a/packages/api/src/files/documents/html.ts +++ b/packages/api/src/files/documents/html.ts @@ -1073,8 +1073,29 @@ function buildPptxCdnDocument(base64: string): string { :root { color-scheme: light dark; --bg: #ffffff; --fg: #1f2937; --muted: #6b7280; } @media (prefers-color-scheme: dark) { :root { --bg: #1a1a2e; --fg: #e5e7eb; --muted: #9ca3af; } } html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } -#lc-render { padding: 16px; display: flex; flex-direction: column; align-items: center; gap: 12px; } -#lc-render > * { box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); border-radius: 4px; max-width: 100%; } +#lc-render { padding: 16px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 16px; } +/* Each rendered slide is wrapped post-hoc by the bootstrap script in + * an .lc-slide-wrap div with explicit width/height set inline. The + * inner slide keeps its native pixel size and gets transformed via + * a CSS scale so it fits the iframe width without horizontal + * scrolling. Without this, pptx-preview emits slides at whatever + * resolution it computed against (typically 960×540) and overflows + * narrow panels — manual e2e feedback on PR #12934. */ +.lc-slide-wrap { + position: relative; + margin: 0 auto; + overflow: hidden; + background: #ffffff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); + border-radius: 4px; + flex-shrink: 0; +} +.lc-slide-wrap > * { + position: absolute; + top: 0; + left: 0; + transform-origin: top left; +} #lc-fallback { padding: 24px; font-size: 14px; line-height: 1.5; color: var(--muted); text-align: center; } .lc-pptx-loading { display: flex; align-items: center; justify-content: center; height: 60vh; color: var(--muted); font-size: 14px; } @@ -1129,16 +1150,101 @@ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); fon var container = document.getElementById('lc-render'); var loading = document.querySelector('.lc-pptx-loading'); if (loading) { loading.remove(); } - var previewer = pptxPreview.init(container, { width: 960, height: 540 }); + /* pptx-preview renders into a fixed pixel size; we keep it at the + * native 16:9 default so its internal layout calculations are + * right, then post-process to scale each slide to the iframe's + * width via CSS transform. The wrap+transform approach (vs. + * shrinking the init dimensions) preserves font metrics, image + * resolution, and chart positioning that pptx-preview computed + * against the 960×540 canvas. */ + var SLIDE_W = 960, SLIDE_H = 540; + var previewer = pptxPreview.init(container, { width: SLIDE_W, height: SLIDE_H }); + + /* Wrap a single slide element in a sized container and apply the + * scale transform. Native size is cached on the slide's dataset + * so subsequent fits don't measure the already-transformed box. */ + function wrapAndFit(slide, scale) { + if (slide.parentNode && slide.parentNode.classList && slide.parentNode.classList.contains('lc-slide-wrap')) { + return slide.parentNode; + } + var nativeW = parseFloat(slide.dataset.lcNativeW); + var nativeH = parseFloat(slide.dataset.lcNativeH); + if (!nativeW || !nativeH) { + nativeW = slide.offsetWidth || SLIDE_W; + nativeH = slide.offsetHeight || SLIDE_H; + slide.dataset.lcNativeW = String(nativeW); + slide.dataset.lcNativeH = String(nativeH); + } + var wrap = document.createElement('div'); + wrap.className = 'lc-slide-wrap'; + slide.parentNode.insertBefore(wrap, slide); + wrap.appendChild(slide); + wrap.style.width = (nativeW * scale) + 'px'; + wrap.style.height = (nativeH * scale) + 'px'; + slide.style.transform = 'scale(' + scale + ')'; + return wrap; + } + + function computeScale() { + /* clientWidth excludes vertical scrollbar but includes our + * 16px horizontal padding (set via box-sizing:border-box). The + * cap at 1.0 means slides smaller than the panel render at 1:1 + * rather than upscaling and getting blurry. */ + var available = (container.clientWidth || window.innerWidth) - 32; + if (available <= 0) { available = 600; } + return Math.min(1, available / SLIDE_W); + } + + function fitAll() { + var scale = computeScale(); + /* Re-fit any already-wrapped slides (window resize). */ + var wraps = container.querySelectorAll('.lc-slide-wrap'); + for (var i = 0; i < wraps.length; i++) { + var wrap = wraps[i]; + var inner = wrap.firstElementChild; + if (!inner) { continue; } + var nativeW = parseFloat(inner.dataset.lcNativeW) || SLIDE_W; + var nativeH = parseFloat(inner.dataset.lcNativeH) || SLIDE_H; + wrap.style.width = (nativeW * scale) + 'px'; + wrap.style.height = (nativeH * scale) + 'px'; + inner.style.transform = 'scale(' + scale + ')'; + } + /* Wrap any newly-appended slides (pptx-preview emits them + * incrementally as parsing completes). */ + var children = container.children; + for (var j = 0; j < children.length; j++) { + var el = children[j]; + if (el.classList && (el.classList.contains('lc-slide-wrap') || el.classList.contains('lc-pptx-loading'))) { + continue; + } + wrapAndFit(el, scale); + } + } + + /* Watch the container for slide insertions so streaming renders + * get scaled as they arrive (vs. waiting for the whole preview + * promise to settle). */ + if (typeof MutationObserver !== 'undefined') { + new MutationObserver(fitAll).observe(container, { childList: true }); + } + + /* Re-fit on iframe resize (panel drag, window resize). */ + if (typeof ResizeObserver !== 'undefined') { + new ResizeObserver(fitAll).observe(container); + } else { + window.addEventListener('resize', fitAll); + } + var result = previewer.preview(bytes.buffer); if (result && typeof result.then === 'function') { - result.then(markSuccess).catch(function (err) { showFallback(err && err.message); }); + result.then(function () { fitAll(); markSuccess(); }).catch(function (err) { showFallback(err && err.message); }); } /* Final safety net: if 8 seconds in the renderer hasn't appended * anything visible to the container, assume it failed silently. */ setTimeout(function () { if (settled) { return; } if (container && container.children.length > 0) { + fitAll(); markSuccess(); return; }