diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts
index 5b460b2e27..99f27f5756 100644
--- a/packages/api/src/files/documents/html.spec.ts
+++ b/packages/api/src/files/documents/html.spec.ts
@@ -373,14 +373,29 @@ describe('Office HTML producers', () => {
expect(csp).toMatch(/form-action 'none'/);
expect(csp).toMatch(/script-src https:\/\/cdn\.jsdelivr\.net 'unsafe-inline'/);
expect(csp).not.toMatch(/unsafe-eval/);
+ /* PPTX must allow blob:-only Web Workers — pptx-preview's
+ * bundled echarts dep spins up workers via blob: URLs for
+ * chart rendering. Without this, the renderer's async
+ * pipeline throws unhandled rejections and the iframe shows
+ * a black screen. */
+ expect(csp).toMatch(/worker-src blob:/);
});
- test('exposes a fallback message that surfaces if the renderer fails to load', async () => {
+ test('exposes a fallback message that surfaces if the renderer fails to load or times out', async () => {
const pptx = await buildPptx([{ title: 'X' }]);
const html = await _internal.pptxToHtmlViaCdn(pptx);
+ // Visible loading state + fallback that swaps in on error.
expect(html).toContain('Loading preview…');
expect(html).toContain('Preview unavailable');
+ // The renderer-not-loaded check.
expect(html).toContain("typeof pptxPreview === 'undefined'");
+ // The unhandledrejection + error listeners — pptx-preview's
+ // bundled deps raise async rejections that don't surface
+ // through the outer Promise.
+ expect(html).toContain("addEventListener('unhandledrejection'");
+ expect(html).toContain("addEventListener('error'");
+ // The 8-second timeout safety net for silent renderer failures.
+ expect(html).toContain('renderer-timeout');
});
test('size-fallback threshold is the documented 350 KB', () => {
diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts
index e073b71ccc..25656d753c 100644
--- a/packages/api/src/files/documents/html.ts
+++ b/packages/api/src/files/documents/html.ts
@@ -972,10 +972,20 @@ const MAX_PPTX_CDN_BINARY_BYTES = 350 * 1024;
* once the renderer paints them.
*/
function buildPptxCdnDocument(base64: string): string {
+ /* PPTX-specific CSP relaxations vs DOCX:
+ * - `worker-src blob:` — pptx-preview's bundled echarts dep spins up
+ * Web Workers via blob: URLs for chart rendering. Without this,
+ * workers default to `default-src 'none'`, get blocked, and
+ * echarts throws an unhandled-rejection deep inside its async
+ * pipeline that we can't catch from the bootstrap script. The
+ * visible symptom is a black iframe with the renderer half-
+ * started. Allowing blob:-only workers is the minimum surface to
+ * unblock rendering without permitting arbitrary worker URLs. */
const csp = [
"default-src 'none'",
"script-src https://cdn.jsdelivr.net 'unsafe-inline'",
"style-src 'unsafe-inline'",
+ 'worker-src blob:',
"img-src 'self' data: blob:",
'font-src data:',
"connect-src 'none'",
@@ -1006,15 +1016,39 @@ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); fon