Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/public-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,6 @@ jobs:

- name: Browser smoke
run: pnpm verify:browser-smoke

- name: Verify PPTX slideshow
run: pnpm verify:pptx-slideshow
377 changes: 377 additions & 0 deletions apps/viewer-demo/scripts/verify-pptx-slideshow.mjs

Large diffs are not rendered by default.

83 changes: 83 additions & 0 deletions apps/viewer-demo/slideshow-test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PPTX slideshow test harness</title>
<style>
html, body { margin: 0; }
body {
font: 14px/1.5 system-ui, sans-serif;
background: #eef3f8;
color: #1f2d3b;
/* The deck must be scrollable so the scroll-restoration path is exercised. */
overflow-y: auto;
padding: 24px;
}
h1 { font-size: 18px; margin: 0 0 8px; }
p.note { color: #607282; margin: 0 0 20px; }
.viewer {
box-sizing: border-box;
min-height: 320px;
margin: 0 0 24px;
padding: 12px;
border: 1px solid rgba(42, 94, 144, .18);
border-radius: 10px;
background: #fff;
}
.viewer > h2 { font-size: 13px; margin: 0 0 8px; color: #41556b; }
.viewer > div { min-height: 240px; }
.controls { margin: 0 0 20px; }
.controls button {
margin: 0 8px 8px 0;
padding: 6px 12px;
border: 1px solid rgba(42, 94, 144, .3);
border-radius: 8px;
background: #fff;
color: #2a5e90;
cursor: pointer;
}
.controls button:hover { background: #f0f6fc; }
</style>
</head>
<body>
<h1>PPTX slideshow test harness</h1>
<p class="note">Driven by <code>verify-pptx-slideshow.mjs</code>. Exposes <code>window.__slideshowTest</code>.</p>

<div class="controls">
<button id="unrelated-host-action" type="button">Unrelated host action</button>
<button id="enter-shadow" type="button">Enter shadow (fullscreen)</button>
<button id="enter-default" type="button">Enter default</button>
<button id="enter-windowed" type="button">Enter windowed</button>
</div>

<section class="viewer">
<h2>default (non-windowed API)</h2>
<div id="viewer-default"></div>
</section>

<section class="viewer">
<h2>windowed (lazySlides)</h2>
<div id="viewer-windowed"></div>
</section>

<section class="viewer">
<h2>shadow root (non-windowed)</h2>
<div id="viewer-shadow-host"></div>
</section>

<section class="viewer">
<h2>renderer A (renderPptx)</h2>
<div id="renderer-a"></div>
</section>

<section class="viewer">
<h2>renderer B (renderPptx)</h2>
<div id="renderer-b"></div>
</section>

<div id="renderer-failure"></div>

<script type="module" src="/src/slideshow-test.ts"></script>
</body>
</html>
225 changes: 225 additions & 0 deletions apps/viewer-demo/src/slideshow-test.ts
Original file line number Diff line number Diff line change
@@ -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<void>
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<void>
fullscreenElementClass: () => string
}

declare global {
interface Window {
__slideshowTest?: SlideshowTestApi
}
}

const viewers = new Map<ViewerName, PptxViewer>()
const renderers = new Map<RendererName, FileViewerRenderedInstance>()

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<HTMLElement>(':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<HTMLButtonElement>('.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'
})
2 changes: 2 additions & 0 deletions apps/viewer-demo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
3 changes: 2 additions & 1 deletion apps/viewer-demo/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/contracts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/i18n/messages.ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ export const JA_JP_MESSAGES: Record<FileViewerMessageKey, string> = {
'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 で解析し、メインスレッドのブロックを防ぎます。',
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ const ZH_CN_MESSAGES: Record<FileViewerMessageKey, string> = {
'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 中解析,避免阻塞主线程。',
Expand Down Expand Up @@ -594,6 +597,9 @@ const EN_US_MESSAGES: Record<FileViewerMessageKey, string> = {
'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.',
Expand Down
2 changes: 2 additions & 0 deletions packages/renderers/pptx/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading