diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts
index ff963e9af0..ddd445df25 100644
--- a/packages/api/src/files/documents/html.spec.ts
+++ b/packages/api/src/files/documents/html.spec.ts
@@ -478,22 +478,27 @@ describe('Office HTML producers', () => {
/* The wrapper class used by the CSS rules. */
expect(html).toContain('lc-slide-wrap');
/* The wrap function + the per-slide scale function. The scale
- * uses each slide's actual rendered native width (not a
- * constant) so panels wider than 960px still fill — no
- * upscale cap means we never leave whitespace on the sides
- * of the panel. */
+ * uses each slide's actual rendered native width AND height
+ * so panels of any aspect fill correctly — no upscale cap
+ * means we never leave whitespace on either axis. */
expect(html).toContain('wrapSlides');
expect(html).toContain('scaleFor');
expect(html).toContain('availableWidth');
- /* The scale formula divides available width by the slides
- * rendered native width — this is the line that ensures full
- * panel coverage. */
- expect(html).toContain('availableWidth() / (nativeW || SLIDE_W)');
+ expect(html).toContain('availableHeight');
+ /* The scale is `min(width-fit, height-fit)` so each slide is
+ * the largest it can be in the iframe viewport without
+ * overflowing either dimension — manual e2e feedback on
+ * PR #12934. */
+ expect(html).toContain('Math.min(sw, sh)');
/* Negative assertion: the previous version capped the scale
* at 1.0 with `Math.min(1, ...)`, which left whitespace on
* panels wider than 960px. The new code must not reintroduce
* that cap. */
- expect(html).not.toContain('Math.min(1');
+ expect(html).not.toMatch(/Math\.min\(\s*1\s*,/);
+ /* Body fills the iframe viewport vertically and inherits the
+ * dark bg so a short deck (single slide) never reveals a
+ * "white below" gap below the slides. */
+ expect(html).toContain('min-height: 100vh');
/* Container is hidden during render and revealed by the
* `finalize` step so the unscaled flash never reaches the
* user. */
diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts
index caf560e682..11dfa72409 100644
--- a/packages/api/src/files/documents/html.ts
+++ b/packages/api/src/files/documents/html.ts
@@ -1073,7 +1073,14 @@ 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; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 16px; }
+/* Body fills the iframe viewport vertically and inherits the dark bg
+ * so even when total slide content is shorter than the panel (e.g. a
+ * single-slide deck) the artifact card has no visible "white below"
+ * gap — DOCX flows naturally and never has this issue, but PPTX
+ * slides are fixed-aspect and don't grow with the panel. */
+html, body { min-height: 100vh; }
+body { display: flex; flex-direction: column; }
+#lc-render { padding: 16px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 16px; flex: 1 0 auto; }
/* 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
@@ -1168,14 +1175,30 @@ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); fon
var w = (container.clientWidth || window.innerWidth) - 32;
return w > 0 ? w : 600;
}
+ function availableHeight() {
+ /* Viewport height inside the iframe minus the same 16px top+
+ * bottom padding so a single-slide deck can grow up to the full
+ * panel height without spilling. */
+ var h = (window.innerHeight || document.documentElement.clientHeight || 0) - 32;
+ return h > 0 ? h : 400;
+ }
- /* Per-slide scale: divides each slides actual rendered native
- * width into the panel width, so we always fill the panel. No
- * upscale cap — pptx-preview always rasterizes against the init
- * canvas (960×540 here), so text/charts inside the slide are
- * vector or canvas-rendered and stay legible when scaled up. */
- function scaleFor(nativeW) {
- return availableWidth() / (nativeW || SLIDE_W);
+ /* Per-slide scale: the largest factor that lets the slide fit in
+ * the iframe viewport without overflowing either dimension. No
+ * upscale cap — pptx-preview rasterizes against the init canvas
+ * (960×540), text/charts are vector or canvas-rendered, so they
+ * stay legible when scaled up.
+ *
+ * Why the height-aware version (vs. width-only): a tall artifact
+ * panel with a short deck would otherwise leave the slide at
+ * panel_w × (panel_w·9/16) and waste the rest of the viewport
+ * vertically. Min-of-fits makes the slide grow until ONE
+ * dimension is the constraint, exactly the contain-fit pattern
+ * the rest of the iframe expects. */
+ function scaleFor(nativeW, nativeH) {
+ var sw = availableWidth() / (nativeW || SLIDE_W);
+ var sh = availableHeight() / (nativeH || SLIDE_H);
+ return Math.min(sw, sh);
}
/* Wrap each rendered slide and apply the scale. Called ONCE,
@@ -1199,7 +1222,7 @@ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); fon
slide.dataset.lcNativeW = String(nativeW);
slide.dataset.lcNativeH = String(nativeH);
}
- var scale = scaleFor(nativeW);
+ var scale = scaleFor(nativeW, nativeH);
var wrap = document.createElement('div');
wrap.className = 'lc-slide-wrap';
wrap.style.width = (nativeW * scale) + 'px';
@@ -1222,7 +1245,7 @@ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); fon
if (!inner || !inner.dataset) { continue; }
var nativeW = parseFloat(inner.dataset.lcNativeW) || SLIDE_W;
var nativeH = parseFloat(inner.dataset.lcNativeH) || SLIDE_H;
- var scale = scaleFor(nativeW);
+ var scale = scaleFor(nativeW, nativeH);
wrap.style.width = (nativeW * scale) + 'px';
wrap.style.height = (nativeH * scale) + 'px';
inner.style.transform = 'scale(' + scale + ')';