🩹 fix: PPTX wrap+scale runs after preview, not during streaming

Manual e2e on PR #12934: regenerated PPTX showed "Preview unavailable"
in the iframe. Root cause: the MutationObserver I added in the
previous commit fired during pptx-preview's render and moved slides
out from under the library's references. pptx-preview's async
pipeline raised an unhandled rejection, the iframe's window-level
listener caught it, and the fallback message replaced the partial
render.

Fix: drop the MutationObserver. Apply the wrap+scale ONCE in a
`finalize` step that runs:
  - On `previewer.preview().then` (the happy path)
  - On the 8-second timeout safety net IF the container has children
    (silent-failure path — pptx-preview emitted slides but never
    resolved its outer promise)

To prevent the user from seeing an unscaled flash while pptx-preview
renders into the 960px-wide canvas, the container is set to
`visibility: hidden` at init and only revealed inside `finalize`
after wrap+scale completes.

Resize handling stays via `ResizeObserver` on `document.body`,
installed AFTER the wrap pass so it doesn't fire during the wrap
itself.

Tests: regression assertion now also locks in:
  - `container.style.visibility = 'hidden' / 'visible'` (the flash-
    prevention contract)
  - Absence of MutationObserver (the bug we just removed — must NOT
    creep back in via a future "let's scale during streaming" idea)
This commit is contained in:
Danny Avila 2026-05-04 18:29:26 -04:00
parent 02fa6da8d5
commit 0d0a142ee5
2 changed files with 77 additions and 75 deletions

View file

@ -466,23 +466,33 @@ describe('Office HTML producers', () => {
* 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. */
* panel always fits — manual e2e feedback on PR #12934.
*
* The wrap is applied ONCE, after `previewer.preview` resolves
* (and the post-render container is visible). Wrapping during
* streaming via MutationObserver caused pptx-preview to throw
* — its internal pipeline holds references to the appended
* slides and broke when we moved them under a parent wrap. */
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. */
/* The wrapper class used by the CSS rules. */
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');
/* The wrap function + the scale computation derived from
* `clientWidth / SLIDE_W`. */
expect(html).toContain('wrapSlides');
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');
/* Container is hidden during render and revealed by the
* `finalize` step so the unscaled flash never reaches the
* user. */
expect(html).toContain("container.style.visibility = 'hidden'");
expect(html).toContain("container.style.visibility = 'visible'");
/* ResizeObserver re-fits on panel resize. (No
* MutationObserver — streaming wraps broke pptx-preview.) */
expect(html).toContain('ResizeObserver');
/* Native dimensions cached on the slide so re-fitting on
* resize doesn't measure the already-transformed box. */
expect(html).not.toContain('MutationObserver');
/* Native dimensions cached on the slide dataset so re-fitting
* on resize never measures an already-transformed box. */
expect(html).toContain('lcNativeW');
});
});

View file

@ -1153,99 +1153,91 @@ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); fon
/* 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. */
* width via CSS transform. */
var SLIDE_W = 960, SLIDE_H = 540;
/* Hide the container while pptx-preview renders so the user
* doesnt see the unscaled 960px-wide flash. We reveal once
* wrap+scale has settled. */
container.style.visibility = 'hidden';
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() {
/* Wrap each rendered slide and apply the scale. Called ONCE,
* after pptx-preview is done — wrapping during streaming would
* move slides out from under the librarys references and break
* its internal state. */
function wrapSlides() {
var scale = computeScale();
var children = Array.prototype.slice.call(container.children);
for (var i = 0; i < children.length; i++) {
var slide = children[i];
if (!slide.classList || slide.classList.contains('lc-slide-wrap') || slide.classList.contains('lc-pptx-loading')) {
continue;
}
var nativeW = slide.offsetWidth || SLIDE_W;
var nativeH = slide.offsetHeight || SLIDE_H;
if (slide.dataset) {
slide.dataset.lcNativeW = String(nativeW);
slide.dataset.lcNativeH = String(nativeH);
}
var wrap = document.createElement('div');
wrap.className = 'lc-slide-wrap';
wrap.style.width = (nativeW * scale) + 'px';
wrap.style.height = (nativeH * scale) + 'px';
slide.style.transformOrigin = 'top left';
slide.style.transform = 'scale(' + scale + ')';
container.insertBefore(wrap, slide);
wrap.appendChild(slide);
}
}
/* Re-fit existing wraps on iframe resize (panel drag, window
* resize). Reads the cached native dimensions from the slides
* dataset so we never re-measure an already-scaled box. */
function refit() {
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; }
if (!inner || !inner.dataset) { 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);
}
function finalize() {
wrapSlides();
container.style.visibility = 'visible';
/* Resize observer set up AFTER the wrap so it doesnt fire
* during the wrap pass. */
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(refit).observe(document.body);
} else {
window.addEventListener('resize', refit);
}
}
/* 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);
markSuccess();
}
var result = previewer.preview(bytes.buffer);
if (result && typeof result.then === 'function') {
result.then(function () { fitAll(); markSuccess(); }).catch(function (err) { showFallback(err && err.message); });
result.then(finalize).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. */
/* Safety net: if 8 seconds in the preview promise hasnt resolved,
* accept whatever pptx-preview managed to render and apply the
* wrap+scale to it. Empty container → fallback. */
setTimeout(function () {
if (settled) { return; }
if (container && container.children.length > 0) {
fitAll();
markSuccess();
finalize();
return;
}
showFallback('renderer-timeout');