diff --git a/.github/workflows/public-ci.yml b/.github/workflows/public-ci.yml
index 98dfebd1..a8930718 100644
--- a/.github/workflows/public-ci.yml
+++ b/.github/workflows/public-ci.yml
@@ -67,3 +67,6 @@ jobs:
- name: Browser smoke
run: pnpm verify:browser-smoke
+
+ - name: Verify PPTX slideshow
+ run: pnpm verify:pptx-slideshow
diff --git a/apps/viewer-demo/scripts/verify-pptx-slideshow.mjs b/apps/viewer-demo/scripts/verify-pptx-slideshow.mjs
new file mode 100644
index 00000000..fded59a8
--- /dev/null
+++ b/apps/viewer-demo/scripts/verify-pptx-slideshow.mjs
@@ -0,0 +1,377 @@
+import { chromium } from 'playwright'
+import assert from 'node:assert/strict'
+import { createServer } from 'node:http'
+import { readFile } from 'node:fs/promises'
+import { extname, join, normalize, sep } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+// Self-host the built demo so the script needs no external server and can run
+// in CI. Build first: `pnpm build`, then `pnpm verify:pptx-slideshow`.
+const DIST = fileURLToPath(new URL('../dist', import.meta.url))
+
+const MIME = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript',
+ '.mjs': 'text/javascript',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json',
+ '.wasm': 'application/wasm',
+ '.otf': 'font/otf',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
+ '.ttf': 'font/ttf',
+ '.png': 'image/png',
+ '.svg': 'image/svg+xml',
+ '.ico': 'image/x-icon',
+ '.txt': 'text/plain; charset=utf-8',
+ '.xml': 'application/xml',
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+}
+
+const server = createServer(async (req, res) => {
+ try {
+ const url = new URL(req.url, 'http://localhost')
+ let pathname = decodeURIComponent(url.pathname)
+ if (pathname === '/') {
+ pathname = '/index.html'
+ }
+ let filePath = join(DIST, normalize(pathname))
+ if (!extname(pathname)) {
+ filePath = join(DIST, `${pathname}.html`)
+ }
+ if (!filePath.startsWith(DIST + sep)) {
+ res.writeHead(403)
+ res.end('forbidden')
+ return
+ }
+ let body
+ try {
+ body = await readFile(filePath)
+ } catch {
+ // SPA fallback: unknown routes render the app shell.
+ body = await readFile(join(DIST, 'index.html'))
+ }
+ res.writeHead(200, {
+ 'Content-Type': MIME[extname(filePath)] || 'application/octet-stream',
+ 'Cache-Control': 'no-store',
+ })
+ res.end(body)
+ } catch (error) {
+ res.writeHead(500)
+ res.end(String(error))
+ }
+})
+
+await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
+const { port } = server.address()
+const BASE = `http://127.0.0.1:${port}`
+
+const browser = await chromium.launch()
+const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
+const errors = []
+page.on('pageerror', e => errors.push(String(e)))
+
+const deep = sel => page.locator(sel).first()
+
+// The viewer renders inside a shadow root, so plain document.querySelector misses everything.
+const DEEP = `
+ const deepAll = (selector, node = document, out = []) => {
+ out.push(...node.querySelectorAll(selector));
+ for (const el of node.querySelectorAll('*')) {
+ if (el.shadowRoot) deepAll(selector, el.shadowRoot, out);
+ }
+ return out;
+ };
+ const deepOne = (selector) => deepAll(selector)[0] || null;
+`
+
+const readState = () => page.evaluate(`(() => {
+ ${DEEP}
+ const root = deepOne('.flyfish-pptx-presentation')
+ const content = deepOne('.flyfish-pptx-content')
+ const slots = Array.from(content?.querySelectorAll(':scope > .flyfish-pptx-slide-slot') || [])
+ const visible = slots.filter(s => getComputedStyle(s).display !== 'none')
+ return {
+ overlay: Boolean(root),
+ counter: root?.querySelector('.flyfish-pptx-presentation-counter')?.textContent || '',
+ visibleSlides: visible.length,
+ activeNumber: visible[0]?.dataset.slideNumber || '',
+ presenting: content?.classList.contains('is-presenting') || false,
+ transform: content instanceof HTMLElement ? content.style.transform : '',
+ inOverlay: Boolean(root && content && root.contains(content)),
+ }
+})()`)
+
+// ---------------------------------------------------------------------------
+// Part 1: the demo app (renderer wrapper integration)
+// ---------------------------------------------------------------------------
+await page.goto(`${BASE}/?url=${encodeURIComponent('/example/ppt.pptx')}`, { waitUntil: 'load' })
+
+await deep('.flyfish-pptx-slide-slot').waitFor({ state: 'attached', timeout: 60_000 })
+await deep('.pptx-slideshow-button').waitFor({ state: 'visible', timeout: 60_000 })
+console.log('1) deck rendered, slideshow button visible')
+
+const slotCount = await page.locator('.flyfish-pptx-slide-slot').count()
+assert.ok(slotCount > 1, `expected several slides, got ${slotCount}`)
+
+// Shortcuts are scoped to a viewer the user has explicitly activated.
+await deep('.pptx-render-surface').click({ position: { x: 8, y: 8 } })
+await page.keyboard.press('F5')
+await deep('.flyfish-pptx-presentation').waitFor({ state: 'visible', timeout: 10_000 })
+console.log('2) F5 opened the slideshow overlay')
+
+let state = await readState()
+assert.equal(state.presenting, true, 'content should carry is-presenting')
+assert.equal(state.visibleSlides, 1, `exactly one slide should be visible, got ${state.visibleSlides}`)
+assert.equal(state.inOverlay, true, 'the scale box should have moved into the overlay')
+assert.match(state.counter, /^1 \/ \d+$/, `unexpected counter ${state.counter}`)
+assert.match(state.transform, /scale\(/, 'the active slide should be scaled to fit')
+console.log(`3) one slide shown, counter "${state.counter}", scaled: ${state.transform}`)
+
+await page.keyboard.press('ArrowRight')
+await page.waitForTimeout(250)
+state = await readState()
+assert.equal(state.activeNumber, '2', `ArrowRight should advance, active=${state.activeNumber}`)
+assert.equal(state.visibleSlides, 1, 'still exactly one slide after advancing')
+console.log(`4) ArrowRight advanced to slide ${state.activeNumber} (${state.counter})`)
+
+await page.keyboard.press('ArrowLeft')
+await page.waitForTimeout(250)
+state = await readState()
+assert.equal(state.activeNumber, '1', `ArrowLeft should go back, active=${state.activeNumber}`)
+console.log('5) ArrowLeft went back to slide 1')
+
+await page.mouse.click(1000, 400)
+await page.waitForTimeout(250)
+state = await readState()
+assert.equal(state.activeNumber, '2', `right-side click should advance, active=${state.activeNumber}`)
+await page.mouse.click(60, 400)
+await page.waitForTimeout(250)
+state = await readState()
+assert.equal(state.activeNumber, '1', `left-edge click should go back, active=${state.activeNumber}`)
+console.log('6) click navigation works in both directions')
+
+await page.keyboard.press('End')
+await page.waitForTimeout(300)
+state = await readState()
+const total = Number(state.counter.split('/')[1].trim())
+assert.equal(Number(state.activeNumber), total, `End should jump to the last slide (${state.counter})`)
+console.log(`7) End jumped to the last slide (${state.counter})`)
+
+await page.keyboard.press('Escape')
+await page.waitForTimeout(400)
+state = await readState()
+assert.equal(state.overlay, false, 'Escape should remove the overlay')
+assert.equal(state.presenting, false, 'is-presenting should be cleared')
+const restored = await page.evaluate(`(() => {
+ ${DEEP}
+ const surface = deepOne('.pptx-render-surface')
+ const box = deepOne('.flyfish-pptx-scale-box')
+ const slots = deepAll('.flyfish-pptx-slide-slot')
+ return {
+ backInSurface: Boolean(surface && box && surface.contains(box)),
+ visible: slots.filter(s => getComputedStyle(s).display !== 'none').length,
+ }
+})()`)
+assert.equal(restored.backInSurface, true, 'the scale box should return to the render surface')
+assert.ok(restored.visible > 1, `all slides should be visible again, got ${restored.visible}`)
+console.log(`8) Escape restored the scroll view (${restored.visible} slides visible again)`)
+
+await page.keyboard.press('KeyP')
+await deep('.flyfish-pptx-presentation').waitFor({ state: 'visible', timeout: 10_000 })
+state = await readState()
+assert.equal(state.visibleSlides, 1, 'second entry should show one slide')
+console.log(`9) P re-entered the slideshow (${state.counter})`)
+await page.keyboard.press('Escape')
+await page.waitForTimeout(300)
+
+// ---------------------------------------------------------------------------
+// Part 2: API-level harness (default/non-windowed, resize, two instances,
+// shadow fullscreen, focusable controls, unmount cleanup, scroll/transform)
+// ---------------------------------------------------------------------------
+await page.goto(`${BASE}/slideshow-test.html`, { waitUntil: 'load' })
+await page.waitForFunction(
+ () => document.documentElement.dataset.slideshowTestReady === 'true',
+ null,
+ { timeout: 60_000 }
+)
+
+const call = (method, ...args) => page.evaluate(
+ ({ method, args }) => window.__slideshowTest[method](...args),
+ { method, args }
+)
+const waitOverlay = (count, timeout = 10_000) => page.waitForFunction(
+ expected => window.__slideshowTest.overlayCount() === expected,
+ count,
+ { timeout }
+)
+
+// 10) default/non-windowed API: slideCount counts the deck, enter works, and
+// the active slide is a real rendered slide, not an empty slot marker.
+const defaultCount = await call('slideCount', 'default')
+assert.equal(defaultCount, 20, `default viewer should count 20 slides, got ${defaultCount}`)
+await call('enter', 'default')
+await waitOverlay(1)
+assert.equal(await call('activeNumber', 'default'), 1, 'default viewer should start on slide 1')
+assert.equal(await call('activeSlideHasContent', 'default'), true, 'active slide should have content')
+assert.match(await call('counter', 'default'), /^1 \/ 20$/, 'counter should read 1 / 20')
+assert.match(await call('transform', 'default'), /scale\(/, 'non-windowed slide should be scaled to fit')
+await page.keyboard.press('ArrowRight')
+await page.waitForTimeout(250)
+assert.equal(await call('activeNumber', 'default'), 2, 'non-windowed deck should navigate')
+assert.equal(await call('activeSlideHasContent', 'default'), true, 'slide 2 should have content')
+await call('exit', 'default')
+await waitOverlay(0, 5_000)
+console.log('10) default/non-windowed API: slideCount, enter, navigate, content, exit')
+
+// 11) virtualization must not unmount the active slide during a presentation.
+await call('enter', 'windowed', 20)
+await waitOverlay(1)
+assert.equal(await call('activeNumber', 'windowed'), 20, 'windowed viewer should jump to slide 20')
+assert.equal(await call('activeSlideHasContent', 'windowed'), true, 'slide 20 should be rendered')
+await page.setViewportSize({ width: 1000, height: 700 })
+await page.waitForTimeout(400)
+assert.equal(await call('activeNumber', 'windowed'), 20, 'active slide should survive a resize')
+assert.equal(await call('activeSlideHasContent', 'windowed'), true, 'active slide should keep content after a resize')
+await call('exit', 'windowed')
+await waitOverlay(0, 5_000)
+await page.setViewportSize({ width: 1280, height: 800 })
+console.log('11) active slide keeps its content after a resize')
+
+// 12) Host controls never activate a viewer shortcut. Once a viewer is
+// explicitly activated, one P opens exactly one overlay; with two overlays
+// open only the focused one advances.
+await page.click('#unrelated-host-action')
+await page.evaluate(() => {
+ window.__hostShortcutPrevented = null
+ document.addEventListener('keydown', event => {
+ if (event.key === 'p' || event.key === 'P') {
+ window.__hostShortcutPrevented = event.defaultPrevented
+ }
+ }, { once: true })
+})
+await page.keyboard.press('KeyP')
+await page.waitForTimeout(250)
+assert.equal(await call('overlayCount'), 0, 'P on an unrelated host control must not open a slideshow')
+assert.equal(await page.evaluate(() => window.__hostShortcutPrevented), false, 'host P key must not be prevented')
+await page.click('#renderer-b')
+await page.keyboard.press('KeyP')
+await waitOverlay(1)
+console.log('12) host shortcut isolation and two-instance arbitration work')
+await page.keyboard.press('KeyP')
+await waitOverlay(0, 5_000)
+await page.click('#renderer-a')
+await page.keyboard.press('KeyP')
+await waitOverlay(1)
+await page.keyboard.press('KeyP')
+await waitOverlay(0, 5_000)
+await call('enter', 'default')
+await call('enter', 'windowed', 1)
+await waitOverlay(2)
+const defaultBefore = await call('activeNumber', 'default')
+const focusedBefore = await call('activeNumber', 'windowed')
+await page.keyboard.press('ArrowRight')
+await page.waitForTimeout(250)
+assert.equal(await call('activeNumber', 'windowed'), focusedBefore + 1, 'focused overlay should advance')
+assert.equal(await call('activeNumber', 'default'), defaultBefore, 'unfocused overlay should stay put')
+await call('exit', 'default')
+await call('exit', 'windowed')
+await waitOverlay(0, 5_000)
+
+// 13) native fullscreen inside a shadow root: the browser's own exit closes the
+// overlay instead of leaving a fixed layer behind.
+await page.click('#enter-shadow')
+await page.waitForFunction(
+ () => Boolean(window.__slideshowTest.fullscreenElementClass()),
+ null,
+ { timeout: 5_000 }
+)
+assert.equal(await call('overlayCount'), 1, 'shadow viewer should be presenting')
+await call('exitFullscreen')
+await waitOverlay(0, 5_000)
+console.log('13) shadow-root native fullscreen exit closes the overlay')
+
+// 14) Enter/Space on the focused exit button activates it, not the slide.
+await call('enter', 'default')
+await waitOverlay(1)
+await call('focusExitButton', 'default')
+await page.keyboard.press('Enter')
+await waitOverlay(0, 5_000)
+await call('enter', 'default')
+await waitOverlay(1)
+await call('focusExitButton', 'default')
+await page.keyboard.press('Space')
+await waitOverlay(0, 5_000)
+console.log('14) Enter/Space on the exit button exits instead of advancing')
+
+// 15) unmount/destroy while presenting cleans the overlay up.
+await call('enter', 'windowed')
+await waitOverlay(1)
+await call('destroy', 'windowed')
+await waitOverlay(0, 5_000)
+await page.click('#renderer-a')
+await page.keyboard.press('KeyP')
+await waitOverlay(1)
+await call('unmountRenderer', 'rendererA')
+await waitOverlay(0, 5_000)
+console.log('15) destroy/unmount while presenting removes the overlay')
+
+// 16) exiting from a scrolled deck restores the exact scroll position and the
+// original scale transform.
+await call('scrollTo', 600)
+await page.waitForTimeout(100)
+const beforeScroll = await call('scrollTop')
+const beforeTransform = await call('transform', 'default')
+assert.ok(beforeScroll >= 600, `expected the page to scroll to ~600, got ${beforeScroll}`)
+await call('enter', 'default')
+await waitOverlay(1)
+await call('exit', 'default')
+await waitOverlay(0, 5_000)
+await page.waitForTimeout(300)
+const afterScroll = await call('scrollTop')
+const afterTransform = await call('transform', 'default')
+assert.equal(afterScroll, beforeScroll, `scroll position should be restored, got ${afterScroll} expected ${beforeScroll}`)
+assert.equal(afterTransform, beforeTransform, `transform should be restored, got "${afterTransform}" expected "${beforeTransform}"`)
+console.log('16) scroll position and transform restored exactly')
+
+// 17) A renderer that rejects during worker startup returns no instance, so it
+// must remove its document listener and shell before propagating the error.
+await page.evaluate(() => {
+ const added = []
+ const removed = []
+ const originalAdd = document.addEventListener.bind(document)
+ const originalRemove = document.removeEventListener.bind(document)
+ window.__failedRendererListenerProbe = { added, removed, originalAdd, originalRemove }
+ document.addEventListener = (type, listener, options) => {
+ if (type === 'keydown') added.push(listener)
+ return originalAdd(type, listener, options)
+ }
+ document.removeEventListener = (type, listener, options) => {
+ if (type === 'keydown') removed.push(listener)
+ return originalRemove(type, listener, options)
+ }
+})
+const failure = await call('renderFailure')
+const failureListeners = await page.evaluate(() => {
+ const probe = window.__failedRendererListenerProbe
+ document.addEventListener = probe.originalAdd
+ document.removeEventListener = probe.originalRemove
+ return {
+ added: probe.added.length,
+ removed: probe.removed.length,
+ leaked: probe.added.filter(listener => !probe.removed.includes(listener)).length,
+ }
+})
+assert.equal(failure.rejected, true, 'invalid worker startup should reject')
+assert.equal(failure.childCount, 0, 'failed renderer shell should be removed')
+assert.equal(failureListeners.added, 1, 'failed renderer should install one keydown listener')
+assert.equal(failureListeners.removed, 1, 'failed renderer should remove its keydown listener')
+assert.equal(failureListeners.leaked, 0, 'failed renderer must not leak a keydown listener')
+console.log('17) failed renderer startup cleans its shell and document listener')
+
+await call('unmountRenderer', 'rendererB')
+
+assert.deepEqual(errors, [], `page errors: ${errors.join(' | ')}`)
+console.log('\nall slideshow checks passed')
+await browser.close()
+server.close()
diff --git a/apps/viewer-demo/slideshow-test.html b/apps/viewer-demo/slideshow-test.html
new file mode 100644
index 00000000..5fc37cc2
--- /dev/null
+++ b/apps/viewer-demo/slideshow-test.html
@@ -0,0 +1,83 @@
+
+
+
+
+
+ PPTX slideshow test harness
+
+
+
+ PPTX slideshow test harness
+ Driven by verify-pptx-slideshow.mjs. Exposes window.__slideshowTest.
+
+
+
+
+
+
+
+
+
+ default (non-windowed API)
+
+
+
+
+ windowed (lazySlides)
+
+
+
+
+ shadow root (non-windowed)
+
+
+
+
+ renderer A (renderPptx)
+
+
+
+
+ renderer B (renderPptx)
+
+
+
+
+
+
+
+
diff --git a/apps/viewer-demo/src/slideshow-test.ts b/apps/viewer-demo/src/slideshow-test.ts
new file mode 100644
index 00000000..0f58cab3
--- /dev/null
+++ b/apps/viewer-demo/src/slideshow-test.ts
@@ -0,0 +1,225 @@
+import { PptxViewer } from '@file-viewer/pptx'
+import { renderFileViewerPresentation } from '@file-viewer/renderer-presentation'
+import type { FileViewerRenderedInstance } from '@file-viewer/core'
+
+type ViewerName = 'default' | 'windowed' | 'shadow'
+type RendererName = 'rendererA' | 'rendererB'
+
+interface SlideshowTestApi {
+ slideCount: (name: ViewerName) => number
+ enter: (name: ViewerName, slide?: number) => Promise
+ exit: (name: ViewerName) => void
+ presenting: (name: ViewerName) => boolean
+ activeNumber: (name: ViewerName) => number
+ activeSlideHasContent: (name: ViewerName) => boolean
+ counter: (name: ViewerName) => string
+ transform: (name: ViewerName) => string
+ overlayCount: () => number
+ scrollTop: () => number
+ scrollTo: (y: number) => void
+ focusExitButton: (name: ViewerName) => void
+ destroy: (name: ViewerName) => void
+ unmountRenderer: (name: RendererName) => void
+ renderFailure: () => Promise<{ rejected: boolean; childCount: number }>
+ exitFullscreen: () => Promise
+ fullscreenElementClass: () => string
+}
+
+declare global {
+ interface Window {
+ __slideshowTest?: SlideshowTestApi
+ }
+}
+
+const viewers = new Map()
+const renderers = new Map()
+
+const destroyRenderedInstance = (instance: FileViewerRenderedInstance) => {
+ if ('unmount' in instance) {
+ void instance.unmount()
+ } else if ('$destroy' in instance) {
+ void instance.$destroy()
+ } else {
+ void instance.destroy()
+ }
+}
+
+const deepAll = (selector: string, root: ParentNode = document): Element[] => {
+ const out: Element[] = []
+ const walk = (node: ParentNode) => {
+ out.push(...Array.from(node.querySelectorAll(selector)))
+ for (const el of Array.from(node.querySelectorAll('*'))) {
+ if (el.shadowRoot) {
+ walk(el.shadowRoot)
+ }
+ }
+ }
+ walk(root)
+ return out
+}
+
+const fetchSample = async () => {
+ const response = await fetch('/example/ppt.pptx')
+ if (!response.ok) {
+ throw new Error(`sample fetch failed: ${response.status}`)
+ }
+ return response.arrayBuffer()
+}
+
+// The raw PptxViewer resolves its worker with `new URL('./worker/pptx.worker.js',
+// import.meta.url)`, which the demo build does not rewrite for this entry chunk.
+// Point it at the vendored worker copy the demo already ships instead.
+const PPTX_WORKER_URL = '/vendor/pptx/pptx.worker.js'
+
+const mountShadowTarget = () => {
+ const host = document.getElementById('viewer-shadow-host')!
+ const shadow = host.attachShadow({ mode: 'open' })
+ const target = document.createElement('div')
+ shadow.appendChild(target)
+ return target
+}
+
+const overlayFor = (name: ViewerName) => {
+ const viewer = viewers.get(name)
+ if (!viewer) {
+ return null
+ }
+ return deepAll('.flyfish-pptx-presentation').find(overlay => overlay.contains(viewer.content)) || null
+}
+
+const waitForReady = async () => {
+ const deadline = Date.now() + 60_000
+ const allReady = () => {
+ const rawReady = (['default', 'windowed', 'shadow'] as ViewerName[]).every(
+ name => (viewers.get(name)?.slideCount ?? 0) === 20
+ )
+ const rendererReady = (['renderer-a', 'renderer-b'] as const).every(id => {
+ const element = document.getElementById(id)
+ return Boolean(element?.querySelector('.pptx-slideshow-button:not([hidden])'))
+ })
+ return rawReady && rendererReady
+ }
+ while (!allReady()) {
+ if (Date.now() > deadline) {
+ throw new Error('timed out waiting for the harness viewers to render')
+ }
+ await new Promise(resolve => setTimeout(resolve, 100))
+ }
+}
+
+const init = async () => {
+ const buffer = await fetchSample()
+
+ // The default and windowed viewers are driven through evaluate, not a user
+ // gesture, so native fullscreen is disabled and the overlay stays fixed.
+ viewers.set('default', await PptxViewer.open(buffer, document.getElementById('viewer-default')!, {
+ workerUrl: PPTX_WORKER_URL,
+ presentationFullscreen: false,
+ }))
+ viewers.set('windowed', await PptxViewer.open(buffer, document.getElementById('viewer-windowed')!, {
+ workerUrl: PPTX_WORKER_URL,
+ presentationFullscreen: false,
+ lazySlides: true,
+ listOptions: { windowed: true, initialSlides: 3, batchSize: 4, overscanViewport: 1.5 },
+ }))
+ viewers.set('shadow', await PptxViewer.open(buffer, mountShadowTarget(), {
+ workerUrl: PPTX_WORKER_URL,
+ }))
+
+ renderers.set('rendererA', await renderFileViewerPresentation(
+ buffer,
+ document.getElementById('renderer-a') as HTMLDivElement,
+ 'pptx',
+ {}
+ ))
+ renderers.set('rendererB', await renderFileViewerPresentation(
+ buffer,
+ document.getElementById('renderer-b') as HTMLDivElement,
+ 'pptx',
+ {}
+ ))
+
+ await waitForReady()
+
+ // Real user gestures so requestFullscreen is allowed in the browser.
+ document.getElementById('enter-shadow')!.addEventListener('click', () => {
+ void viewers.get('shadow')?.enterPresentation()
+ })
+ document.getElementById('enter-default')!.addEventListener('click', () => {
+ void viewers.get('default')?.enterPresentation()
+ })
+ document.getElementById('enter-windowed')!.addEventListener('click', () => {
+ void viewers.get('windowed')?.enterPresentation()
+ })
+
+ window.__slideshowTest = {
+ slideCount: name => viewers.get(name)?.slideCount ?? 0,
+ enter: async (name, slide) => {
+ await viewers.get(name)?.enterPresentation(slide)
+ },
+ exit: name => viewers.get(name)?.exitPresentation(),
+ presenting: name => Boolean(viewers.get(name)?.presenting),
+ activeNumber: name => viewers.get(name)?.presentationSlideNumber ?? 0,
+ activeSlideHasContent: name => {
+ const viewer = viewers.get(name)
+ if (!viewer) {
+ return false
+ }
+ const containers = Array.from(
+ viewer.content.querySelectorAll(':scope > .flyfish-pptx-slide-slot, :scope > .slide')
+ )
+ const active = containers.find(container => container.classList.contains('is-active-slide'))
+ if (!active) {
+ return false
+ }
+ const slide = active.classList.contains('flyfish-pptx-slide-slot')
+ ? active.firstElementChild
+ : active
+ return Boolean(slide && slide.classList.contains('slide') && slide.textContent?.trim())
+ },
+ counter: name => overlayFor(name)?.querySelector('.flyfish-pptx-presentation-counter')?.textContent ?? '',
+ transform: name => viewers.get(name)?.content.style.transform ?? '',
+ overlayCount: () => deepAll('.flyfish-pptx-presentation').length,
+ scrollTop: () => document.scrollingElement?.scrollTop ?? 0,
+ scrollTo: y => window.scrollTo(0, y),
+ focusExitButton: name => {
+ const button = overlayFor(name)?.querySelector('.flyfish-pptx-presentation-exit')
+ button?.focus()
+ },
+ destroy: name => {
+ viewers.get(name)?.destroy()
+ viewers.delete(name)
+ },
+ unmountRenderer: name => {
+ const instance = renderers.get(name)
+ if (instance) {
+ destroyRenderedInstance(instance)
+ }
+ renderers.delete(name)
+ },
+ renderFailure: async () => {
+ const target = document.getElementById('renderer-failure') as HTMLDivElement
+ let rejected = false
+ try {
+ await renderFileViewerPresentation(buffer, target, 'pptx', {
+ options: { presentation: { workerUrl: '/missing-pptx-worker.js' } },
+ })
+ } catch {
+ rejected = true
+ }
+ return { rejected, childCount: target.childElementCount }
+ },
+ exitFullscreen: () => document.exitFullscreen(),
+ fullscreenElementClass: () => {
+ const element = document.fullscreenElement
+ return element ? element.className || element.tagName : ''
+ },
+ }
+
+ document.documentElement.dataset.slideshowTestReady = 'true'
+}
+
+init().catch(error => {
+ console.error('slideshow test harness failed to init:', error)
+ document.documentElement.dataset.slideshowTestReady = 'error'
+})
diff --git a/apps/viewer-demo/tsconfig.json b/apps/viewer-demo/tsconfig.json
index 5a1fe838..6717df37 100644
--- a/apps/viewer-demo/tsconfig.json
+++ b/apps/viewer-demo/tsconfig.json
@@ -19,6 +19,8 @@
"@/*": ["./src/*"],
"@file-viewer/core": ["../../packages/core/src/index.ts"],
"@file-viewer/core/browser": ["../../packages/core/src/browser.ts"],
+ "@file-viewer/pptx": ["../../packages/renderers/pptx/src/index.ts"],
+ "@file-viewer/renderer-presentation": ["../../packages/renderers/presentation/src/index.ts"],
"@file-viewer/vue3": ["../../packages/components/vue3/src/package/index.ts"],
"@file-viewer/web": ["../../packages/components/web/src/index.ts"],
"@flyfish-group/file-viewer3": ["../../packages/components/vue3/src/package/index.ts"]
diff --git a/apps/viewer-demo/vite.config.ts b/apps/viewer-demo/vite.config.ts
index a92bbb99..0aea78b9 100644
--- a/apps/viewer-demo/vite.config.ts
+++ b/apps/viewer-demo/vite.config.ts
@@ -108,7 +108,8 @@ export default defineConfig(ctx => {
input: {
main: fileURLToPath(new URL('index.html', import.meta.url)),
compare: fileURLToPath(new URL('compare.html', import.meta.url)),
- iframe: fileURLToPath(new URL('iframe.html', import.meta.url))
+ iframe: fileURLToPath(new URL('iframe.html', import.meta.url)),
+ 'slideshow-test': fileURLToPath(new URL('slideshow-test.html', import.meta.url))
}
},
outDir: 'dist'
diff --git a/package.json b/package.json
index deb25388..715f7183 100644
--- a/package.json
+++ b/package.json
@@ -38,7 +38,8 @@
"docs:preview": "vitepress preview docs",
"verify:vue3-toolbar-browser": "node apps/component-demo/scripts/verify-vue3-toolbar-browser.mjs",
"verify:renderer-sanitization": "pnpm --filter @file-viewer/pptx build && node apps/viewer-demo/scripts/verify-renderer-sanitization.mjs",
- "verify:browser-smoke": "node apps/viewer-demo/scripts/public-smoke.mjs"
+ "verify:browser-smoke": "node apps/viewer-demo/scripts/public-smoke.mjs",
+ "verify:pptx-slideshow": "node apps/viewer-demo/scripts/verify-pptx-slideshow.mjs"
},
"devDependencies": {
"@types/node": "^25.9.2",
diff --git a/packages/core/src/contracts/types.ts b/packages/core/src/contracts/types.ts
index aec7666b..60159448 100644
--- a/packages/core/src/contracts/types.ts
+++ b/packages/core/src/contracts/types.ts
@@ -148,6 +148,9 @@ export type FileViewerMessageKey =
| 'presentation.ppt.error.title'
| 'presentation.ppt.error.parseFailed'
| 'presentation.ppt.error.assetHint'
+ | 'presentation.slideshow.start'
+ | 'presentation.slideshow.exit'
+ | 'presentation.slideshow.hint'
| 'archive.error.nestedUnsupported'
| 'archive.loading.readingDirectory'
| 'archive.loading.readingDirectoryHint'
diff --git a/packages/core/src/i18n/messages.ja.ts b/packages/core/src/i18n/messages.ja.ts
index c2f0ba63..d4f3a4ac 100644
--- a/packages/core/src/i18n/messages.ja.ts
+++ b/packages/core/src/i18n/messages.ja.ts
@@ -140,6 +140,9 @@ export const JA_JP_MESSAGES: Record = {
'presentation.ppt.error.title': 'PPT プレビューに失敗しました',
'presentation.ppt.error.parseFailed': 'この PPT ファイルを解析できませんでした。',
'presentation.ppt.error.assetHint': '@file-viewer/ppt モジュール、pptWasmUrl、application/wasm MIME タイプ、Web Crypto、CSP 設定を確認してください。',
+ 'presentation.slideshow.start': 'スライドショーを開始',
+ 'presentation.slideshow.exit': 'スライドショーを終了',
+ 'presentation.slideshow.hint': '← → 移動 · Esc 終了',
'archive.error.nestedUnsupported': '.{type} 形式はオンラインでプレビューできません。ダウンロードするか、対応形式へ変換してください。',
'archive.loading.readingDirectory': '圧縮ファイルのディレクトリを読み込んでいます...',
'archive.loading.readingDirectoryHint': '大きなファイルは Worker で解析し、メインスレッドのブロックを防ぎます。',
diff --git a/packages/core/src/i18n/messages.ts b/packages/core/src/i18n/messages.ts
index c3f562fb..90bd0a80 100644
--- a/packages/core/src/i18n/messages.ts
+++ b/packages/core/src/i18n/messages.ts
@@ -157,6 +157,9 @@ const ZH_CN_MESSAGES: Record = {
'presentation.ppt.error.title': 'PPT 预览失败',
'presentation.ppt.error.parseFailed': '无法解析这个 PPT 文件。',
'presentation.ppt.error.assetHint': '请检查 @file-viewer/ppt 模块、pptWasmUrl、application/wasm MIME 类型、Web Crypto 和 CSP 配置。',
+ 'presentation.slideshow.start': '开始放映',
+ 'presentation.slideshow.exit': '退出放映',
+ 'presentation.slideshow.hint': '← → 翻页 · Esc 退出',
'archive.error.nestedUnsupported': '不支持 .{type} 格式的在线预览,请下载后预览或转换为支持的格式',
'archive.loading.readingDirectory': '正在读取压缩包目录...',
'archive.loading.readingDirectoryHint': '大文件会在 Worker 中解析,避免阻塞主线程。',
@@ -594,6 +597,9 @@ const EN_US_MESSAGES: Record = {
'presentation.ppt.error.title': 'PPT preview failed',
'presentation.ppt.error.parseFailed': 'This PPT file could not be parsed.',
'presentation.ppt.error.assetHint': 'Check the @file-viewer/ppt module, pptWasmUrl, application/wasm MIME type, Web Crypto, and CSP settings.',
+ 'presentation.slideshow.start': 'Start slideshow',
+ 'presentation.slideshow.exit': 'Exit slideshow',
+ 'presentation.slideshow.hint': '← → navigate · Esc exit',
'archive.error.nestedUnsupported': '.{type} cannot be previewed online. Download it or convert it to a supported format.',
'archive.loading.readingDirectory': 'Reading archive directory...',
'archive.loading.readingDirectoryHint': 'Large files are parsed in a Worker to keep the main thread responsive.',
diff --git a/packages/renderers/pptx/src/index.ts b/packages/renderers/pptx/src/index.ts
index 948170dd..710164f5 100644
--- a/packages/renderers/pptx/src/index.ts
+++ b/packages/renderers/pptx/src/index.ts
@@ -1,4 +1,6 @@
export { PptxViewer } from './viewer';
+export { PptxPresentation } from './presentation';
+export type { PptxPresentationLabels, PptxPresentationState } from './presentation';
export { createPptxWorker } from './worker';
export {
RECOMMENDED_ZIP_LIMITS,
diff --git a/packages/renderers/pptx/src/presentation.ts b/packages/renderers/pptx/src/presentation.ts
new file mode 100644
index 00000000..1e7f6dec
--- /dev/null
+++ b/packages/renderers/pptx/src/presentation.ts
@@ -0,0 +1,432 @@
+import type { PptxViewer } from './viewer';
+
+export interface PptxPresentationState {
+ active: boolean;
+ slideNumber: number;
+ total: number;
+}
+
+export interface PptxPresentationLabels {
+ exit?: string;
+ hint?: string;
+ next?: string;
+ previous?: string;
+}
+
+const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
+
+/**
+ * Full-screen slideshow for a rendered PPTX deck.
+ *
+ * The slides stay where the viewer put them: the whole scale box is moved into an overlay and the
+ * inactive slots are hidden with CSS, so the engine's scoped `.flyfish-pptx-content .slide` rules
+ * keep applying and no node is cloned. A placeholder marks the original position so exiting puts
+ * everything back exactly where it was.
+ */
+export class PptxPresentation {
+ private readonly viewer: PptxViewer;
+ private readonly labels: PptxPresentationLabels;
+ private readonly fullscreen: boolean;
+ private overlay: HTMLDivElement | null = null;
+ private stage: HTMLDivElement | null = null;
+ private counter: HTMLDivElement | null = null;
+ private placeholder: Comment | null = null;
+ private listeners: Array<{ target: EventTarget; type: string; listener: EventListener }> = [];
+ private layoutFrame = 0;
+ private ownsFullscreen = false;
+ private current = 1;
+
+ constructor(viewer: PptxViewer, labels: PptxPresentationLabels = {}, fullscreen = true) {
+ this.viewer = viewer;
+ this.labels = labels;
+ this.fullscreen = fullscreen;
+ }
+
+ get active() {
+ return Boolean(this.overlay);
+ }
+
+ get slideNumber() {
+ return this.current;
+ }
+
+ get state(): PptxPresentationState {
+ return { active: this.active, slideNumber: this.current, total: this.viewer.slideCount };
+ }
+
+ async enter(slideNumber = this.current) {
+ if (this.active || this.viewer.slideCount === 0) {
+ return;
+ }
+
+ const documentRef = this.viewer.target.ownerDocument || document;
+ const overlay = documentRef.createElement('div');
+ overlay.className = 'flyfish-pptx-presentation';
+ overlay.tabIndex = -1;
+ overlay.setAttribute('role', 'region');
+ overlay.setAttribute('aria-label', this.labels.hint || 'Slideshow');
+
+ const stage = documentRef.createElement('div');
+ stage.className = 'flyfish-pptx-presentation-stage';
+
+ const counter = documentRef.createElement('div');
+ counter.className = 'flyfish-pptx-presentation-counter';
+
+ const hint = documentRef.createElement('div');
+ hint.className = 'flyfish-pptx-presentation-hint';
+ hint.textContent = this.labels.hint || '';
+ hint.hidden = !this.labels.hint;
+
+ const exit = documentRef.createElement('button');
+ exit.type = 'button';
+ exit.className = 'flyfish-pptx-presentation-exit';
+ exit.textContent = '✕';
+ exit.setAttribute('aria-label', this.labels.exit || 'Exit slideshow');
+ exit.addEventListener('click', event => {
+ event.stopPropagation();
+ this.exit();
+ });
+
+ overlay.append(stage, counter, hint, exit);
+ this.overlay = overlay;
+ this.stage = stage;
+ this.counter = counter;
+
+ // Keep the overlay inside whichever root holds the slide styles, or the engine's scoped CSS
+ // would not reach the slides once they move.
+ const styleRoot = this.viewer.presentationRoot;
+ styleRoot.appendChild(overlay);
+
+ // Park a comment where the scale box lived so exit() can restore the exact position.
+ this.placeholder = documentRef.createComment('flyfish-pptx-presentation');
+ this.viewer.saveScrollPosition();
+ this.viewer.scaleBox.replaceWith(this.placeholder);
+ stage.appendChild(this.viewer.scaleBox);
+ this.viewer.content.classList.add('is-presenting');
+
+ this.attach(documentRef, overlay);
+ this.goTo(slideNumber);
+
+ if (this.fullscreen) {
+ try {
+ await overlay.requestFullscreen?.();
+ this.ownsFullscreen = this.isFullscreenElement(overlay);
+ } catch {
+ // Fullscreen needs a user gesture and can be blocked by permissions policy. The overlay is
+ // position:fixed, so the slideshow still fills the viewport without it.
+ this.ownsFullscreen = false;
+ }
+ }
+
+ // exit() may have run while fullscreen was being requested. Leave the
+ // browser out of fullscreen rather than stranded with no overlay.
+ if (this.overlay !== overlay) {
+ if (this.ownsFullscreen) {
+ void documentRef.exitFullscreen?.().catch(() => undefined);
+ }
+ this.ownsFullscreen = false;
+ return;
+ }
+
+ overlay.focus({ preventScroll: true });
+ this.scheduleLayout();
+ this.notify();
+ }
+
+ exit() {
+ const overlay = this.overlay;
+ if (!overlay) {
+ return;
+ }
+
+ const documentRef = this.viewer.target.ownerDocument || document;
+ // Capture before the overlay leaves the DOM: a detached node's getRootNode()
+ // is the document, so the fullscreen check would fail after removal.
+ const shouldExitFullscreen = this.ownsFullscreen && this.isFullscreenElement(overlay);
+ this.detach();
+
+ if (this.layoutFrame) {
+ (documentRef.defaultView || window).cancelAnimationFrame(this.layoutFrame);
+ this.layoutFrame = 0;
+ }
+
+ this.viewer.content.classList.remove('is-presenting');
+ for (const container of this.slideContainers()) {
+ container.classList.remove('is-active-slide');
+ }
+
+ if (this.placeholder?.parentNode) {
+ this.placeholder.replaceWith(this.viewer.scaleBox);
+ } else {
+ this.viewer.target.appendChild(this.viewer.scaleBox);
+ }
+ this.placeholder = null;
+
+ overlay.remove();
+ this.overlay = null;
+ this.stage = null;
+ this.counter = null;
+
+ if (shouldExitFullscreen) {
+ void documentRef.exitFullscreen?.().catch(() => undefined);
+ }
+ this.ownsFullscreen = false;
+
+ this.viewer.restoreScrollPosition();
+ this.notify();
+ }
+
+ async toggle(slideNumber?: number) {
+ if (this.active) {
+ this.exit();
+ return;
+ }
+ await this.enter(slideNumber);
+ }
+
+ goTo(slideNumber: number) {
+ const total = this.viewer.slideCount;
+ if (total === 0) {
+ return;
+ }
+
+ this.current = clamp(Math.round(slideNumber) || 1, 1, total);
+ this.viewer.ensureSlideRendered(this.current);
+ // Rendering the neighbour keeps the next step instant without defeating virtualization.
+ this.viewer.ensureSlideRendered(this.current + 1);
+
+ const containers = this.slideContainers();
+ // Windowed decks tag each slot with its slide number; non-windowed decks
+ // append slides in order, so the active one is found by index.
+ const byNumber = containers.some(container => Boolean(container.dataset.slideNumber));
+ containers.forEach((container, index) => {
+ const isActive = byNumber
+ ? Number(container.dataset.slideNumber) === this.current
+ : index === this.current - 1;
+ container.classList.toggle('is-active-slide', isActive);
+ });
+
+ if (this.counter) {
+ this.counter.textContent = `${this.current} / ${total}`;
+ }
+
+ if (this.active) {
+ this.scheduleLayout();
+ this.notify();
+ }
+ }
+
+ next() {
+ if (this.current < this.viewer.slideCount) {
+ this.goTo(this.current + 1);
+ }
+ }
+
+ previous() {
+ if (this.current > 1) {
+ this.goTo(this.current - 1);
+ }
+ }
+
+ /** Scale the active slide to fit the overlay, letterboxing whichever axis has slack. */
+ layout() {
+ const overlay = this.overlay;
+ if (!overlay) {
+ return;
+ }
+
+ const container = this.slideContainers().find((item, index) => {
+ if (item.dataset.slideNumber) {
+ return Number(item.dataset.slideNumber) === this.current;
+ }
+ return index === this.current - 1;
+ });
+ // Windowed decks wrap the slide in a slot; non-windowed decks are the slide.
+ const slide = container && container.classList.contains('flyfish-pptx-slide-slot')
+ ? container.firstElementChild as HTMLElement | null
+ : container as HTMLElement | null;
+ const size = this.viewer.slideDimensions;
+ const slideWidth = slide?.offsetWidth || size?.width || 0;
+ const slideHeight = slide?.offsetHeight || size?.height || 0;
+ if (!slideWidth || !slideHeight) {
+ return;
+ }
+
+ const viewWidth = overlay.clientWidth;
+ const viewHeight = overlay.clientHeight;
+ const scale = Math.min(viewWidth / slideWidth, viewHeight / slideHeight);
+ const offsetX = Math.max(0, (viewWidth - slideWidth * scale) / 2);
+ const offsetY = Math.max(0, (viewHeight - slideHeight * scale) / 2);
+
+ const content = this.viewer.content;
+ content.style.width = `${slideWidth}px`;
+ content.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
+
+ const scaleBox = this.viewer.scaleBox;
+ scaleBox.style.width = '100%';
+ scaleBox.style.height = '100%';
+ scaleBox.style.minHeight = '';
+ }
+
+ destroy() {
+ this.exit();
+ }
+
+ private slots() {
+ return Array.from(
+ this.viewer.content.querySelectorAll(':scope > .flyfish-pptx-slide-slot')
+ );
+ }
+
+ /**
+ * The nodes the slideshow toggles between. Windowed decks wrap each slide in a
+ * slot; the default (non-windowed) deck appends slides directly to the content
+ * node, so those are the containers there.
+ */
+ private slideContainers() {
+ const slots = this.slots();
+ if (slots.length > 0) {
+ return slots;
+ }
+ return Array.from(
+ this.viewer.content.querySelectorAll(':scope > .slide')
+ );
+ }
+
+ /**
+ * Chromium reports the fullscreen element of a shadow root on the shadow root
+ * itself while document.fullscreenElement stays on the host, so both have to
+ * be checked before deciding who owns fullscreen.
+ */
+ private isFullscreenElement(overlay: HTMLElement) {
+ const documentRef = this.viewer.target.ownerDocument || document;
+ if (documentRef.fullscreenElement === overlay) {
+ return true;
+ }
+ const root = overlay.getRootNode();
+ if (root !== documentRef && 'fullscreenElement' in root) {
+ return (root as ShadowRoot).fullscreenElement === overlay;
+ }
+ return false;
+ }
+
+ private scheduleLayout() {
+ const view = this.viewer.target.ownerDocument.defaultView || window;
+ if (this.layoutFrame) {
+ view.cancelAnimationFrame(this.layoutFrame);
+ }
+ this.layoutFrame = view.requestAnimationFrame(() => {
+ this.layoutFrame = 0;
+ this.layout();
+ });
+ }
+
+ private notify() {
+ this.viewer.emitPresentationChange(this.state);
+ }
+
+ private on(target: EventTarget, type: string, listener: EventListener) {
+ target.addEventListener(type, listener);
+ this.listeners.push({ target, type, listener });
+ }
+
+ private attach(documentRef: Document, overlay: HTMLElement) {
+ const view = documentRef.defaultView || window;
+
+ this.on(documentRef, 'keydown', (event: Event) => this.handleKey(event as KeyboardEvent));
+ this.on(overlay, 'click', (event: Event) => this.handleClick(event as MouseEvent));
+ this.on(overlay, 'contextmenu', (event: Event) => {
+ event.preventDefault();
+ this.previous();
+ });
+ this.on(view, 'resize', () => this.scheduleLayout());
+ this.on(documentRef, 'fullscreenchange', () => {
+ // Leaving fullscreen with the browser's own Esc must close the slideshow too, otherwise the
+ // overlay would stay up as a plain fixed layer.
+ if (this.ownsFullscreen && !this.isFullscreenElement(overlay)) {
+ this.ownsFullscreen = false;
+ this.exit();
+ }
+ });
+ }
+
+ private detach() {
+ for (const { target, type, listener } of this.listeners) {
+ target.removeEventListener(type, listener);
+ }
+ this.listeners = [];
+ }
+
+ private handleClick(event: MouseEvent) {
+ const overlay = this.overlay;
+ if (!overlay || (event.target as HTMLElement | null)?.closest('.flyfish-pptx-presentation-exit')) {
+ return;
+ }
+ // PowerPoint advances on click; the left edge is the only place that goes back.
+ const bounds = overlay.getBoundingClientRect();
+ const ratio = (event.clientX - bounds.left) / (bounds.width || 1);
+ if (ratio < 0.2) {
+ this.previous();
+ } else {
+ this.next();
+ }
+ }
+
+ private handleKey(event: KeyboardEvent) {
+ if (!this.active || event.altKey || event.metaKey || event.ctrlKey) {
+ return;
+ }
+
+ // Enter/Space on a focused control (the exit button) must activate that
+ // control, not advance the slide. Let the browser's default run instead.
+ const target = event.target as HTMLElement | null;
+ if (
+ target &&
+ typeof target.closest === 'function' &&
+ target.closest('button, a, input, textarea, select, [contenteditable="true"], [contenteditable=""]')
+ ) {
+ return;
+ }
+
+ // With two slideshows open, a key pressed inside the other overlay belongs
+ // to that overlay; only the focused one advances.
+ if (target && typeof target.closest === 'function') {
+ const otherOverlay = target.closest('.flyfish-pptx-presentation');
+ if (otherOverlay && otherOverlay !== this.overlay) {
+ return;
+ }
+ }
+
+ switch (event.key) {
+ case 'ArrowRight':
+ case 'ArrowDown':
+ case 'PageDown':
+ case ' ':
+ case 'Enter':
+ event.preventDefault();
+ this.next();
+ break;
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ case 'PageUp':
+ case 'Backspace':
+ event.preventDefault();
+ this.previous();
+ break;
+ case 'Home':
+ event.preventDefault();
+ this.goTo(1);
+ break;
+ case 'End':
+ event.preventDefault();
+ this.goTo(this.viewer.slideCount);
+ break;
+ case 'Escape':
+ event.preventDefault();
+ this.exit();
+ break;
+ default:
+ break;
+ }
+ }
+}
diff --git a/packages/renderers/pptx/src/styles.ts b/packages/renderers/pptx/src/styles.ts
index c0116c2a..e6484780 100644
--- a/packages/renderers/pptx/src/styles.ts
+++ b/packages/renderers/pptx/src/styles.ts
@@ -63,6 +63,24 @@ export const pptxViewerCss = `
${scopePptxContentStyleText(legacyPptxCss)}
.flyfish-pptx-content > .slide:last-of-type{margin-bottom:0}
.flyfish-pptx-slide-slot:last-of-type > .slide{margin-bottom:0}
+.flyfish-pptx-presentation{position:fixed;inset:0;z-index:2147483000;display:block;overflow:hidden;background:#000;cursor:default;user-select:none;-webkit-user-select:none}
+.flyfish-pptx-presentation:focus{outline:none}
+.flyfish-pptx-presentation-stage{position:absolute;inset:0;overflow:hidden}
+.flyfish-pptx-presentation .flyfish-pptx-scale-box{position:absolute;inset:0;width:100%;height:100%;margin:0}
+.flyfish-pptx-presentation .flyfish-pptx-thumbnail{display:none}
+.flyfish-pptx-content.is-presenting > .flyfish-pptx-slide-slot{display:none;min-height:0}
+.flyfish-pptx-content.is-presenting > .flyfish-pptx-slide-slot.is-active-slide{display:flow-root}
+.flyfish-pptx-content.is-presenting > .slide{display:none}
+.flyfish-pptx-content.is-presenting > .slide.is-active-slide{display:block}
+.flyfish-pptx-content.is-presenting .slide{margin:0!important;box-shadow:none}
+.flyfish-pptx-presentation-counter{position:absolute;right:18px;bottom:14px;padding:5px 12px;border-radius:999px;background:rgba(255,255,255,.14);color:#fff;font:13px/1.4 system-ui,sans-serif;letter-spacing:.02em;pointer-events:none}
+.flyfish-pptx-presentation-hint{position:absolute;left:18px;bottom:14px;color:rgba(255,255,255,.55);font:12px/1.4 system-ui,sans-serif;pointer-events:none}
+.flyfish-pptx-presentation-hint[hidden]{display:none}
+.flyfish-pptx-presentation-exit{position:absolute;top:14px;right:14px;width:34px;height:34px;padding:0;border:0;border-radius:999px;background:rgba(255,255,255,.14);color:#fff;font-size:15px;line-height:1;cursor:pointer}
+.flyfish-pptx-presentation-exit:hover{background:rgba(255,255,255,.26)}
+.flyfish-pptx-presentation-exit:focus-visible{outline:2px solid #fff;outline-offset:2px}
+@media (prefers-reduced-motion:no-preference){.flyfish-pptx-content.is-presenting > .flyfish-pptx-slide-slot.is-active-slide,.flyfish-pptx-content.is-presenting > .slide.is-active-slide{animation:flyfish-pptx-slide-in 140ms ease-out}}
+@keyframes flyfish-pptx-slide-in{from{opacity:.35}to{opacity:1}}
`;
const PPTX_STYLE_ID = 'flyfish-pptx-native-style';
diff --git a/packages/renderers/pptx/src/types.ts b/packages/renderers/pptx/src/types.ts
index 72d67f57..20ea25c0 100644
--- a/packages/renderers/pptx/src/types.ts
+++ b/packages/renderers/pptx/src/types.ts
@@ -1,3 +1,5 @@
+import type { PptxPresentationLabels, PptxPresentationState } from './presentation';
+
export type PptxFitMode = 'contain' | 'none';
export interface PptxZipLimits {
@@ -80,6 +82,9 @@ export interface PptxViewerOptions extends PptxWorkerFactoryOptions {
onRenderComplete?: () => void;
onWarning?: (warning: unknown) => void;
onError?: (error: unknown) => void;
+ presentationLabels?: PptxPresentationLabels;
+ presentationFullscreen?: boolean;
+ onPresentationChange?: (state: PptxPresentationState) => void;
}
export interface PptxWorkerMessage {
diff --git a/packages/renderers/pptx/src/viewer.ts b/packages/renderers/pptx/src/viewer.ts
index ae9da13b..978e1c6e 100644
--- a/packages/renderers/pptx/src/viewer.ts
+++ b/packages/renderers/pptx/src/viewer.ts
@@ -1,6 +1,8 @@
import { renderPptxPostProcessing } from './chart';
import type { PptxPostProcessingHandle } from './chart';
import { resolvePptxEngineOptions, RECOMMENDED_ZIP_LIMITS } from './options';
+import { PptxPresentation } from './presentation';
+import type { PptxPresentationState } from './presentation';
import { sanitizePptxCss, sanitizePptxMarkup } from './sanitize';
import { ensurePptxViewerStyles } from './styles';
import type { PptxDiagnosticError, PptxSlideSize, PptxViewerOptions, PptxWorkerMessage } from './types';
@@ -174,6 +176,13 @@ export class PptxViewer {
private previewThumbnailDataUrl: string | null = null;
private slideSize: PptxSlideSize | null = null;
private slideRecords: WindowedSlideRecord[] = [];
+ // Total slides received from the worker. The windowed path mirrors this in
+ // slideRecords, but the default (non-windowed) path appends slides straight
+ // into the content node without creating records, so the deck size has to be
+ // counted independently of the virtualization bookkeeping.
+ private totalSlides = 0;
+ private savedScroll: { top: number; left: number } | null = null;
+ private pendingScrollRestore: { top: number; left: number } | null = null;
private slideWindowTarget: HTMLElement | Window | null = null;
private slideWindowListeners: Array<{ target: EventTarget; type: string; listener: EventListener }> = [];
private slideWindowFrame = 0;
@@ -182,6 +191,7 @@ export class PptxViewer {
private readonly mediaRecords = new Map();
private disposed = false;
private completed = false;
+ private presentation: PptxPresentation | null = null;
private readonly handleSlideWindowChange = () => this.scheduleSlideWindowUpdate();
private constructor(buffer: ArrayBuffer, target: HTMLElement, options: PptxViewerOptions) {
@@ -208,6 +218,111 @@ export class PptxViewer {
return this.previewThumbnailDataUrl;
}
+ get slideCount() {
+ return this.totalSlides;
+ }
+
+ get slideDimensions() {
+ const width = Number(this.slideSize?.width);
+ const height = Number(this.slideSize?.height);
+ return Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0
+ ? { width, height }
+ : null;
+ }
+
+ get presenting() {
+ return Boolean(this.presentation?.active);
+ }
+
+ get presentationSlideNumber() {
+ return this.presentation?.slideNumber ?? 1;
+ }
+
+ /**
+ * Where the slideshow overlay is mounted. It has to share a root with the injected slide styles,
+ * or the engine's scoped CSS stops applying once the slides move into the overlay.
+ */
+ get presentationRoot(): ShadowRoot | HTMLElement {
+ const documentRef = this.target.ownerDocument || document;
+ if (this.options.styleRoot) {
+ return this.options.styleRoot;
+ }
+ const root = this.target.getRootNode();
+ const ShadowRootCtor = documentRef.defaultView?.ShadowRoot;
+ if (ShadowRootCtor && root instanceof ShadowRootCtor) {
+ return root as ShadowRoot;
+ }
+ return documentRef.body || documentRef.documentElement;
+ }
+
+ /** Force a slide out of the virtualized window so the slideshow can show it immediately. */
+ ensureSlideRendered(slideNumber: number) {
+ const record = this.slideRecords.find(item => item.slideNumber === slideNumber);
+ if (!record) {
+ return null;
+ }
+ if (!record.rendered) {
+ this.renderSlideRecord(record);
+ }
+ return record.element;
+ }
+
+ refreshLayout() {
+ this.scheduleResize();
+ }
+
+ /**
+ * Remember where the deck was scrolled before the slideshow moves the scale
+ * box into the overlay. Moving it collapses the scroller and clamps scrollTop
+ * to zero, so the position has to be captured up front and restored on exit.
+ */
+ saveScrollPosition() {
+ const view = this.target.ownerDocument.defaultView || window;
+ const HTMLElementCtor = view.HTMLElement;
+ const scroller = this.slideWindowTarget || this.findSlideWindowTarget();
+ if (scroller instanceof HTMLElementCtor) {
+ this.savedScroll = { top: scroller.scrollTop, left: scroller.scrollLeft };
+ } else {
+ this.savedScroll = { top: view.scrollY, left: view.scrollX };
+ }
+ }
+
+ restoreScrollPosition() {
+ if (this.savedScroll) {
+ this.pendingScrollRestore = this.savedScroll;
+ this.savedScroll = null;
+ }
+ this.scheduleResize();
+ }
+
+ emitPresentationChange(state: PptxPresentationState) {
+ this.options.onPresentationChange?.(state);
+ }
+
+ async enterPresentation(slideNumber?: number) {
+ if (this.disposed || this.totalSlides === 0) {
+ return;
+ }
+ this.presentation ||= new PptxPresentation(
+ this,
+ this.options.presentationLabels,
+ this.options.presentationFullscreen
+ );
+ await this.presentation.enter(slideNumber);
+ }
+
+ exitPresentation() {
+ this.presentation?.exit();
+ }
+
+ async togglePresentation(slideNumber?: number) {
+ if (this.presenting) {
+ this.exitPresentation();
+ return;
+ }
+ await this.enterPresentation(slideNumber);
+ }
+
async open() {
ensureZipWithinLimits(this.buffer, this.options);
ensurePptxViewerStyles(this.target.ownerDocument || document, this.options.styleRoot);
@@ -225,6 +340,8 @@ export class PptxViewer {
destroy() {
this.disposed = true;
+ this.presentation?.destroy();
+ this.presentation = null;
this.previewThumbnailDataUrl = null;
this.releaseCharts();
this.releaseMedia();
@@ -251,6 +368,7 @@ export class PptxViewer {
this.previewThumbnailDataUrl = null;
this.slideSize = null;
this.slideRecords = [];
+ this.totalSlides = 0;
this.content.replaceChildren();
this.content.dataset.renderState = 'loading';
try {
@@ -298,6 +416,7 @@ export class PptxViewer {
switch (message.type) {
case 'slide': {
this.clearThumbnail();
+ this.totalSlides += 1;
if (this.shouldWindowSlides()) {
this.appendWindowedSlide(String(message.data || ''), Number(message.slide_num || 0));
} else {
@@ -316,6 +435,7 @@ export class PptxViewer {
const error = message.data;
const html = buildSlideErrorHtml(slideNumber, error);
this.clearThumbnail();
+ this.totalSlides += 1;
if (this.shouldWindowSlides()) {
this.appendWindowedSlide(html, slideNumber);
} else {
@@ -558,6 +678,23 @@ export class PptxViewer {
const windowOptions = this.getSlideWindowOptions();
const indexesToRender = this.getWindowedSlideIndexes(windowOptions);
+
+ // While presenting, the active slide and its neighbour live inside the fixed
+ // overlay, outside the scroll-view geometry the windowing code measures. Keep
+ // them mounted so a resize cannot unmount the slide that is on screen.
+ const presentation = this.presentation;
+ if (presentation?.active) {
+ const activeIndex = this.slideRecords.findIndex(
+ record => record.slideNumber === presentation.slideNumber
+ );
+ if (activeIndex >= 0) {
+ indexesToRender.add(activeIndex);
+ if (activeIndex + 1 < this.slideRecords.length) {
+ indexesToRender.add(activeIndex + 1);
+ }
+ }
+ }
+
let changed = false;
this.slideRecords.forEach((record, index) => {
@@ -959,6 +1096,12 @@ export class PptxViewer {
}
private resize() {
+ // While presenting, the slideshow owns the transform of the same nodes.
+ if (this.presentation?.active) {
+ this.presentation.layout();
+ return;
+ }
+
const slides = this.getMountedSlideElements();
const sizeWidth = Number(this.slideSize?.width);
@@ -980,6 +1123,22 @@ export class PptxViewer {
this.scaleBox.style.width = `${Math.ceil(slideWidth * effectiveScale)}px`;
this.scaleBox.style.height = `${Math.ceil(this.content.scrollHeight * effectiveScale)}px`;
this.scaleBox.style.minHeight = '';
+
+ // The scale box height is recomputed above; only now can the saved scroll
+ // position be applied without the scroller clamping it back to zero.
+ if (this.pendingScrollRestore) {
+ const { top, left } = this.pendingScrollRestore;
+ this.pendingScrollRestore = null;
+ const view = this.target.ownerDocument.defaultView || window;
+ const HTMLElementCtor = view.HTMLElement;
+ const scroller = this.slideWindowTarget || this.findSlideWindowTarget();
+ if (scroller instanceof HTMLElementCtor) {
+ scroller.scrollTop = top;
+ scroller.scrollLeft = left;
+ } else {
+ view.scrollTo(left, top);
+ }
+ }
}
private getMountedSlideElements() {
diff --git a/packages/renderers/pptx/tsconfig.json b/packages/renderers/pptx/tsconfig.json
index 1ceaf28b..855c4770 100644
--- a/packages/renderers/pptx/tsconfig.json
+++ b/packages/renderers/pptx/tsconfig.json
@@ -24,6 +24,7 @@
"src/styles.ts",
"src/chart.ts",
"src/worker.ts",
- "src/viewer.ts"
+ "src/viewer.ts",
+ "src/presentation.ts"
]
}
diff --git a/packages/renderers/presentation/src/pptx.ts b/packages/renderers/presentation/src/pptx.ts
index e037a338..07db64f9 100644
--- a/packages/renderers/presentation/src/pptx.ts
+++ b/packages/renderers/presentation/src/pptx.ts
@@ -29,6 +29,14 @@ const pptxStyle = `
.pptx-loading[hidden],.pptx-error[hidden]{display:none!important}
.pptx-loading-dot{width:9px;height:9px;border-radius:999px;background:#1f9d67;box-shadow:0 0 0 6px rgba(31,157,103,.13)}
.pptx-error{box-sizing:border-box;width:min(680px,calc(100% - 32px));margin:48px auto;padding:24px;border:1px solid rgba(28,43,58,.12);border-radius:14px;background:#fff;color:#1f2d3b;box-shadow:0 16px 42px rgba(25,42,54,.08)}
+.pptx-slideshow-button{position:sticky;top:12px;z-index:4;float:right;display:inline-flex;align-items:center;gap:8px;margin:0 0 12px;padding:8px 14px;border:1px solid rgba(42,94,144,.16);border-radius:999px;background:rgba(255,255,255,.94);color:#2a5e90;font:13px/1.2 inherit;cursor:pointer;box-shadow:0 10px 26px rgba(24,44,64,.12)}
+.pptx-slideshow-button:hover{border-color:rgba(42,94,144,.34);color:#1d4a75}
+.pptx-slideshow-button:focus-visible{outline:2px solid #2a5e90;outline-offset:2px}
+.pptx-slideshow-button[hidden]{display:none!important}
+.pptx-slideshow-button-glyph{font-size:11px;line-height:1}
+.pptx-slideshow-button-key{padding:1px 6px;border-radius:6px;background:rgba(42,94,144,.1);font-size:11px;letter-spacing:.04em}
+[data-viewer-theme='dark'] .pptx-slideshow-button{border-color:rgba(148,163,184,.2);background:rgba(15,23,42,.92);color:#cbd5e1}
+[data-viewer-theme='dark'] .pptx-slideshow-button-key{background:rgba(148,163,184,.16)}
.pptx-error strong{display:block;margin-bottom:10px;font-size:18px}
.pptx-error p{margin:0;color:#607282;line-height:1.7}
[data-viewer-theme='dark'] .pptx-viewer-shell{background:var(--file-viewer-render-surface-background,#101820);color:#e5eef8}
@@ -59,6 +67,13 @@ const pptxPrintStyle = `
}
`;
+const SLIDESHOW_HOTKEY_LABEL = 'F5 / P';
+
+// With several viewers on one page each renderer installs a document-level
+// F5/P listener. Keep one explicitly activated shell per document so shortcuts
+// never leak across viewers, host controls, or iframe documents.
+const activePptxShells = new WeakMap();
+
const createStyle = (documentRef: Document) => {
const style = documentRef.createElement('style');
style.textContent = pptxStyle;
@@ -395,9 +410,83 @@ export default async function renderPptx(
const errorText = createElement(documentRef, 'p');
error.append(errorTitle, errorText);
+ const slideshowButton = createElement(documentRef, 'button', 'pptx-slideshow-button');
+ slideshowButton.type = 'button';
+ slideshowButton.hidden = true;
+ const slideshowGlyph = createElement(documentRef, 'span', 'pptx-slideshow-button-glyph', '▶');
+ const slideshowText = createElement(documentRef, 'span', undefined, t('presentation.slideshow.start'));
+ const slideshowKey = createElement(documentRef, 'span', 'pptx-slideshow-button-key', SLIDESHOW_HOTKEY_LABEL);
+ slideshowButton.append(slideshowGlyph, slideshowText, slideshowKey);
+ slideshowButton.addEventListener('click', () => {
+ void viewer?.togglePresentation();
+ });
+
const surface = createElement(documentRef, 'div', 'pptx-render-surface');
- shell.append(loading, error, surface);
+ shell.append(loading, error, slideshowButton, surface);
target.replaceChildren(style, shell);
+
+ // A viewer owns the shortcut only after the user interacts with it. Moving
+ // focus or the pointer back to the host page releases that ownership.
+ const activateShell = () => {
+ activePptxShells.set(documentRef, shell);
+ };
+ const deactivateShell = (event: Event) => {
+ if (viewer?.presenting || activePptxShells.get(documentRef) !== shell) {
+ return;
+ }
+ if (event.composedPath().includes(shell)) {
+ return;
+ }
+ const targetElement = event.target as HTMLElement | null;
+ if (
+ event.type === 'focusin' &&
+ !targetElement?.matches('button, a, input, textarea, select, [contenteditable], [tabindex]')
+ ) {
+ return;
+ }
+ activePptxShells.delete(documentRef);
+ };
+ shell.addEventListener('pointerdown', activateShell);
+ shell.addEventListener('focusin', activateShell);
+ documentRef.addEventListener('pointerdown', deactivateShell);
+ documentRef.addEventListener('focusin', deactivateShell);
+
+ // F5 mirrors PowerPoint; P is the keyboard-only toggle for browsers where F5 is spoken for.
+ // Typing in a field must never start a slideshow, so editable targets are skipped.
+ const isEditableTarget = (node: EventTarget | null) => {
+ const element = node as HTMLElement | null;
+ if (!element || typeof element.closest !== 'function') {
+ return false;
+ }
+ return Boolean(element.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]'));
+ };
+
+ const handleShortcut = (event: KeyboardEvent) => {
+ if (disposed || !viewer || event.altKey || event.ctrlKey || event.metaKey) {
+ return;
+ }
+ if (event.key !== 'F5' && event.key !== 'p' && event.key !== 'P') {
+ return;
+ }
+ if (!shell.isConnected || isEditableTarget(event.target)) {
+ return;
+ }
+ // Only the focused/last-activated shell answers, and a key pressed inside
+ // another shell belongs to that shell.
+ if (activePptxShells.get(documentRef) !== shell) {
+ return;
+ }
+ const targetElement = event.target as HTMLElement | null;
+ if (targetElement && typeof targetElement.closest === 'function') {
+ const targetShell = targetElement.closest('.pptx-viewer-shell');
+ if (targetShell && targetShell !== shell) {
+ return;
+ }
+ }
+ event.preventDefault();
+ void viewer.togglePresentation();
+ };
+ documentRef.addEventListener('keydown', handleShortcut);
context?.registerThumbnailAdapter?.({
captureSource: 'embedded',
beforeCapture: async ({ signal }) => {
@@ -464,6 +553,12 @@ export default async function renderPptx(
error.hidden = state !== 'error';
errorText.textContent = errorMessage;
surface.classList.toggle('is-loading', state === 'loading');
+ const presenting = Boolean(viewer?.presenting);
+ slideshowButton.hidden = state !== 'ready' || (viewer?.slideCount ?? 0) === 0;
+ slideshowText.textContent = presenting
+ ? t('presentation.slideshow.exit')
+ : t('presentation.slideshow.start');
+ slideshowButton.setAttribute('aria-pressed', presenting ? 'true' : 'false');
};
const registerExportAdapter = () => {
@@ -531,6 +626,18 @@ export default async function renderPptx(
batchSize: 4,
overscanViewport: 1.5,
},
+ presentationLabels: {
+ exit: t('presentation.slideshow.exit'),
+ hint: t('presentation.slideshow.hint'),
+ },
+ onPresentationChange: state => {
+ if (!disposed) {
+ if (state.active) {
+ activePptxShells.set(documentRef, shell);
+ }
+ syncUi();
+ }
+ },
onSlideRendered: () => notifyProgressiveReady(),
onRenderComplete: () => {
if (disposed || context?.signal?.aborted) {
@@ -588,18 +695,36 @@ export default async function renderPptx(
}
};
- await openPresentation();
+ const cleanup = () => {
+ if (disposed) {
+ return;
+ }
+ disposed = true;
+ documentRef.removeEventListener('keydown', handleShortcut);
+ documentRef.removeEventListener('pointerdown', deactivateShell);
+ documentRef.removeEventListener('focusin', deactivateShell);
+ shell.removeEventListener('pointerdown', activateShell);
+ shell.removeEventListener('focusin', activateShell);
+ if (activePptxShells.get(documentRef) === shell) {
+ activePptxShells.delete(documentRef);
+ }
+ context?.registerExportAdapter?.(null);
+ context?.registerThumbnailAdapter?.(null);
+ unregisterFileViewerZoomProvider(shell);
+ viewer?.destroy();
+ viewer = null;
+ target.replaceChildren();
+ };
+
+ try {
+ await openPresentation();
+ } catch (error) {
+ cleanup();
+ throw error;
+ }
return {
$el: shell,
- unmount() {
- disposed = true;
- context?.registerExportAdapter?.(null);
- context?.registerThumbnailAdapter?.(null);
- unregisterFileViewerZoomProvider(shell);
- viewer?.destroy();
- viewer = null;
- target.replaceChildren();
- },
+ unmount: cleanup,
};
}