diff --git a/.github/scripts/dependency-cohort.test.mjs b/.github/scripts/dependency-cohort.test.mjs
index 652bbb2dc..d4cf73143 100644
--- a/.github/scripts/dependency-cohort.test.mjs
+++ b/.github/scripts/dependency-cohort.test.mjs
@@ -43,6 +43,9 @@ function angularCohort(manifest) {
const fixture = JSON.parse(read('apps/component-demo/test/angular-pptx/package.json'))
test('cold Angular consumer pins coherent framework and tooling groups', () =>
angularCohort(fixture))
+test('cold Angular consumer declares the reviewed Vite security runtime', () => {
+ assert.equal(fixture.overrides['@angular/build'].vite, '8.3.4')
+})
test('independently released tooling patches are accepted', () => {
const changed = structuredClone(fixture)
for (const section of ['dependencies', 'devDependencies'])
@@ -76,6 +79,12 @@ test('missing members, ranges and prerelease versions are rejected', () => {
delete changed.devDependencies['@angular/compiler-cli']
assert.throws(() => angularCohort(changed), /Missing Angular/)
})
+test('thumbnail declares and physically installs the reviewed happy-dom security runtime', () => {
+ const manifest = JSON.parse(read('packages/thumbnail/package.json'))
+ assert.equal(manifest.devDependencies['happy-dom'], '^21.0.4')
+ const require = createRequire(new URL('../../packages/thumbnail/package.json', import.meta.url))
+ assert.equal(require('happy-dom/package.json').version, '21.0.4')
+})
test('root and thumbnail declarations agree with the reviewed workspace Vitest cohort', () => {
const root = JSON.parse(read('package.json'))
const thumbnail = JSON.parse(read('packages/thumbnail/package.json'))
diff --git a/.github/scripts/prepare-browser-apt.py b/.github/scripts/prepare-browser-apt.py
new file mode 100644
index 000000000..577201e3e
--- /dev/null
+++ b/.github/scripts/prepare-browser-apt.py
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+"""Bound Ubuntu downloads on an ephemeral GitHub-hosted browser runner."""
+
+import argparse
+from pathlib import Path
+import re
+
+
+ARCHIVE = "https://archive.ubuntu.com/ubuntu/"
+SECURITY = "https://security.ubuntu.com/ubuntu/"
+AZURE = "http://azure.archive.ubuntu.com/ubuntu/"
+OPTIONS = '''// Browser CI: retain APT's signed metadata and package hash verification.
+Acquire::Retries "2";
+Acquire::http::Timeout "30";
+Acquire::https::Timeout "30";
+'''
+
+
+def prepare(mirrors, config):
+ # These HTTPS alternatives are already in the official runner mirror list:
+ # https://github.com/actions/runner-images/blob/main/images/ubuntu/scripts/build/configure-apt-sources.sh
+ # A slow but responsive Azure download does not trigger APT's idle timeout.
+ # Only change that known mirror list, never sources, suites or signing keys.
+ for target in (mirrors, config):
+ if target.is_symlink():
+ raise ValueError("APT preparation refuses symbolic links")
+ if target.exists() and not target.is_file():
+ raise ValueError("APT preparation requires regular configuration files")
+ if not target.parent.is_dir():
+ raise ValueError("APT preparation requires existing parent directories")
+ entries = []
+ for line in mirrors.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ match = re.fullmatch(r"(\S+)\s+priority:\d+", line)
+ if not match or match[1] not in (AZURE, ARCHIVE, SECURITY):
+ raise ValueError("Unrecognized runner mirror configuration; no files changed")
+ entries.append(match[1])
+ if len(set(entries)) != len(entries) or not {ARCHIVE, SECURITY}.issubset(entries):
+ raise ValueError("Expected unique official HTTPS archive alternatives; no files changed")
+ mirrors.write_text(f"{ARCHIVE}\tpriority:1\n{SECURITY}\tpriority:2\n")
+ config.write_text(OPTIONS)
+ print("Ubuntu browser downloads: official HTTPS archives, two retries, 30s idle timeout")
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--mirror-list", type=Path, default=Path("/etc/apt/apt-mirrors.txt"))
+ parser.add_argument(
+ "--apt-config", type=Path,
+ default=Path("/etc/apt/apt.conf.d/80-file-viewer-browser"),
+ )
+ args = parser.parse_args()
+ try:
+ prepare(args.mirror_list, args.apt_config)
+ except (OSError, ValueError) as error:
+ parser.exit(1, f"APT preparation failed: {error}\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/scripts/prepare-browser-apt.test.mjs b/.github/scripts/prepare-browser-apt.test.mjs
new file mode 100644
index 000000000..6687cfd00
--- /dev/null
+++ b/.github/scripts/prepare-browser-apt.test.mjs
@@ -0,0 +1,114 @@
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import test from 'node:test'
+
+const script = new URL('./prepare-browser-apt.py', import.meta.url).pathname
+const archive = 'https://archive.ubuntu.com/ubuntu/'
+const security = 'https://security.ubuntu.com/ubuntu/'
+const original = `http://azure.archive.ubuntu.com/ubuntu/\tpriority:1\n${archive}\tpriority:2\n${security}\tpriority:3\n`
+
+function fixture(t, source = original) {
+ const directory = mkdtempSync(join(tmpdir(), 'file-viewer-apt-'))
+ t.after(() => rmSync(directory, { recursive: true, force: true }))
+ const mirrors = join(directory, 'apt-mirrors.txt')
+ const config = join(directory, '80-file-viewer-browser')
+ writeFileSync(mirrors, source)
+ writeFileSync(config, '// original config\n')
+ return {
+ directory,
+ mirrors,
+ config,
+ run: () =>
+ spawnSync('python3', [script, '--mirror-list', mirrors, '--apt-config', config], {
+ encoding: 'utf8'
+ })
+ }
+}
+
+test('the actual runner mirror list selects its official HTTPS alternatives and retains trust inputs', (t) => {
+ const f = fixture(t)
+ const sources = join(f.directory, 'ubuntu.sources')
+ const keyring = join(f.directory, 'ubuntu-archive-keyring.gpg')
+ const source =
+ 'URIs: mirror+file:/etc/apt/apt-mirrors.txt\nSuites: noble noble-security\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n'
+ writeFileSync(sources, source)
+ writeFileSync(keyring, 'unchanged signing key fixture')
+ const result = f.run()
+ assert.equal(result.status, 0, result.stderr)
+ assert.equal(readFileSync(f.mirrors, 'utf8'), `${archive}\tpriority:1\n${security}\tpriority:2\n`)
+ const options = readFileSync(f.config, 'utf8')
+ assert.match(options, /Acquire::Retries "2"/)
+ assert.match(options, /Acquire::http::Timeout "30"/)
+ assert.match(options, /Acquire::https::Timeout "30"/)
+ assert.doesNotMatch(
+ options,
+ /AllowUnauthenticated|AllowInsecure|Verify-Peer|Verify-Host|Check-Valid-Until/
+ )
+ assert.equal(readFileSync(sources, 'utf8'), source)
+ assert.equal(readFileSync(keyring, 'utf8'), 'unchanged signing key fixture')
+ assert.equal(f.run().status, 0)
+ assert.equal(readFileSync(f.mirrors, 'utf8'), `${archive}\tpriority:1\n${security}\tpriority:2\n`)
+})
+
+for (const [name, source] of [
+ ['unknown mirror', original + 'https://example.invalid/ubuntu/ priority:4\n'],
+ ['missing HTTPS archive', `${security} priority:1\n`],
+ ['insecure alternative', original.replace(archive, 'http://archive.ubuntu.com/ubuntu/')],
+ ['duplicate archive', original + `${archive} priority:4\n`],
+ ['unknown mirror metadata', original.replace('priority:2', 'priority:2 arch:amd64')]
+]) {
+ test(`an ${name} fails before changing either configuration file`, (t) => {
+ const f = fixture(t, source)
+ assert.notEqual(f.run().status, 0)
+ assert.equal(readFileSync(f.mirrors, 'utf8'), source)
+ assert.equal(readFileSync(f.config, 'utf8'), '// original config\n')
+ })
+}
+
+test('a symlink cannot redirect the privileged config write', (t) => {
+ const f = fixture(t)
+ const outside = join(f.directory, 'unrelated-config')
+ writeFileSync(outside, 'unchanged')
+ rmSync(f.config)
+ symlinkSync(outside, f.config)
+ assert.notEqual(f.run().status, 0)
+ assert.equal(readFileSync(outside, 'utf8'), 'unchanged')
+ assert.equal(readFileSync(f.mirrors, 'utf8'), original)
+})
+
+test('all browser CI entrypoints prepare APT before a bounded, unchanged browser install', () => {
+ for (const file of [
+ 'public-ci.yml',
+ 'geo3d-focused-validation.yml',
+ 'geo3d-branch-validation.yml'
+ ]) {
+ const source = readFileSync(new URL(`../workflows/${file}`, import.meta.url), 'utf8')
+ const steps = source.split(/\n\s+- name: /)
+ for (const [index, step] of steps.entries()) {
+ if (!step.includes('pnpm exec playwright install --with-deps')) continue
+ assert.match(steps[index - 1], /sudo python3 \.github\/scripts\/prepare-browser-apt\.py/)
+ assert.match(step, /timeout-minutes: 10/)
+ assert.match(step, /install --with-deps chromium (?:firefox )?webkit/)
+ }
+ }
+ const workflow = readFileSync(new URL('../workflows/public-ci.yml', import.meta.url), 'utf8')
+ const fonts = workflow.split('- name: Install the Noto CJK font cohort')[1]?.split('- name:')[0]
+ assert.ok(fonts)
+ assert.match(fonts, /timeout-minutes: 10/)
+ assert.match(fonts, /dpkg-query.*fonts-noto-cjk/)
+ assert.match(fonts, /apt-get install -y --no-install-recommends fonts-noto-cjk/)
+ assert.match(fonts, /grep -q 'Noto Serif CJK SC'/)
+ assert.match(fonts, /font-cohort\.log/)
+ const browsers = workflow
+ .split('- name: Verify PDF hand tool and native DOC font table in Chromium and WebKit')[1]
+ ?.split('- name:')[0]
+ assert.match(browsers, /renderer-pdf verify:hand-tool/)
+ assert.match(browsers, /@file-viewer\/doc verify:font-table-browser/)
+ assert.ok(
+ workflow.indexOf('- name: Install the Noto CJK font cohort') <
+ workflow.indexOf('- name: Verify PDF hand tool')
+ )
+})
diff --git a/.github/workflows/geo3d-branch-validation.yml b/.github/workflows/geo3d-branch-validation.yml
index f54e70e27..79bf9722c 100644
--- a/.github/workflows/geo3d-branch-validation.yml
+++ b/.github/workflows/geo3d-branch-validation.yml
@@ -87,9 +87,14 @@ jobs:
id: focused
run: bash "$RUNNER_TEMP/geo3d-record.sh" geo3d-entry pnpm test:geo3d-entry
+ - name: Configure bounded Ubuntu archive downloads
+ if: ${{ !cancelled() && steps.install.outcome == 'success' }}
+ run: sudo python3 .github/scripts/prepare-browser-apt.py
+
- name: Install Chromium and WebKit
id: browsers
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
+ timeout-minutes: 10
run: bash "$RUNNER_TEMP/geo3d-record.sh" playwright-install pnpm exec playwright install --with-deps chromium webkit
- name: Focused Geo3D browser smoke
diff --git a/.github/workflows/geo3d-focused-validation.yml b/.github/workflows/geo3d-focused-validation.yml
index 072b6d6e9..0f46ba1d1 100644
--- a/.github/workflows/geo3d-focused-validation.yml
+++ b/.github/workflows/geo3d-focused-validation.yml
@@ -53,9 +53,13 @@ jobs:
run: |
mkdir -p output/geo3d-focused
pnpm test:geo3d-entry 2>&1 | tee output/geo3d-focused/entry.log
+ - name: Configure bounded Ubuntu archive downloads
+ if: ${{ !cancelled() && steps.install.outcome == 'success' }}
+ run: sudo python3 .github/scripts/prepare-browser-apt.py
- name: Install Chromium
id: browser
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
+ timeout-minutes: 10
run: pnpm exec playwright install --with-deps chromium webkit
- name: Browser datasets and resource ownership
if: >-
diff --git a/.github/workflows/public-ci.yml b/.github/workflows/public-ci.yml
index e5cbcc5c9..66c28e0d2 100644
--- a/.github/workflows/public-ci.yml
+++ b/.github/workflows/public-ci.yml
@@ -56,9 +56,14 @@ jobs:
if: steps.impact.outputs.runtime != 'false'
run: pnpm --filter @file-viewer/core --filter @file-viewer/renderer-text --filter @file-viewer/capability-mermaid --filter @file-viewer/renderer-drawing --filter @file-viewer/renderer-dicom build
+ - name: Configure bounded Ubuntu archive downloads
+ if: steps.impact.outputs.runtime != 'false'
+ run: sudo python3 .github/scripts/prepare-browser-apt.py
+
- name: Install Chromium and WebKit
id: browsers
if: steps.impact.outputs.runtime != 'false'
+ timeout-minutes: 10
run: pnpm exec playwright install --with-deps chromium webkit
- name: Verify DICOM cold consumers, six transfer syntaxes and visible PNGs
@@ -200,8 +205,13 @@ jobs:
if: steps.impact.outputs.runtime != 'false'
run: pnpm type-check
+ - name: Configure bounded Ubuntu archive downloads
+ if: steps.impact.outputs.runtime != 'false'
+ run: sudo python3 .github/scripts/prepare-browser-apt.py
+
- name: Install Chromium, Firefox and WebKit
if: steps.impact.outputs.runtime != 'false'
+ timeout-minutes: 10
run: pnpm exec playwright install --with-deps chromium firefox webkit
- name: Run the issue regression gates
@@ -279,17 +289,24 @@ jobs:
if: steps.impact.outputs.runtime != 'false'
run: pnpm --filter @file-viewer/vite-plugin verify:lite-consumer
- - name: Verify PDF hand tool and native DOC font table in Chromium and WebKit
+ - name: Install the Noto CJK font cohort
if: steps.impact.outputs.runtime != 'false'
+ timeout-minutes: 10
run: |
# WenQuanYi's CJK vertical advances can be zero in Chromium. The
# geometry contract needs the same reviewed Noto cohort as DOCX CI.
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends fonts-noto-cjk
+ if ! dpkg-query -W -f='${Status}' fonts-noto-cjk 2>/dev/null | grep -Fxq 'install ok installed'; then
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends fonts-noto-cjk
+ fi
mkdir -p output/doc-font-table
fc-match -f '%{family}\n' 'Noto Serif CJK SC' | tee output/doc-font-table/font-cohort.log
grep -q 'Noto Serif CJK SC' output/doc-font-table/font-cohort.log
dpkg-query -W fonts-noto-cjk >> output/doc-font-table/font-cohort.log
+
+ - name: Verify PDF hand tool and native DOC font table in Chromium and WebKit
+ if: steps.impact.outputs.runtime != 'false'
+ run: |
pnpm --filter @file-viewer/renderer-pdf verify:hand-tool
pnpm --filter @file-viewer/doc verify:font-table-browser
@@ -376,10 +393,18 @@ jobs:
if: steps.impact.outputs.runtime != 'false'
run: pnpm verify:chm-renderer
- - name: Verify Vue 3 toolbar in Chromium
+ - name: Verify Vue 3 and Web toolbar in Chromium and WebKit
if: steps.impact.outputs.runtime != 'false'
run: pnpm verify:vue3-toolbar-browser
+ - name: Retain zoom toolbar browser evidence
+ if: always() && steps.impact.outputs.runtime != 'false'
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: zoom-toolbar-${{ github.sha }}
+ path: output/zoom-toolbar-browser/**
+ retention-days: 7
+
- name: Verify untrusted renderer markup sanitization
if: steps.impact.outputs.runtime != 'false'
run: pnpm verify:renderer-sanitization
diff --git a/apps/component-demo/scripts/verify-angular-pptx-consumer.mjs b/apps/component-demo/scripts/verify-angular-pptx-consumer.mjs
index dca74baf4..fb7e5bb60 100644
--- a/apps/component-demo/scripts/verify-angular-pptx-consumer.mjs
+++ b/apps/component-demo/scripts/verify-angular-pptx-consumer.mjs
@@ -66,7 +66,8 @@ const report = {
angular: consumerRequire('@angular/core/package.json').version,
tooling: {
build: consumerRequire('@angular/build/package.json').version,
- cli: consumerRequire('@angular/cli/package.json').version
+ cli: consumerRequire('@angular/cli/package.json').version,
+ vite: createRequire(consumerRequire.resolve('@angular/build/package.json'))('vite/package.json').version
},
baseHref: '/ui/',
expectedSlides,
@@ -79,6 +80,7 @@ const acceptedFixture = JSON.parse(
assert.equal(report.angular, acceptedFixture.dependencies['@angular/core'])
assert.equal(report.tooling.build, acceptedFixture.devDependencies['@angular/build'])
assert.equal(report.tooling.cli, acceptedFixture.devDependencies['@angular/cli'])
+assert.equal(report.tooling.vite, acceptedFixture.overrides['@angular/build'].vite)
let dev,
server,
page,
diff --git a/apps/component-demo/scripts/verify-vue3-toolbar-browser.mjs b/apps/component-demo/scripts/verify-vue3-toolbar-browser.mjs
index 0a849588d..76aa85abf 100644
--- a/apps/component-demo/scripts/verify-vue3-toolbar-browser.mjs
+++ b/apps/component-demo/scripts/verify-vue3-toolbar-browser.mjs
@@ -4,6 +4,7 @@ import { createServer } from 'node:http'
import { createRequire } from 'node:module'
import { delimiter, extname, join, normalize, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
+import { verifyZoomToolbarBrowser } from './verify-zoom-toolbar-browser.mjs'
// The verifier runs from the repository root in local and public CI.
const distDir = resolve('apps/component-demo/dist')
@@ -15,11 +16,11 @@ const importPlaywright = async () => {
try {
return await import('playwright')
} catch (originalError) {
- const candidatePaths = process.env.PATH
- ?.split(delimiter)
- .filter(pathEntry => pathEntry.endsWith(`${sep}node_modules${sep}.bin`))
- .map(binDir => resolve(binDir, '..'))
- .filter(pathEntry => existsSync(pathEntry)) || []
+ const candidatePaths =
+ process.env.PATH?.split(delimiter)
+ .filter((pathEntry) => pathEntry.endsWith(`${sep}node_modules${sep}.bin`))
+ .map((binDir) => resolve(binDir, '..'))
+ .filter((pathEntry) => existsSync(pathEntry)) || []
for (const candidatePath of candidatePaths) {
try {
@@ -40,10 +41,12 @@ const importPlaywright = async () => {
}
const playwrightModule = await importPlaywright()
-const { chromium } = playwrightModule.chromium ? playwrightModule : playwrightModule.default
+const { chromium, webkit } = playwrightModule.chromium ? playwrightModule : playwrightModule.default
if (!existsSync(entryPath)) {
- throw new Error(`Built Vue 3 component demo is missing: ${entryPath}. Run pnpm build:component-demo first.`)
+ throw new Error(
+ `Built Vue 3 component demo is missing: ${entryPath}. Run pnpm build:component-demo first.`
+ )
}
const contentTypes = {
@@ -53,6 +56,9 @@ const contentTypes = {
'.md': 'text/markdown; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.png': 'image/png',
+ '.pdf': 'application/pdf',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
'.svg': 'image/svg+xml',
'.wasm': 'application/wasm'
}
@@ -60,9 +66,8 @@ const contentTypes = {
const server = createServer((request, response) => {
const requestUrl = new URL(request.url || '/', 'http://127.0.0.1')
const cleanPath = decodeURIComponent(requestUrl.pathname)
- const candidate = cleanPath === '/'
- ? entryPath
- : join(distDir, normalize(cleanPath).replace(/^[/\\]+/, ''))
+ const candidate =
+ cleanPath === '/' ? entryPath : join(distDir, normalize(cleanPath).replace(/^[/\\]+/, ''))
const filePath = existsSync(candidate) && statSync(candidate).isFile() ? candidate : entryPath
response.writeHead(200, {
'Content-Type': contentTypes[extname(filePath).toLowerCase()] || 'application/octet-stream',
@@ -84,10 +89,10 @@ try {
browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
const errors = []
- page.on('console', message => {
+ page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text())
})
- page.on('pageerror', error => errors.push(error.message))
+ page.on('pageerror', (error) => errors.push(error.message))
await page.goto(`${baseUrl}/vue3.html?smoke=toolbar-slots`, {
waitUntil: 'domcontentloaded',
@@ -107,7 +112,7 @@ try {
const toolbar = root?.querySelector('.viewer-actions')
return {
hasOpenShadowRoot: !!root,
- childOrder: Array.from(toolbar?.children || []).map(element => {
+ childOrder: Array.from(toolbar?.children || []).map((element) => {
if (element.querySelector('[data-testid="vue3-toolbar-start"]')) return 'start'
if (element.classList.contains('viewer-search-actions')) return 'search'
if (element.querySelector('[data-testid="vue3-toolbar-end"]')) return 'end'
@@ -115,24 +120,48 @@ try {
})
}
})
- assert.equal(shadowContract.hasOpenShadowRoot, true, 'The default Vue 3 integration must use its open Shadow DOM path.')
+ assert.equal(
+ shadowContract.hasOpenShadowRoot,
+ true,
+ 'The default Vue 3 integration must use its open Shadow DOM path.'
+ )
assert.deepEqual(
shadowContract.childOrder,
['start', 'search', 'end'],
'toolbar-start and toolbar-end must wrap the built-in search group in Shadow DOM.'
)
- assert.equal(await startSlot.getAttribute('data-api-search'), 'true', 'toolbar-start did not receive the public API.')
- assert.equal(await endSlot.getAttribute('data-api-search'), 'true', 'toolbar-end did not receive the public API.')
- assert.equal(await startSlot.getAttribute('data-download-available'), 'true', 'Slot availability did not update after render.')
- assert.ok(Number(await startSlot.getAttribute('data-zoom-scale')) > 0, 'Slot zoom state is invalid.')
+ assert.equal(
+ await startSlot.getAttribute('data-api-search'),
+ 'true',
+ 'toolbar-start did not receive the public API.'
+ )
+ assert.equal(
+ await endSlot.getAttribute('data-api-search'),
+ 'true',
+ 'toolbar-end did not receive the public API.'
+ )
+ assert.equal(
+ await startSlot.getAttribute('data-download-available'),
+ 'true',
+ 'Slot availability did not update after render.'
+ )
+ assert.ok(
+ Number(await startSlot.getAttribute('data-zoom-scale')) > 0,
+ 'Slot zoom state is invalid.'
+ )
await startSlot.click()
await page.locator('.flyfish-search-match--active').first().waitFor({ state: 'visible', timeout })
- await page.waitForFunction(() => {
- const button = document.querySelector('#vue3-viewer .file-viewer-vue3-shadow-host')
- ?.shadowRoot?.querySelector('[data-testid="vue3-toolbar-start"]')
- return Number(button?.getAttribute('data-search-total') || 0) > 0
- }, undefined, { timeout })
+ await page.waitForFunction(
+ () => {
+ const button = document
+ .querySelector('#vue3-viewer .file-viewer-vue3-shadow-host')
+ ?.shadowRoot?.querySelector('[data-testid="vue3-toolbar-start"]')
+ return Number(button?.getAttribute('data-search-total') || 0) > 0
+ },
+ undefined,
+ { timeout }
+ )
await endSlot.click()
assert.equal(
await page.evaluate(() => document.body.dataset.vue3ToolbarEndClicked),
@@ -156,11 +185,20 @@ try {
'A search-only toolbar must be absent when the active renderer cannot search.'
)
- const actionableErrors = errors.filter(message => !/favicon|ResizeObserver loop/i.test(message))
+ const actionableErrors = errors.filter((message) => !/favicon|ResizeObserver loop/i.test(message))
assert.deepEqual(actionableErrors, [], `Browser console errors:\n${actionableErrors.join('\n')}`)
- console.log('[verify-vue3-toolbar-browser] Vue 3 full, Shadow DOM slots, reactive slot API and renderer-bounded search verified.')
+ console.log(
+ '[verify-vue3-toolbar-browser] Vue 3 full, Shadow DOM slots, reactive slot API and renderer-bounded search verified.'
+ )
+ await verifyZoomToolbarBrowser({ browser, baseUrl, engine: 'chromium' })
+ const webkitBrowser = await webkit.launch({ headless: true })
+ try {
+ await verifyZoomToolbarBrowser({ browser: webkitBrowser, baseUrl, engine: 'webkit' })
+ } finally {
+ await webkitBrowser.close()
+ }
} finally {
await browser?.close()
- await new Promise(resolveClose => server.close(resolveClose))
+ await new Promise((resolveClose) => server.close(resolveClose))
}
diff --git a/apps/component-demo/scripts/verify-zoom-toolbar-browser.mjs b/apps/component-demo/scripts/verify-zoom-toolbar-browser.mjs
new file mode 100644
index 000000000..8e25d9cfd
--- /dev/null
+++ b/apps/component-demo/scripts/verify-zoom-toolbar-browser.mjs
@@ -0,0 +1,294 @@
+import assert from 'node:assert/strict'
+import { mkdir, writeFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+
+export async function verifyZoomToolbarBrowser({ browser, baseUrl, engine }) {
+ const directory = resolve('output/zoom-toolbar-browser', engine)
+ await mkdir(directory, { recursive: true })
+ const report = {
+ engine,
+ browser: browser.version(),
+ cases: [],
+ externalRequests: [],
+ pageErrors: [],
+ passed: false
+ }
+ const pages = []
+ const within = async (promise, label) => {
+ let timer
+ try {
+ return await Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), 15000)
+ })
+ ])
+ } finally {
+ clearTimeout(timer)
+ }
+ }
+ const open = async (path) => {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: 'en-US' })
+ pages.push(page)
+ page.on('pageerror', (error) => report.pageErrors.push(error.message))
+ await page.route('**/*', (route) => {
+ const url = route.request().url()
+ if (url.startsWith(baseUrl + '/') || /^(?:blob:|data:)/.test(url)) return route.continue()
+ report.externalRequests.push(url)
+ return route.abort()
+ })
+ await page.goto(baseUrl + path, { waitUntil: 'domcontentloaded' })
+ return page
+ }
+ const frames = (page) =>
+ page.evaluate(
+ () =>
+ new Promise((resolveFrame) =>
+ requestAnimationFrame(() => requestAnimationFrame(resolveFrame))
+ )
+ )
+ const imageReady = async (page) => {
+ const image = page.locator('.image-viewer img').first()
+ await image.waitFor({ state: 'visible' })
+ await image.evaluate((image) => image.decode())
+ return image
+ }
+ try {
+ for (const integration of ['web', 'vue']) {
+ const page = await open(
+ integration === 'web'
+ ? '/custom-element.html?url=/example/pic.png'
+ : '/vue3.html?smoke=zoom-boundary&url=/example/pic.png'
+ )
+ await imageReady(page)
+ const group = page
+ .locator(integration === 'web' ? '.file-viewer-web-toolbar-group' : '.viewer-zoom-actions')
+ .first()
+ const plus =
+ integration === 'web'
+ ? group.locator('button').filter({ hasText: /^\+$/ })
+ : group.locator('[part~="zoom-in-button"]')
+ const minus =
+ integration === 'web'
+ ? group.locator('button').filter({ hasText: /^-$/ })
+ : group.locator('[part~="zoom-out-button"]')
+ const reset =
+ integration === 'web'
+ ? group.locator('button').filter({ hasText: /^1:1$/ })
+ : group.locator('[part~="zoom-reset-button"]')
+ const meter = group.locator(
+ integration === 'web' ? '.file-viewer-web-zoom-meter' : '.viewer-zoom-meter'
+ )
+ await plus.waitFor({ state: 'visible' })
+ await frames(page)
+ const originalLabel = await meter.textContent()
+ assert.equal(
+ await reset.count(),
+ 1,
+ `${integration}: reset slot must exist before the first zoom`
+ )
+ assert.equal(await reset.isDisabled(), true)
+ const origin = { plus: await plus.boundingBox(), minus: await minus.boundingBox() }
+ assert.ok(origin.plus && origin.minus)
+ for (let click = 0; click < 48; click += 1)
+ await page.mouse.click(
+ origin.plus.x + origin.plus.width / 2,
+ origin.plus.y + origin.plus.height / 2
+ )
+ await frames(page)
+ assert.equal(await plus.isVisible(), true)
+ assert.equal(
+ await plus.isDisabled(),
+ true,
+ `${integration}: repeated fixed-position clicks must remain at maximum zoom`
+ )
+ assert.equal((await meter.textContent()).trim(), '500%')
+ assert.deepEqual(
+ await plus.boundingBox(),
+ origin.plus,
+ `${integration}: zoom-in position changed`
+ )
+ await page.screenshot({ path: `${directory}/${integration}-maximum.png` })
+ for (let click = 0; click < 48; click += 1)
+ await page.mouse.click(
+ origin.minus.x + origin.minus.width / 2,
+ origin.minus.y + origin.minus.height / 2
+ )
+ await frames(page)
+ assert.equal(await minus.isVisible(), true)
+ assert.equal(
+ await minus.isDisabled(),
+ true,
+ `${integration}: minimum zoom button must stay disabled in place`
+ )
+ assert.deepEqual(
+ await minus.boundingBox(),
+ origin.minus,
+ `${integration}: zoom-out position changed`
+ )
+ await page.screenshot({ path: `${directory}/${integration}-minimum.png` })
+ await reset.click()
+ await frames(page)
+ assert.equal(await meter.textContent(), originalLabel)
+ report.cases.push({
+ integration,
+ name: 'fixed-position rapid clicks at both limits and reset',
+ passed: true
+ })
+
+ if (integration === 'web') {
+ await page.evaluate(() => {
+ const element = document.getElementById('custom-element-viewer')
+ element.options = {
+ ...element.options,
+ toolbar: { position: 'bottom-right', items: { 'zoom-reset': false } }
+ }
+ })
+ await reset.waitFor({ state: 'detached' })
+ assert.equal(await plus.isVisible(), true)
+ assert.equal(await minus.isVisible(), true)
+ assert.equal(await meter.evaluate((element) => element.tagName), 'SPAN')
+ report.cases.push({
+ integration,
+ name: 'hide reset with existing items policy',
+ passed: true
+ })
+
+ let releaseOld
+ let oldRequested
+ const requested = new Promise((resolveRequest) => {
+ oldRequested = resolveRequest
+ })
+ const released = new Promise((resolveRelease) => {
+ releaseOld = resolveRelease
+ })
+ let settleOld
+ const oldSettled = new Promise((resolveOld) => {
+ settleOld = resolveOld
+ })
+ await page.route('**/delayed398.svg', async (route) => {
+ oldRequested()
+ await released
+ try {
+ await route.fulfill({
+ contentType: 'image/svg+xml',
+ body: ''
+ })
+ } catch (error) {
+ // The old request may already have been aborted by the new load.
+ assert.match(String(error), /abort|closed|handled|invalid|cancel/i)
+ } finally {
+ settleOld()
+ }
+ })
+ const width = await page
+ .locator('.image-viewer img')
+ .first()
+ .evaluate((image) => {
+ image.dataset.github398Old = 'true'
+ return image.naturalWidth
+ })
+ assert.ok(width > 2)
+ await page.evaluate(() => {
+ const element = document.getElementById('custom-element-viewer')
+ element.filename = 'delayed398.svg'
+ element.src = '/delayed398.svg'
+ })
+ await within(requested, 'old source request')
+ await page.evaluate(() => {
+ const element = document.getElementById('custom-element-viewer')
+ element.filename = 'pic.png'
+ element.src = '/example/pic.png'
+ })
+ const replacement = page.locator('.image-viewer img:not([data-github398-old])').first()
+ await replacement.waitFor({ state: 'visible' })
+ await replacement.evaluate((image) => image.decode())
+ assert.equal(await replacement.evaluate((image) => image.naturalWidth), width)
+ await replacement.evaluate((image) => {
+ image.dataset.github398New = 'true'
+ })
+ releaseOld()
+ await within(oldSettled, 'old source cancellation or response')
+ await frames(page)
+ assert.equal(
+ await page
+ .locator('.image-viewer img')
+ .first()
+ .evaluate((image) => image.naturalWidth),
+ width
+ )
+ assert.equal(
+ await page.locator('.image-viewer img').first().getAttribute('data-github398-new'),
+ 'true'
+ )
+ assert.equal(await plus.isVisible(), true)
+ report.cases.push({
+ integration,
+ name: 'cancel old source and ignore its delayed response',
+ passed: true
+ })
+ }
+ }
+ const hiddenReset = await open('/vue3.html?smoke=zoom-reset-hidden&url=/example/pic.png')
+ await imageReady(hiddenReset)
+ assert.equal(await hiddenReset.locator('[part~="zoom-reset-button"]').count(), 0)
+ assert.equal(
+ await hiddenReset.locator('.viewer-zoom-meter').evaluate((element) => element.tagName),
+ 'SPAN'
+ )
+ assert.equal(await hiddenReset.locator('[part~="zoom-in-button"]').isVisible(), true)
+ report.cases.push({
+ integration: 'vue',
+ name: 'hide reset with existing items policy',
+ passed: true
+ })
+
+ const denied = await open('/vue3.html?smoke=zoom-in-denied&url=/example/pic.png')
+ await imageReady(denied)
+ assert.equal(await denied.locator('[part~="zoom-in-button"]').count(), 0)
+ assert.equal(await denied.locator('[part~="zoom-out-button"]').isVisible(), true)
+ report.cases.push({
+ integration: 'vue',
+ name: 'preserve explicit zoom permission hiding',
+ passed: true
+ })
+
+ const pdf = await open('/vue3.html?smoke=zoom-pdf-hidden&url=/example/pdf.pdf')
+ await pdf
+ .locator('.pdfViewer .page canvas')
+ .first()
+ .waitFor({ state: 'visible', timeout: 60000 })
+ assert.equal(await pdf.locator('.pdf-toolbar-group--zoom').count(), 0)
+ assert.equal(await pdf.locator('.pdf-toolbar').isVisible(), true)
+ assert.equal(await pdf.locator('.pdf-toolbar-group--rotate button').count(), 2)
+ await pdf.locator('.pdf-toolbar-group--rotate button').last().click()
+ await pdf.locator('.pdf-rotation-meter').filter({ hasText: '90°' }).waitFor()
+ const total = Number(
+ (await pdf.locator('.pdf-page-meter').textContent()).split('/').at(-1)?.trim()
+ )
+ assert.ok(total > 0)
+ if (total > 1) {
+ await pdf.locator('.pdf-toolbar-group').first().locator('button').last().click()
+ await pdf.locator('.pdf-page-meter strong').filter({ hasText: /^2$/ }).waitFor()
+ }
+ await pdf.screenshot({ path: `${directory}/pdf-zoom-hidden.png` })
+ report.cases.push({
+ integration: 'vue/pdf',
+ name: 'zoom-only hiding preserves working navigation and rotation',
+ pages: total,
+ passed: true
+ })
+ assert.deepEqual(report.externalRequests, [])
+ assert.deepEqual(report.pageErrors, [])
+ report.passed = true
+ console.log(
+ `[verify-zoom-toolbar-browser] ${engine}: ${report.cases.length} real UI cases passed.`
+ )
+ } catch (error) {
+ report.error = error instanceof Error ? error.stack : String(error)
+ throw error
+ } finally {
+ await writeFile(`${directory}/report.json`, JSON.stringify(report, null, 2) + '\n')
+ await Promise.all(pages.map((page) => page.close()))
+ }
+}
diff --git a/apps/component-demo/src/vue3-demo.ts b/apps/component-demo/src/vue3-demo.ts
index bda9c5fc6..90149a86a 100644
--- a/apps/component-demo/src/vue3-demo.ts
+++ b/apps/component-demo/src/vue3-demo.ts
@@ -11,11 +11,13 @@ import './styles.css'
const host = document.getElementById('vue3-viewer')
const params = new URLSearchParams(window.location.search)
const toolbarSmoke = params.get('smoke')
-const defaultUrl = toolbarSmoke === 'toolbar-slots'
- ? '/example/markdown.md'
- : toolbarSmoke === 'search-boundary'
- ? '/example/pic.png'
- : '/example/word.docx'
+const zoomSmoke = toolbarSmoke?.startsWith('zoom-') ?? false
+const defaultUrl =
+ toolbarSmoke === 'toolbar-slots'
+ ? '/example/markdown.md'
+ : toolbarSmoke === 'search-boundary'
+ ? '/example/pic.png'
+ : '/example/word.docx'
const demoSource = getDemoSource(defaultUrl)
if (!host) {
@@ -33,40 +35,67 @@ const searchOnlyToolbar = {
const options: ViewerOptions = {
theme: 'light',
- toolbar: toolbarSmoke ? searchOnlyToolbar : { position: 'bottom-right' }
+ toolbar: zoomSmoke
+ ? {
+ ...searchOnlyToolbar,
+ zoom: true,
+ items: toolbarSmoke === 'zoom-reset-hidden' ? { 'zoom-reset': false } : undefined,
+ permissions: toolbarSmoke === 'zoom-in-denied' ? { 'zoom-in': false } : undefined
+ }
+ : toolbarSmoke
+ ? searchOnlyToolbar
+ : { position: 'bottom-right' },
+ pdf: toolbarSmoke === 'zoom-pdf-hidden' ? { zoomToolbar: false } : undefined
}
-const toolbarSlots = toolbarSmoke === 'toolbar-slots'
- ? {
- 'toolbar-start': (slotProps: FileViewerToolbarSlotProps) => h('button', {
- type: 'button',
- 'data-testid': 'vue3-toolbar-start',
- 'data-api-search': String(typeof slotProps.api.searchDocument === 'function'),
- 'data-download-available': String(slotProps.availability.download),
- 'data-zoom-scale': String(slotProps.zoomState.scale),
- 'data-search-total': String(slotProps.searchState.total),
- onClick: () => slotProps.api.searchDocument('Markdown')
- }, 'Search from start slot'),
- 'toolbar-end': (slotProps: FileViewerToolbarSlotProps) => h('button', {
- type: 'button',
- 'data-testid': 'vue3-toolbar-end',
- 'data-api-search': String(typeof slotProps.api.searchDocument === 'function'),
- 'data-search-total': String(slotProps.searchState.total),
- onClick: () => {
- document.body.dataset.vue3ToolbarEndClicked = 'true'
- }
- }, 'End slot')
- }
- : undefined
+const toolbarSlots =
+ toolbarSmoke === 'toolbar-slots'
+ ? {
+ 'toolbar-start': (slotProps: FileViewerToolbarSlotProps) =>
+ h(
+ 'button',
+ {
+ type: 'button',
+ 'data-testid': 'vue3-toolbar-start',
+ 'data-api-search': String(typeof slotProps.api.searchDocument === 'function'),
+ 'data-download-available': String(slotProps.availability.download),
+ 'data-zoom-scale': String(slotProps.zoomState.scale),
+ 'data-search-total': String(slotProps.searchState.total),
+ onClick: () => slotProps.api.searchDocument('Markdown')
+ },
+ 'Search from start slot'
+ ),
+ 'toolbar-end': (slotProps: FileViewerToolbarSlotProps) =>
+ h(
+ 'button',
+ {
+ type: 'button',
+ 'data-testid': 'vue3-toolbar-end',
+ 'data-api-search': String(typeof slotProps.api.searchDocument === 'function'),
+ 'data-search-total': String(slotProps.searchState.total),
+ onClick: () => {
+ document.body.dataset.vue3ToolbarEndClicked = 'true'
+ }
+ },
+ 'End slot'
+ )
+ }
+ : undefined
createApp({
render() {
- return h(FileViewer, {
- url: demoSource.url,
- filename: demoSource.filename,
- options
- }, toolbarSlots)
+ return h(
+ FileViewer,
+ {
+ url: demoSource.url,
+ filename: demoSource.filename,
+ options
+ },
+ toolbarSlots
+ )
}
-}).use(FileViewerPlugin).mount(host)
+})
+ .use(FileViewerPlugin)
+ .mount(host)
document.body.setAttribute('data-component', 'vue3')
diff --git a/apps/component-demo/test/angular-pptx/package.json b/apps/component-demo/test/angular-pptx/package.json
index be95832a5..93c3a2f42 100644
--- a/apps/component-demo/test/angular-pptx/package.json
+++ b/apps/component-demo/test/angular-pptx/package.json
@@ -7,10 +7,10 @@
"build": "ng build"
},
"dependencies": {
- "@angular/common": "22.2.1",
- "@angular/compiler": "22.2.1",
- "@angular/core": "22.2.1",
- "@angular/platform-browser": "22.2.1",
+ "@angular/common": "22.2.3",
+ "@angular/compiler": "22.2.3",
+ "@angular/core": "22.2.3",
+ "@angular/platform-browser": "22.2.3",
"@file-viewer/web": "3.2.2",
"@file-viewer/preset-office": "3.2.2",
"file-viewer-copy-assets": "3.2.2",
@@ -18,14 +18,15 @@
"tslib": "2.8.1"
},
"devDependencies": {
- "@angular/build": "22.2.2",
- "@angular/cli": "22.2.2",
- "@angular/compiler-cli": "22.2.1",
+ "@angular/build": "22.2.3",
+ "@angular/cli": "22.2.3",
+ "@angular/compiler-cli": "22.2.3",
"typescript": "6.0.3"
},
"overrides": {
"@angular/build": {
- "piscina": "5.3.2"
+ "piscina": "5.3.2",
+ "vite": "8.3.4"
},
"@angular/cli": {
"@modelcontextprotocol/sdk": "1.31.0"
diff --git a/apps/component-demo/test/evidence/github-398/chromium-vue-minimum.png b/apps/component-demo/test/evidence/github-398/chromium-vue-minimum.png
new file mode 100644
index 000000000..cd15c6a73
Binary files /dev/null and b/apps/component-demo/test/evidence/github-398/chromium-vue-minimum.png differ
diff --git a/apps/component-demo/test/evidence/github-398/chromium-web-maximum.png b/apps/component-demo/test/evidence/github-398/chromium-web-maximum.png
new file mode 100644
index 000000000..18d4822af
Binary files /dev/null and b/apps/component-demo/test/evidence/github-398/chromium-web-maximum.png differ
diff --git a/apps/component-demo/test/evidence/github-398/webkit-pdf-zoom-hidden.png b/apps/component-demo/test/evidence/github-398/webkit-pdf-zoom-hidden.png
new file mode 100644
index 000000000..243eaddde
Binary files /dev/null and b/apps/component-demo/test/evidence/github-398/webkit-pdf-zoom-hidden.png differ
diff --git a/package.json b/package.json
index 9a08bdca7..a9d519834 100644
--- a/package.json
+++ b/package.json
@@ -62,13 +62,14 @@
"test:spreadsheet-github-234": "pnpm --filter @file-viewer/renderer-spreadsheet verify:github-234",
"test:spreadsheet-github-405": "pnpm --filter @file-viewer/renderer-spreadsheet verify:github-405",
"test:text-single-byte-encodings": "pnpm exec vitest run test/text-single-byte-encodings.spec.ts test/text-encoding-contracts.spec.ts && node packages/renderers/text/test/single-byte-encodings.browser.mjs",
+ "test:toolbar-zoom-github-398": "pnpm exec vitest run test/core-operations.spec.ts test/vue-toolbar-hook.spec.ts test/web-toolbar-order.spec.ts",
"test:vite-copy-assets-github-257": "pnpm --filter @file-viewer/vite-plugin verify:github-257",
"test:vue3-github-299": "pnpm exec vitest run test/vue3-error-event.spec.ts",
"test:word-github-266": "pnpm --filter @file-viewer/core build && pnpm --filter @file-viewer/doc build && pnpm --filter @file-viewer/renderer-word build",
"test:word-revisions-github-255": "pnpm exec vitest run test/word-revisions.spec.ts test/word-native-revisions.spec.ts test/vue3-render-options.spec.ts",
"test:xml-github-305": "pnpm --filter @file-viewer/renderer-text build && pnpm exec vitest run test/core-options.spec.ts test/vue3-render-options.spec.ts && node packages/renderers/text/test/issue-305-xml-profiles.browser.mjs",
"test:thumbnail": "pnpm --filter @file-viewer/thumbnail test",
- "test": "pnpm verify:public-release-facts && pnpm test:i18n && pnpm test:thumbnail && pnpm test:archive-github-101 && pnpm test:archive-github-148 && pnpm test:bpmn-github-297 && pnpm test:cad-output-github-245 && pnpm test:doc-github-171 && pnpm test:doc-github-192 && pnpm test:doc-github-236 && pnpm test:doc-github-34 && pnpm test:doc-github-87 && pnpm test:email-github-232 && pnpm test:epub-github-292 && pnpm test:epub-github-300 && pnpm test:html-preview-github-256 && pnpm test:image-github-298 && pnpm test:ofd-github-177 && pnpm test:ofd-github-266 && pnpm test:ofd-github-94 && pnpm test:ofd-resources-github-57 && pnpm test:patch-github-304 && pnpm test:pdf-cjk-font-github-242 && pnpm test:pptx-assets-github-140 && pnpm test:pptx-github-176 && pnpm test:pptx-github-268 && pnpm test:reading-experience && pnpm test:requirejs-github-258 && pnpm test:shadow-focus-github-243 && pnpm test:spreadsheet-github-175 && pnpm test:spreadsheet-github-178 && pnpm test:spreadsheet-github-234 && pnpm test:spreadsheet-github-405 && pnpm test:text-single-byte-encodings && pnpm test:vite-copy-assets-github-257 && pnpm test:vue3-github-299 && pnpm test:word-github-266 && pnpm test:word-revisions-github-255 && pnpm test:xml-github-305 && pnpm test:ofd-signatures && pnpm test:ifc-entry",
+ "test": "pnpm verify:public-release-facts && pnpm test:i18n && pnpm test:thumbnail && pnpm test:archive-github-101 && pnpm test:archive-github-148 && pnpm test:bpmn-github-297 && pnpm test:cad-output-github-245 && pnpm test:doc-github-171 && pnpm test:doc-github-192 && pnpm test:doc-github-236 && pnpm test:doc-github-34 && pnpm test:doc-github-87 && pnpm test:email-github-232 && pnpm test:epub-github-292 && pnpm test:epub-github-300 && pnpm test:html-preview-github-256 && pnpm test:image-github-298 && pnpm test:ofd-github-177 && pnpm test:ofd-github-266 && pnpm test:ofd-github-94 && pnpm test:ofd-resources-github-57 && pnpm test:patch-github-304 && pnpm test:pdf-cjk-font-github-242 && pnpm test:pptx-assets-github-140 && pnpm test:pptx-github-176 && pnpm test:pptx-github-268 && pnpm test:reading-experience && pnpm test:requirejs-github-258 && pnpm test:shadow-focus-github-243 && pnpm test:spreadsheet-github-175 && pnpm test:spreadsheet-github-178 && pnpm test:spreadsheet-github-234 && pnpm test:spreadsheet-github-405 && pnpm test:text-single-byte-encodings && pnpm test:toolbar-zoom-github-398 && pnpm test:vite-copy-assets-github-257 && pnpm test:vue3-github-299 && pnpm test:word-github-266 && pnpm test:word-revisions-github-255 && pnpm test:xml-github-305 && pnpm test:ofd-signatures && pnpm test:ifc-entry",
"type-check:components": "pnpm --filter @file-viewer/web type-check && pnpm --filter @file-viewer/react type-check && pnpm --filter @file-viewer/react-legacy type-check && pnpm --filter @file-viewer/vue3 type-check && pnpm --filter @file-viewer/vue2.7 type-check && pnpm --filter @file-viewer/vue2.6 type-check && pnpm --filter @file-viewer/jquery type-check && pnpm --filter @file-viewer/svelte type-check",
"type-check:tools": "pnpm --filter './packages/tools/**' type-check",
"docs:dev": "pnpm --filter @flyfish-group/file-viewer-docs dev",
diff --git a/packages/build-support/licenses/asset-family-materials.json b/packages/build-support/licenses/asset-family-materials.json
index 816d31da3..34e725f3b 100644
--- a/packages/build-support/licenses/asset-family-materials.json
+++ b/packages/build-support/licenses/asset-family-materials.json
@@ -444,8 +444,8 @@
},
{
"sourcePath": "packages/renderers/dicom/THIRD_PARTY_NOTICES.md",
- "sha256": "197379984adb7e753627d00a91364f7a9269c003822a057e6499c51b65fd78a6",
- "bytes": 14376,
+ "sha256": "28e26035bf01dc15aae7e4ac3a5ea7c17f116a688fe9795ab9e92dda8db075cc",
+ "bytes": 14455,
"kind": "generated-attribution-inventory",
"component": "DICOM source inventory",
"declaredLicense": null,
diff --git a/packages/components/vue3/src/package/components/FileViewer/FileViewer.vue b/packages/components/vue3/src/package/components/FileViewer/FileViewer.vue
index cc01eb715..29b9ff356 100644
--- a/packages/components/vue3/src/package/components/FileViewer/FileViewer.vue
+++ b/packages/components/vue3/src/package/components/FileViewer/FileViewer.vue
@@ -429,6 +429,7 @@ const {
const {
operationAvailability,
+ zoomButtons,
visibleToolbar,
toolbarOrder,
showToolbar: showBuiltInToolbar,
@@ -827,7 +828,7 @@ useViewerPreviewLifecycle({
:aria-label='viewerLabels.zoomGroup'
>
(() => toolbarState.value.operationAvailability)
+ const zoomButtons = computed(() => resolveFileViewerToolbarZoomButtons({
+ toolbar: normalizedToolbar.value,
+ zoomState,
+ renderedReady: renderedReady.value,
+ hasError: !!error.value
+ }))
const visibleToolbar = computed(() => toolbarState.value.visibleToolbar)
const toolbarOrder = computed(() => toolbarState.value.toolbarOrder)
const showToolbar = computed(() => toolbarState.value.showToolbar)
@@ -102,6 +109,7 @@ export const useViewerToolbar = ({
return {
operationAvailability,
+ zoomButtons,
visibleToolbar,
toolbarOrder,
showToolbar,
diff --git a/packages/core/README.en.md b/packages/core/README.en.md
index 22676441f..c846c64f3 100644
--- a/packages/core/README.en.md
+++ b/packages/core/README.en.md
@@ -86,6 +86,15 @@ const viewer = createViewer(document.querySelector('#viewer')!, {
await viewer.load({ url: '/files/report.pdf' })
```
+Zoom buttons keep their positions and become disabled at their limits. Configure the existing `toolbar.items` map to hide reset controls while retaining the `resetZoom()` API. Set `pdf.zoomToolbar` to `false` to hide the PDF renderer's zoom group while keeping its page navigation and rotation controls:
+
+```ts
+const options = {
+ toolbar: { items: { 'zoom-reset': false } },
+ pdf: { zoomToolbar: false }
+}
+```
+
HTML document builders live in the opt-in export entry so ordinary previews do
not download the sanitizer. The viewer's `exportHtml()` and `print()` methods
load this entry only when invoked. For v3, migrate direct builder imports from
diff --git a/packages/core/README.md b/packages/core/README.md
index 22676441f..c846c64f3 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -86,6 +86,15 @@ const viewer = createViewer(document.querySelector('#viewer')!, {
await viewer.load({ url: '/files/report.pdf' })
```
+Zoom buttons keep their positions and become disabled at their limits. Configure the existing `toolbar.items` map to hide reset controls while retaining the `resetZoom()` API. Set `pdf.zoomToolbar` to `false` to hide the PDF renderer's zoom group while keeping its page navigation and rotation controls:
+
+```ts
+const options = {
+ toolbar: { items: { 'zoom-reset': false } },
+ pdf: { zoomToolbar: false }
+}
+```
+
HTML document builders live in the opt-in export entry so ordinary previews do
not download the sanitizer. The viewer's `exportHtml()` and `print()` methods
load this entry only when invoked. For v3, migrate direct builder imports from
diff --git a/packages/core/README.zh-CN.md b/packages/core/README.zh-CN.md
index eec07dba1..fae7292da 100644
--- a/packages/core/README.zh-CN.md
+++ b/packages/core/README.zh-CN.md
@@ -86,6 +86,15 @@ const viewer = createViewer(document.querySelector('#viewer')!, {
await viewer.load({ url: '/files/report.pdf' })
```
+缩放按钮在达到边界时保持位置并禁用。使用现有的 `toolbar.items` 配置隐藏重置入口,`resetZoom()` API 仍然可用。设置 `pdf.zoomToolbar: false` 可仅隐藏 PDF 渲染器内部的缩放组,同时保留翻页和旋转控件:
+
+```ts
+const options = {
+ toolbar: { items: { 'zoom-reset': false } },
+ pdf: { zoomToolbar: false }
+}
+```
+
HTML document builders live in the opt-in export entry so ordinary previews do
not download the sanitizer. The viewer's `exportHtml()` and `print()` methods
load this entry only when invoked. For v3, migrate direct builder imports from
diff --git a/packages/core/src/browser/controller.ts b/packages/core/src/browser/controller.ts
index b0e891021..8a1d429de 100644
--- a/packages/core/src/browser/controller.ts
+++ b/packages/core/src/browser/controller.ts
@@ -66,6 +66,8 @@ import {
type RendererSession,
} from '../index';
+import { resolveFileViewerToolbarZoomButtons } from '../lifecycle/operations';
+
export type FileRef = FileViewerFileRef;
export type ViewerWatermarkOptions = FileViewerWatermarkOptions;
@@ -817,11 +819,18 @@ export const mountViewer = (
const toolbar = normalizeFileViewerToolbar(options);
const availability = getToolbarAvailability();
const showSearchToolbar = isSearchToolbarVisible(toolbar, options);
+ const zoomState = getToolbarZoomState();
+ const zoomButtons = resolveFileViewerToolbarZoomButtons({
+ toolbar,
+ zoomState,
+ renderedReady: state.ready,
+ hasError: !!state.error,
+ });
const visibleToolbar = resolveVisibleFileViewerToolbar(toolbar, availability, showSearchToolbar);
+ visibleToolbar.zoom = zoomButtons.zoomIn || zoomButtons.zoomOut || zoomButtons.zoomReset;
const showToolbar = hasVisibleFileViewerToolbarActions(visibleToolbar);
const toolbarPosition = resolveFileViewerToolbarPosition(options, getCurrentExtension());
const toolbarDisabled = state.loading || !!state.error;
- const zoomState = getToolbarZoomState();
const t = createFileViewerTranslator(options);
toolbarEl.hidden = !showToolbar;
toolbarEl.dataset.toolbarPosition = toolbarPosition;
@@ -910,7 +919,7 @@ export const mountViewer = (
addPart(group, 'toolbar-group');
group.setAttribute('aria-label', t('toolbar.zoomGroup'));
- if (availability.zoomOut) {
+ if (zoomButtons.zoomOut) {
const button = createButton(documentRef, '-', 'file-viewer-web-icon-button', () => controller?.zoomOut());
button.title = t('toolbar.zoomOut');
button.setAttribute('aria-label', t('toolbar.zoomOut'));
@@ -923,7 +932,7 @@ export const mountViewer = (
group.appendChild(button);
}
- if (availability.zoomReset) {
+ if (zoomButtons.zoomReset) {
const meter = createButton(documentRef, zoomState.label, 'file-viewer-web-zoom-meter', () => controller?.resetZoom());
meter.title = t('toolbar.zoomReset');
meter.disabled = isFileViewerZoomButtonDisabled({
@@ -941,7 +950,7 @@ export const mountViewer = (
));
}
- if (availability.zoomReset) {
+ if (zoomButtons.zoomReset) {
// Placed before the zoom-in button (not after) so that toggling this
// button's visibility never shifts the zoom-in button's position: the
// toolbar group is right-aligned, and both icon buttons share the same
@@ -960,7 +969,7 @@ export const mountViewer = (
group.appendChild(button);
}
- if (availability.zoomIn) {
+ if (zoomButtons.zoomIn) {
const button = createButton(documentRef, '+', 'file-viewer-web-icon-button', () => controller?.zoomIn());
button.title = t('toolbar.zoomIn');
button.setAttribute('aria-label', t('toolbar.zoomIn'));
diff --git a/packages/core/src/contracts/types.ts b/packages/core/src/contracts/types.ts
index 4987382f0..85237eea8 100644
--- a/packages/core/src/contracts/types.ts
+++ b/packages/core/src/contracts/types.ts
@@ -681,6 +681,8 @@ export interface FileViewerPdfOptions {
/** Opt-in mouse hand-tool panning. Touch/pinch, links and form fields remain native. Default: false. */
handTool?: boolean
toolbar?: boolean
+ /** Hides only the PDF renderer's zoom controls; navigation and rotation remain available. Default: true. */
+ zoomToolbar?: boolean
navigation?: boolean
defaultNavigationVisible?: boolean
thumbnails?: boolean
diff --git a/packages/core/src/headless.ts b/packages/core/src/headless.ts
index 01e6a523e..0555312f3 100644
--- a/packages/core/src/headless.ts
+++ b/packages/core/src/headless.ts
@@ -202,6 +202,7 @@ export {
resolveFileViewerLifecycleFallbackSource,
resolveFileViewerLifecycleHookErrorMessage,
resolveFileViewerOperationAvailability,
+ resolveFileViewerToolbarZoomButtons,
resolveFileViewerToolbarPosition,
resolveFileViewerToolbarOrder,
resolveFileViewerToolbarState,
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ee0484141..0080af6d9 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -749,6 +749,7 @@ export {
resolveFileViewerLifecycleHookErrorMessage,
resolveFileViewerBeforeOperationErrorMessage,
resolveFileViewerOperationAvailability,
+ resolveFileViewerToolbarZoomButtons,
resolveFileViewerToolbarOrder,
resolveFileViewerToolbarState,
resolveFileViewerToolbarPosition,
diff --git a/packages/core/src/lifecycle/operations.ts b/packages/core/src/lifecycle/operations.ts
index 018bf7201..f7c75e246 100644
--- a/packages/core/src/lifecycle/operations.ts
+++ b/packages/core/src/lifecycle/operations.ts
@@ -1168,6 +1168,22 @@ export const hasVisibleFileViewerToolbarActions = (toolbar: FileViewerToolbarOpt
);
};
+/** Button presence follows UI policy; current operation state only disables it. */
+export const resolveFileViewerToolbarZoomButtons = ({
+ toolbar,
+ zoomState,
+ renderedReady,
+ hasError = false,
+}: Pick): Pick => {
+ const visible = !!toolbar.zoom && renderedReady && !hasError &&
+ (zoomState.canZoomIn || zoomState.canZoomOut || zoomState.canReset);
+ return {
+ zoomIn: visible && isFileViewerToolbarOperationVisible(toolbar, 'zoom-in'),
+ zoomOut: visible && isFileViewerToolbarOperationVisible(toolbar, 'zoom-out'),
+ zoomReset: visible && isFileViewerToolbarOperationVisible(toolbar, 'zoom-reset'),
+ };
+};
+
export const isFileViewerZoomButtonDisabled = ({
toolbarDisabled = false,
availability,
@@ -1214,6 +1230,8 @@ export const resolveFileViewerToolbarState = ({
operationAvailability,
options?.search !== false && searchAvailable
);
+ const zoomButtons = resolveFileViewerToolbarZoomButtons({ toolbar, ...availabilityInput });
+ visibleToolbar.zoom = zoomButtons.zoomIn || zoomButtons.zoomOut || zoomButtons.zoomReset;
return {
operationAvailability,
diff --git a/packages/renderers/dicom/THIRD_PARTY_LICENSES.json b/packages/renderers/dicom/THIRD_PARTY_LICENSES.json
index 60bdb8291..0d0afc298 100644
--- a/packages/renderers/dicom/THIRD_PARTY_LICENSES.json
+++ b/packages/renderers/dicom/THIRD_PARTY_LICENSES.json
@@ -764,6 +764,20 @@
],
"noticeFiles": []
},
+ {
+ "name": "caniuse-lite",
+ "version": "1.0.30001816",
+ "license": "CC-BY-4.0",
+ "direct": false,
+ "optional": false,
+ "firstParty": false,
+ "author": "Ben Briggs http://beneb.info",
+ "repository": "https://github.com/browserslist/caniuse-lite",
+ "licenseFiles": [
+ "LICENSE"
+ ],
+ "noticeFiles": []
+ },
{
"name": "comlink",
"version": "4.4.2",
diff --git a/packages/renderers/dicom/THIRD_PARTY_NOTICES.md b/packages/renderers/dicom/THIRD_PARTY_NOTICES.md
index b4942e97b..78af34d4a 100644
--- a/packages/renderers/dicom/THIRD_PARTY_NOTICES.md
+++ b/packages/renderers/dicom/THIRD_PARTY_NOTICES.md
@@ -67,6 +67,7 @@ None of the Apache-2.0 dependencies in this closure publishes a top-level `NOTIC
### CC-BY-4.0
- `caniuse-lite@1.0.30001815` — https://github.com/browserslist/caniuse-lite
+- `caniuse-lite@1.0.30001816` — https://github.com/browserslist/caniuse-lite
### ISC
diff --git a/packages/renderers/dicom/file-viewer.capability.json b/packages/renderers/dicom/file-viewer.capability.json
index 9f048b917..f7e15fcf1 100644
--- a/packages/renderers/dicom/file-viewer.capability.json
+++ b/packages/renderers/dicom/file-viewer.capability.json
@@ -162,6 +162,11 @@
"spdx": "CC-BY-4.0",
"notice": "Reviewed runtime/SDK source caniuse-lite@1.0.30001815; repository https://github.com/browserslist/caniuse-lite; complete license and notice filenames are recorded in THIRD_PARTY_LICENSES.json."
},
+ {
+ "packageName": "caniuse-lite",
+ "spdx": "CC-BY-4.0",
+ "notice": "Reviewed runtime/SDK source caniuse-lite@1.0.30001816; repository https://github.com/browserslist/caniuse-lite; complete license and notice filenames are recorded in THIRD_PARTY_LICENSES.json."
+ },
{
"packageName": "comlink",
"spdx": "Apache-2.0",
diff --git a/packages/renderers/pdf/src/pdf.ts b/packages/renderers/pdf/src/pdf.ts
index cbc3ba6ef..12247735b 100644
--- a/packages/renderers/pdf/src/pdf.ts
+++ b/packages/renderers/pdf/src/pdf.ts
@@ -602,7 +602,9 @@ export default async function renderPdf(
if (navigationEnabled) {
toolbar.append(navToggleButton);
}
- toolbar.append(pageGroup, zoomGroup, rotateGroup);
+ toolbar.append(pageGroup);
+ if (options?.zoomToolbar !== false) toolbar.append(zoomGroup);
+ toolbar.append(rotateGroup);
const content = createElement(documentRef, 'div', 'pdf-content');
const navPane = createElement(documentRef, 'aside', 'pdf-nav-pane');
diff --git a/packages/thumbnail/package.json b/packages/thumbnail/package.json
index aef9683c4..62b6e2f2b 100644
--- a/packages/thumbnail/package.json
+++ b/packages/thumbnail/package.json
@@ -52,7 +52,7 @@
"@xmldom/xmldom": "0.9.12",
"epubjs": "0.3.93",
"esbuild": "^0.28.1",
- "happy-dom": "^20.14.3",
+ "happy-dom": "^21.0.4",
"typescript": "^6.0.3",
"vitest": "5.0.3"
},
diff --git a/packages/tools/cli/catalog/catalog.json b/packages/tools/cli/catalog/catalog.json
index 9616903ca..191ef55d1 100644
--- a/packages/tools/cli/catalog/catalog.json
+++ b/packages/tools/cli/catalog/catalog.json
@@ -946,6 +946,11 @@
"spdx": "CC-BY-4.0",
"notice": "Reviewed runtime/SDK source caniuse-lite@1.0.30001815; repository https://github.com/browserslist/caniuse-lite; complete license and notice filenames are recorded in THIRD_PARTY_LICENSES.json."
},
+ {
+ "packageName": "caniuse-lite",
+ "spdx": "CC-BY-4.0",
+ "notice": "Reviewed runtime/SDK source caniuse-lite@1.0.30001816; repository https://github.com/browserslist/caniuse-lite; complete license and notice filenames are recorded in THIRD_PARTY_LICENSES.json."
+ },
{
"packageName": "comlink",
"spdx": "Apache-2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dc799cc5c..f86d7d190 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -84,7 +84,7 @@ importers:
version: 6.0.3
vitest:
specifier: 5.0.3
- version: 5.0.3(@types/node@26.6.4)(happy-dom@20.14.5)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))
+ version: 5.0.3(@types/node@26.6.4)(happy-dom@21.0.4)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))
vue:
specifier: 3.5.42
version: 3.5.42(typescript@6.0.3)
@@ -166,7 +166,7 @@ importers:
version: 6.0.3
vite:
specifier: 8.3.4
- version: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
apps/docs-site:
dependencies:
@@ -246,7 +246,7 @@ importers:
devDependencies:
'@vitejs/plugin-vue':
specifier: ^6.0.7
- version: 6.0.9(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))(vue@3.5.42(typescript@6.0.3))
+ version: 6.0.9(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))(vue@3.5.42(typescript@6.0.3))
'@vue/tsconfig':
specifier: ^0.9.1
version: 0.9.1(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))
@@ -255,7 +255,7 @@ importers:
version: 6.0.3
vite:
specifier: 8.3.4
- version: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
vue-tsc:
specifier: ^3.3.4
version: 3.3.12(typescript@6.0.3)
@@ -386,7 +386,7 @@ importers:
devDependencies:
vite:
specifier: 8.3.4
- version: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
examples/react-vite:
dependencies:
@@ -414,13 +414,13 @@ importers:
version: 19.3.0(@types/react@19.3.0)
'@vitejs/plugin-react':
specifier: ^6.1.1
- version: 6.1.2(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))
+ version: 6.1.2(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))
typescript:
specifier: ^6.0.3
version: 6.0.3
vite:
specifier: 8.3.4
- version: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
examples/vanilla-vite:
dependencies:
@@ -439,7 +439,7 @@ importers:
version: 6.0.3
vite:
specifier: 8.3.4
- version: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
examples/vue3-vite:
dependencies:
@@ -455,13 +455,13 @@ importers:
version: link:../../packages/presets/vite-plugin
'@vitejs/plugin-vue':
specifier: ^6.0.7
- version: 6.0.9(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))(vue@3.5.42(typescript@6.0.3))
+ version: 6.0.9(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))(vue@3.5.42(typescript@6.0.3))
typescript:
specifier: ^6.0.3
version: 6.0.3
vite:
specifier: 8.3.4
- version: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
vue-tsc:
specifier: ^3.3.4
version: 3.3.12(typescript@6.0.3)
@@ -490,7 +490,7 @@ importers:
version: 6.0.3
vite:
specifier: 8.3.4
- version: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
packages/capabilities/midi:
dependencies:
@@ -1552,7 +1552,7 @@ importers:
version: 6.0.3
vite:
specifier: 8.3.4
- version: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
packages/renderers/doc:
dependencies:
@@ -2035,7 +2035,7 @@ importers:
version: 6.0.3
xmllint-wasm:
specifier: 5.3.0
- version: 5.3.0(@types/node@26.6.4)
+ version: 5.3.0(@types/node@26.6.5)
xslt-polyfill:
specifier: 1.0.31
version: 1.0.31
@@ -2119,14 +2119,14 @@ importers:
specifier: ^0.28.1
version: 0.28.2
happy-dom:
- specifier: ^20.14.3
- version: 20.14.5
+ specifier: ^21.0.4
+ version: 21.0.4
typescript:
specifier: ^6.0.3
version: 6.0.3
vitest:
specifier: 5.0.3
- version: 5.0.3(@types/node@26.6.4)(happy-dom@20.14.5)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))
+ version: 5.0.3(@types/node@26.6.5)(happy-dom@21.0.4)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))
packages/tools/asset-installer:
devDependencies:
@@ -3776,6 +3776,9 @@ packages:
'@types/node@26.6.4':
resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==}
+ '@types/node@26.6.5':
+ resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
+
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
@@ -4254,6 +4257,9 @@ packages:
caniuse-lite@1.0.30001815:
resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==}
+ caniuse-lite@1.0.30001816:
+ resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==}
+
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -4781,12 +4787,8 @@ packages:
resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==}
engines: {node: '>=10.13.0'}
- entities@4.2.0:
- resolution: {integrity: sha512-wEJa03bJgqEwPnkUqYdgmcfUXfm6+4hePQhntIvRy/1/+C4dFuhYHsgKBRjbQ6OWBh42P+VhAoCDO77DUh0e/Q==}
- engines: {node: '>=0.12'}
-
- entities@4.3.0:
- resolution: {integrity: sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg==}
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@7.0.1:
@@ -5132,7 +5134,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
- deprecated: Glob versions prior to v9 are no longer supported
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
@@ -5144,8 +5146,8 @@ packages:
hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
- happy-dom@20.14.5:
- resolution: {integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==}
+ happy-dom@21.0.4:
+ resolution: {integrity: sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==}
engines: {node: '>=20.0.0'}
has-flag@4.0.0:
@@ -8509,6 +8511,10 @@ snapshots:
dependencies:
undici-types: 8.9.0
+ '@types/node@26.6.5':
+ dependencies:
+ undici-types: 8.9.0
+
'@types/pako@2.0.4': {}
'@types/prop-types@15.7.15': {}
@@ -8561,7 +8567,7 @@ snapshots:
'@types/ws@8.18.2':
dependencies:
- '@types/node': 26.6.4
+ '@types/node': 26.6.5
'@ungap/structured-clone@1.4.0': {}
@@ -8570,10 +8576,10 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
- '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))':
+ '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))':
dependencies:
'@rolldown/pluginutils': 1.0.1
- vite: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
'@vitejs/plugin-vue-jsx@5.1.6(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))(vue@3.5.42(typescript@6.0.3))':
dependencies:
@@ -8593,6 +8599,12 @@ snapshots:
vite: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
vue: 3.5.42(typescript@6.0.3)
+ '@vitejs/plugin-vue@6.0.9(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))(vue@3.5.42(typescript@6.0.3))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.1
+ vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ vue: 3.5.42(typescript@6.0.3)
+
'@vitest/mocker@5.0.3(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -8602,6 +8614,15 @@ snapshots:
optionalDependencies:
vite: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ '@vitest/mocker@5.0.3(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))':
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ '@vitest/spy': 5.0.3
+ estree-walker: 3.0.3
+ magic-string: 1.4.3
+ optionalDependencies:
+ vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+
'@vitest/spy@5.0.3': {}
'@volar/language-core@2.4.28':
@@ -8874,7 +8895,7 @@ snapshots:
autoprefixer@10.6.1(postcss@8.5.29):
dependencies:
browserslist: 4.28.7
- caniuse-lite: 1.0.30001815
+ caniuse-lite: 1.0.30001816
fraction.js: 5.3.4
picocolors: 1.1.1
postcss: 8.5.29
@@ -8969,7 +8990,7 @@ snapshots:
buffer-image-size@0.6.4:
dependencies:
- '@types/node': 26.6.4
+ '@types/node': 26.6.5
buffer@6.0.3:
dependencies:
@@ -9007,6 +9028,8 @@ snapshots:
caniuse-lite@1.0.30001815: {}
+ caniuse-lite@1.0.30001816: {}
+
ccount@2.0.1: {}
cfb@1.2.2:
@@ -9473,7 +9496,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
- entities: 4.2.0
+ entities: 4.5.0
dom-serializer@3.1.1:
dependencies:
@@ -9548,9 +9571,7 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
- entities@4.2.0: {}
-
- entities@4.3.0: {}
+ entities@4.5.0: {}
entities@7.0.1: {}
@@ -9927,9 +9948,9 @@ snapshots:
hachure-fill@0.5.2: {}
- happy-dom@20.14.5:
+ happy-dom@21.0.4:
dependencies:
- '@types/node': 26.6.4
+ '@types/node': 26.6.5
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.2
buffer-image-size: 0.6.4
@@ -11200,7 +11221,7 @@ snapshots:
parse5@7.0.0:
dependencies:
- entities: 4.3.0
+ entities: 4.5.0
parse5@8.0.1:
dependencies:
@@ -12090,7 +12111,22 @@ snapshots:
sass: 1.105.1
yaml: 2.9.1
- vitest@5.0.3(@types/node@26.6.4)(happy-dom@20.14.5)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)):
+ vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1):
+ dependencies:
+ lightningcss: 1.33.0
+ picomatch: 4.0.7
+ postcss: 8.5.29
+ rolldown: 1.2.13
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 26.6.5
+ esbuild: 0.28.2
+ fsevents: 2.3.3
+ jiti: 2.7.0
+ sass: 1.105.1
+ yaml: 2.9.1
+
+ vitest@5.0.3(@types/node@26.6.4)(happy-dom@21.0.4)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.3(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))
@@ -12108,7 +12144,30 @@ snapshots:
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 26.6.4
- happy-dom: 20.14.5
+ happy-dom: 21.0.4
+ jsdom: 30.1.2
+ transitivePeerDependencies:
+ - msw
+
+ vitest@5.0.3(@types/node@26.6.5)(happy-dom@21.0.4)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)):
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/mocker': 5.0.3(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1))
+ chai: 6.3.0
+ es-module-lexer: 2.3.2
+ expect-type: 1.4.0
+ magic-string: 1.4.3
+ obug: 2.2.1
+ picomatch: 4.0.7
+ std-env: 4.3.0
+ tinybench: 6.2.1
+ tinyexec: 1.3.1
+ tinyglobby: 0.2.17
+ vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(yaml@2.9.1)
+ why-is-node-running: 3.2.1
+ optionalDependencies:
+ '@types/node': 26.6.5
+ happy-dom: 21.0.4
jsdom: 30.1.2
transitivePeerDependencies:
- msw
@@ -12228,6 +12287,10 @@ snapshots:
dependencies:
'@types/node': 26.6.4
+ xmllint-wasm@5.3.0(@types/node@26.6.5):
+ dependencies:
+ '@types/node': 26.6.5
+
xslt-polyfill@1.0.31: {}
xxhash-wasm@1.1.0: {}
diff --git a/test/core-operations.spec.ts b/test/core-operations.spec.ts
new file mode 100644
index 000000000..3d3f87e4c
--- /dev/null
+++ b/test/core-operations.spec.ts
@@ -0,0 +1,1829 @@
+import { describe, expect, it, vi } from 'vitest';
+import { JSDOM } from 'jsdom';
+import { parseHTML } from 'linkedom';
+import {
+ FILE_VIEWER_BEFORE_OPERATION_ERROR_PREFIX,
+ DEFAULT_FILE_VIEWER_TOOLBAR_ORDER,
+ DEFAULT_FILE_VIEWER_DOWNLOAD_FILENAME,
+ DEFAULT_FILE_VIEWER_EXPORT_FILENAME,
+ DEFAULT_FILE_VIEWER_PREVIEW_TITLE,
+ buildFileViewerLifecycleContext,
+ buildFileViewerLifecycleContextFromNormalizedSource,
+ buildFileViewerOperationContext,
+ buildFileViewerOperationContextFromLifecycleState,
+ cloneFileViewerOperationAvailability,
+ createFileViewerLifecycleActions,
+ createFileViewerLifecycleFacade,
+ createFileViewerPublicApi,
+ createFileViewerToolbarActions,
+ createFileViewerToolbarControllerActionHandlers,
+ createFileViewerToolbarZoomSyncSnapshot,
+ createFileViewerOperationActionHandlers,
+ createFileViewerLifecycleStateController,
+ createFileViewerOriginalSourceState,
+ createFileViewerOriginalSourceStateFromNormalizedSource,
+ createFileViewerPublicOperationActionHandlers,
+ dispatchFileViewerLifecycleEvent,
+ dispatchFileViewerOperationContextEvent,
+ dispatchFileViewerOperationAvailabilityChange,
+ dispatchFileViewerZoomChange,
+ emitFileViewerComponentLifecycleEvent,
+ executeFileViewerDownloadOperation,
+ executeFileViewerExportHtmlOperation,
+ executeFileViewerPrintOperation,
+ FILE_VIEWER_LIFECYCLE_HOOK_ERROR_MESSAGE_PREFIX,
+ hasVisibleFileViewerToolbarActions,
+ isFileViewerToolbarOperationPermitted,
+ isFileViewerZoomButtonDisabled,
+ normalizeFileViewerToolbar,
+ reportFileViewerLifecycleHookError,
+ reportFileViewerOperationError,
+ resolveFileViewerBeforeOperationErrorMessage,
+ resolveFileViewerLifecycleFallbackSource,
+ resolveFileViewerLifecycleHookErrorMessage,
+ resolveFileViewerOperationActionErrorMessage,
+ resolveFileViewerOperationFilename,
+ resolveFileViewerOriginalFilename,
+ resolveFileViewerOperationAvailability,
+ resolveFileViewerToolbarZoomButtons,
+ resolveFileViewerToolbarOrder,
+ resolveFileViewerToolbarState,
+ resolveFileViewerToolbarPosition,
+ resolveFileViewerDisplayFilename,
+ resolveVisibleFileViewerToolbar,
+ runFileViewerActiveUnloadComplete,
+ runFileViewerActiveUnloadStart,
+ runFileViewerBeforeOperation,
+ runFileViewerLifecycleHook,
+ runFileViewerToolbarAvailabilitySync,
+ runFileViewerToolbarZoomSync,
+ serializeFileViewerContext,
+ type FileViewerLifecycleComponentEmit,
+ type FileViewerOperationType,
+} from '../packages/core/src';
+
+describe('@file-viewer/core operation helpers', () => {
+ it('builds lifecycle and operation contexts without framework state', () => {
+ const context = buildFileViewerLifecycleContext({
+ phase: 'load-complete',
+ source: 'url',
+ url: '/docs/%E6%8A%A5%E5%91%8A.pdf?token=1',
+ version: 7,
+ startedAt: 1000,
+ timestamp: 1240,
+ bufferSize: 4096,
+ });
+
+ expect(context).toMatchObject({
+ phase: 'load-complete',
+ type: 'pdf',
+ filename: '报告.pdf',
+ source: 'url',
+ url: '/docs/%E6%8A%A5%E5%91%8A.pdf?token=1',
+ size: 4096,
+ version: 7,
+ timestamp: 1240,
+ duration: 240,
+ });
+
+ expect(buildFileViewerLifecycleContext({
+ phase: 'load-complete',
+ source: 'url',
+ url: '/docs/zero.pdf',
+ version: 7,
+ startedAt: 0,
+ timestamp: 240,
+ }).duration).toBe(240);
+
+ expect(buildFileViewerOperationContext('print', context, 1300)).toMatchObject({
+ operation: 'print',
+ label: '打印完整渲染内容',
+ filename: '报告.pdf',
+ timestamp: 1300,
+ });
+
+ expect(buildFileViewerLifecycleContextFromNormalizedSource({
+ phase: 'load-complete',
+ source: {
+ kind: 'url',
+ filename: '计划.pdf',
+ extension: 'pdf',
+ url: '/docs/%E8%AE%A1%E5%88%92.pdf',
+ size: 2048,
+ },
+ version: 8,
+ startedAt: 0,
+ timestamp: 320,
+ })).toMatchObject({
+ phase: 'load-complete',
+ type: 'pdf',
+ filename: '计划.pdf',
+ source: 'url',
+ url: '/docs/%E8%AE%A1%E5%88%92.pdf',
+ size: 2048,
+ version: 8,
+ timestamp: 320,
+ duration: 320,
+ });
+ });
+
+ it('tracks lifecycle state in the framework-neutral core controller', () => {
+ const controller = createFileViewerLifecycleStateController();
+ const context = buildFileViewerLifecycleContext({
+ phase: 'load-complete',
+ source: 'file',
+ filename: 'demo.pdf',
+ version: 3,
+ timestamp: 120,
+ });
+
+ controller.markLoadStarted(3, 100);
+
+ expect(controller.getLoadStartedAt(3)).toBe(100);
+ expect(controller.getActiveDocumentContext()).toBeNull();
+
+ controller.setActiveDocumentContext(context);
+ expect(controller.getActiveDocumentContext()).toBe(context);
+ expect(controller.buildActiveUnloadContext('unload-start', context, 'replace', 130)).toMatchObject({
+ phase: 'unload-start',
+ filename: 'demo.pdf',
+ reason: 'replace',
+ timestamp: 130,
+ });
+ expect(controller.buildActiveUnloadContext('unload-complete', context, 'replace', 140)).toMatchObject({
+ phase: 'unload-complete',
+ filename: 'demo.pdf',
+ reason: 'replace',
+ timestamp: 140,
+ });
+
+ controller.clearLoadStarted(3);
+ controller.clearActiveDocumentContext();
+
+ expect(controller.getLoadStartedAt(3)).toBeUndefined();
+ expect(controller.getActiveDocumentContext()).toBeNull();
+ expect(controller.buildActiveUnloadContext('unload-start', null)).toBeNull();
+ });
+
+ it('runs active unload lifecycle orchestration without framework code', () => {
+ const controller = createFileViewerLifecycleStateController();
+ const onLifecycle = vi.fn();
+ const context = buildFileViewerLifecycleContext({
+ phase: 'load-complete',
+ source: 'url',
+ filename: 'active.docx',
+ url: '/active.docx',
+ version: 9,
+ timestamp: 500,
+ });
+
+ expect(runFileViewerActiveUnloadStart({
+ lifecycleState: controller,
+ onLifecycle,
+ })).toEqual({
+ reason: 'replace',
+ context: null,
+ unloadContext: null,
+ });
+ expect(onLifecycle).not.toHaveBeenCalled();
+
+ controller.setActiveDocumentContext(context);
+ const started = runFileViewerActiveUnloadStart({
+ lifecycleState: controller,
+ reason: 'replace',
+ onLifecycle,
+ });
+
+ expect(started.context).toBe(context);
+ expect(started.unloadContext).toMatchObject({
+ phase: 'unload-start',
+ filename: 'active.docx',
+ reason: 'replace',
+ version: 9,
+ });
+ expect(onLifecycle).toHaveBeenCalledTimes(1);
+ expect(onLifecycle).toHaveBeenLastCalledWith(started.unloadContext);
+
+ const completed = runFileViewerActiveUnloadComplete({
+ lifecycleState: controller,
+ context: started.context,
+ reason: 'component-unmount',
+ onLifecycle,
+ });
+
+ expect(completed.context).toBe(context);
+ expect(completed.unloadContext).toMatchObject({
+ phase: 'unload-complete',
+ filename: 'active.docx',
+ reason: 'component-unmount',
+ version: 9,
+ });
+ expect(onLifecycle).toHaveBeenCalledTimes(2);
+ expect(onLifecycle).toHaveBeenLastCalledWith(completed.unloadContext);
+ });
+
+ it('dispatches component lifecycle emits and formats beforeOperation errors through core', () => {
+ const emitted: string[] = [];
+ const emit = ((event: string, context: { filename: string }) => {
+ emitted.push(`${event}:${context.filename}`);
+ }) as FileViewerLifecycleComponentEmit;
+ const context = buildFileViewerLifecycleContext({
+ phase: 'load-start',
+ source: 'url',
+ filename: 'contract.pdf',
+ version: 11,
+ });
+
+ emitFileViewerComponentLifecycleEvent(emit, context);
+
+ expect(emitted).toEqual(['load-start:contract.pdf']);
+ expect(resolveFileViewerBeforeOperationErrorMessage({
+ error: new Error('denied'),
+ formatErrorMessage: (prefix, error) => `${prefix}:${error instanceof Error ? error.message : String(error)}`,
+ })).toBe(`${FILE_VIEWER_BEFORE_OPERATION_ERROR_PREFIX}:denied`);
+ expect(resolveFileViewerBeforeOperationErrorMessage({
+ error: 'offline',
+ prefix: '权限校验失败',
+ formatErrorMessage: (prefix, error) => `${prefix}:${String(error)}`,
+ })).toBe('权限校验失败:offline');
+
+ const hookError = new Error('hook failed');
+ const lifecycleLogs: Array<[string, unknown, string]> = [];
+ expect(FILE_VIEWER_LIFECYCLE_HOOK_ERROR_MESSAGE_PREFIX).toBe('FileViewer');
+ expect(resolveFileViewerLifecycleHookErrorMessage({ context })).toBe('FileViewer load-start hook failed');
+ expect(resolveFileViewerLifecycleHookErrorMessage({
+ context,
+ prefix: 'Viewer',
+ })).toBe('Viewer load-start hook failed');
+ expect(reportFileViewerLifecycleHookError({
+ error: hookError,
+ context,
+ onLogError: (message, error, nextContext) => {
+ lifecycleLogs.push([message, error, nextContext.phase]);
+ },
+ })).toBe('FileViewer load-start hook failed');
+ expect(lifecycleLogs).toEqual([['FileViewer load-start hook failed', hookError, 'load-start']]);
+
+ const operationContext = buildFileViewerOperationContext('download', context, 220);
+ const operationLogs: Array<[unknown, string]> = [];
+ expect(reportFileViewerOperationError({
+ error: hookError,
+ context: operationContext,
+ onLogError: (error, nextContext) => {
+ operationLogs.push([error, nextContext.operation]);
+ },
+ })).toBe(hookError);
+ expect(operationLogs).toEqual([[hookError, 'download']]);
+ });
+
+ it('builds operation contexts from lifecycle state without wrapper fallback logic', () => {
+ const controller = createFileViewerLifecycleStateController();
+ const activeContext = buildFileViewerLifecycleContext({
+ phase: 'load-complete',
+ source: 'file',
+ filename: 'active.pdf',
+ version: 5,
+ timestamp: 320,
+ });
+
+ controller.setActiveDocumentContext(activeContext);
+ expect(buildFileViewerOperationContextFromLifecycleState({
+ operation: 'download',
+ lifecycleState: controller,
+ version: 5,
+ fallbackUrl: '/ignored.docx',
+ timestamp: 360,
+ })).toMatchObject({
+ operation: 'download',
+ source: 'file',
+ filename: 'active.pdf',
+ version: 5,
+ timestamp: 360,
+ });
+
+ controller.clearActiveDocumentContext();
+ controller.markLoadStarted(8, 100);
+
+ expect(buildFileViewerOperationContextFromLifecycleState({
+ operation: 'print',
+ lifecycleState: controller,
+ version: 8,
+ filename: 'fallback.docx',
+ bufferSize: 512,
+ fallbackUrl: '/docs/fallback.docx',
+ lifecycleTimestamp: 150,
+ timestamp: 170,
+ })).toMatchObject({
+ operation: 'print',
+ label: '打印完整渲染内容',
+ type: 'docx',
+ filename: 'fallback.docx',
+ source: 'url',
+ url: '/docs/fallback.docx',
+ size: 512,
+ version: 8,
+ timestamp: 170,
+ duration: 50,
+ });
+ });
+
+ it('resolves lifecycle fallback sources without wrapper-specific branching', () => {
+ expect(resolveFileViewerLifecycleFallbackSource({
+ file: new File(['demo'], 'demo.txt'),
+ url: '/ignored.txt',
+ })).toEqual({ source: 'file' });
+ expect(resolveFileViewerLifecycleFallbackSource({
+ url: '/example/demo.pdf',
+ })).toEqual({ source: 'url', sourceUrl: '/example/demo.pdf' });
+ expect(resolveFileViewerLifecycleFallbackSource()).toEqual({ source: 'empty' });
+ });
+
+ it('serializes contexts without leaking File objects', () => {
+ const file = new File(['demo'], 'demo.docx');
+ const context = buildFileViewerLifecycleContext({
+ phase: 'load-start',
+ source: 'file',
+ file,
+ version: 1,
+ timestamp: 10,
+ });
+
+ expect(serializeFileViewerContext(context)).toEqual({
+ phase: 'load-start',
+ type: 'docx',
+ filename: 'demo.docx',
+ source: 'file',
+ url: undefined,
+ size: 4,
+ version: 1,
+ timestamp: 10,
+ duration: undefined,
+ reason: undefined,
+ hasFile: true,
+ });
+ });
+
+ it('dispatches lifecycle and operation context events in wrapper event order', () => {
+ const events: string[] = [];
+ const lifecycleContext = buildFileViewerLifecycleContext({
+ phase: 'load-complete',
+ source: 'url',
+ filename: 'dispatch.pdf',
+ url: '/docs/dispatch.pdf',
+ version: 3,
+ timestamp: 300,
+ });
+ const operationContext = buildFileViewerOperationContext('print', lifecycleContext, 320);
+
+ expect(dispatchFileViewerLifecycleEvent({
+ context: lifecycleContext,
+ hooks: {
+ onLoadComplete: context => {
+ events.push(`hook:${context.phase}`);
+ },
+ },
+ onChange: (event, context) => {
+ events.push(`emit:${event}:${context.filename}`);
+ },
+ })).toBe(true);
+ expect(dispatchFileViewerOperationContextEvent({
+ event: 'operation-before',
+ context: operationContext,
+ onChange: context => {
+ events.push(`emit:${context.operation}`);
+ },
+ })).toBe(true);
+
+ expect(events).toEqual([
+ 'emit:load-complete:dispatch.pdf',
+ 'hook:load-complete',
+ 'emit:print',
+ ]);
+ });
+
+ it('creates lifecycle actions that reuse dispatch, unload and operation guards', async () => {
+ const events: string[] = [];
+ const lifecycleState = createFileViewerLifecycleStateController();
+ const context = buildFileViewerLifecycleContext({
+ phase: 'load-complete',
+ source: 'file',
+ filename: 'actions.pdf',
+ version: 8,
+ timestamp: 400,
+ });
+ lifecycleState.setActiveDocumentContext(context);
+ const actions = createFileViewerLifecycleActions({
+ lifecycleState,
+ getOptions: () => ({
+ hooks: {
+ onUnloadStart: nextContext => {
+ events.push(`hook:${nextContext.phase}`);
+ },
+ },
+ beforeOperation: nextContext => {
+ events.push(`guard:${nextContext.operation}`);
+ return false;
+ },
+ }),
+ onLifecycleChange: (event, nextContext) => {
+ events.push(`emit:${event}:${nextContext.filename}`);
+ },
+ onOperationBefore: nextContext => {
+ events.push(`before:${nextContext.operation}`);
+ },
+ onOperationCancel: nextContext => {
+ events.push(`cancel:${nextContext.operation}`);
+ },
+ });
+
+ const unloadStartContext = actions.notifyActiveUnloadStart('replace');
+ expect(unloadStartContext).toMatchObject({
+ phase: 'load-complete',
+ reason: undefined,
+ filename: 'actions.pdf',
+ });
+
+ actions.notifyActiveUnloadComplete(unloadStartContext, 'replace');
+ await expect(actions.runBeforeOperation(
+ buildFileViewerOperationContext('download', context, 430)
+ )).resolves.toBe(false);
+
+ expect(events).toEqual([
+ 'emit:unload-start:actions.pdf',
+ 'hook:unload-start',
+ 'emit:unload-complete:actions.pdf',
+ 'before:download',
+ 'guard:download',
+ 'cancel:download',
+ ]);
+ });
+
+ it('creates lifecycle facades from wrapper state getters', async () => {
+ const events: string[] = [];
+ const errors: string[] = [];
+ const lifecycle = createFileViewerLifecycleFacade({
+ getOptions: () => ({
+ beforeOperation: context => {
+ events.push(`guard:${context.operation}:${context.filename}`);
+ return false;
+ },
+ }),
+ getFilename: () => 'facade.pdf',
+ getBufferSize: () => 2048,
+ getCurrentFile: () => null,
+ getCurrentVersion: () => 12,
+ getFallbackFile: () => undefined,
+ getFallbackUrl: () => '/fallback.pdf',
+ emitLifecycle: (event, context) => {
+ events.push(`emit:${event}:${context.filename}`);
+ },
+ emitOperationBefore: context => {
+ events.push(`before:${context.operation}:${context.source}`);
+ },
+ emitOperationCancel: context => {
+ events.push(`cancel:${context.operation}`);
+ },
+ formatErrorMessage: (prefix, error) => `${prefix}:${error instanceof Error ? error.message : String(error)}`,
+ handleLifecycleError: error => {
+ errors.push(String(error));
+ },
+ onOperationErrorMessage: message => {
+ errors.push(message);
+ },
+ });
+
+ lifecycle.markLoadStarted(12, 100);
+ const loadStart = lifecycle.buildLoadStartState({
+ version: 12,
+ source: 'url',
+ sourceUrl: '/facade.pdf',
+ });
+ expect(loadStart.lifecycleContext).toMatchObject({
+ phase: 'load-start',
+ source: 'url',
+ filename: 'facade.pdf',
+ size: 2048,
+ url: '/facade.pdf',
+ });
+ lifecycle.notifyLifecycle(loadStart.lifecycleContext);
+
+ const complete = lifecycle.buildRenderCompleteState({
+ version: 12,
+ source: 'url',
+ sourceUrl: '/facade.pdf',
+ });
+ expect(complete.lifecycleContext).toMatchObject({
+ phase: 'load-complete',
+ filename: 'facade.pdf',
+ });
+ lifecycle.setActiveDocumentContext(complete.lifecycleContext);
+
+ expect(lifecycle.buildOperationContext('download')).toMatchObject({
+ operation: 'download',
+ label: '下载原始文件',
+ filename: 'facade.pdf',
+ source: 'url',
+ size: 2048,
+ });
+ await expect(lifecycle.runBeforeOperation('download')).resolves.toBe(false);
+ lifecycle.clearLoadStarted(12);
+ lifecycle.clearActiveDocumentContext();
+ expect(lifecycle.buildOperationContext('print')).toMatchObject({
+ operation: 'print',
+ source: 'url',
+ url: '/fallback.pdf',
+ filename: 'facade.pdf',
+ });
+
+ expect(events).toEqual([
+ 'emit:load-start:facade.pdf',
+ 'before:download:url',
+ 'guard:download:facade.pdf',
+ 'cancel:download',
+ ]);
+ expect(errors).toEqual([]);
+ });
+
+ it('dispatches toolbar change notifications in wrapper event order', () => {
+ const events: string[] = [];
+ const availability = {
+ download: true,
+ print: false,
+ exportHtml: true,
+ zoom: true,
+ zoomIn: true,
+ zoomOut: false,
+ zoomReset: false,
+ };
+ const zoomState = {
+ scale: 1.5,
+ label: '150%',
+ canZoomIn: true,
+ canZoomOut: true,
+ canReset: true,
+ };
+ let emittedAvailability: typeof availability | null = null;
+ let emittedZoomState: typeof zoomState | null = null;
+
+ expect(dispatchFileViewerOperationAvailabilityChange({
+ availability,
+ onChange: payload => {
+ events.push('emit:operation-availability-change');
+ emittedAvailability = payload;
+ payload.download = false;
+ },
+ })).toBe(true);
+ expect(dispatchFileViewerZoomChange({
+ state: zoomState,
+ onChange: payload => {
+ events.push('emit:zoom-change');
+ emittedZoomState = payload;
+ },
+ })).toBe(true);
+
+ expect(events).toEqual([
+ 'emit:operation-availability-change',
+ 'emit:zoom-change',
+ ]);
+ expect(emittedAvailability).toEqual({
+ ...availability,
+ download: false,
+ });
+ expect(emittedAvailability).not.toBe(availability);
+ expect(availability.download).toBe(true);
+ expect(emittedZoomState).toBe(zoomState);
+ });
+
+ it('creates toolbar actions that reuse notification dispatch and zoom guard rules', () => {
+ const events: string[] = [];
+ const availability = {
+ download: true,
+ print: false,
+ exportHtml: true,
+ zoom: true,
+ zoomIn: true,
+ zoomOut: false,
+ zoomReset: true,
+ };
+ let toolbarDisabled = false;
+ const zoomState = {
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: false,
+ canReset: true,
+ };
+ const actions = createFileViewerToolbarActions({
+ getOperationAvailability: () => availability,
+ getToolbarDisabled: () => toolbarDisabled,
+ getZoomState: () => zoomState,
+ onOperationAvailabilityChange: payload => {
+ events.push(`emit:availability:${payload.zoomOut}`);
+ },
+ onZoomChange: payload => {
+ events.push(`emit:zoom:${payload.label}`);
+ },
+ });
+
+ expect(actions.isZoomButtonDisabled('canZoomIn')).toBe(false);
+ expect(actions.isZoomButtonDisabled('canZoomOut')).toBe(true);
+ toolbarDisabled = true;
+ expect(actions.isZoomButtonDisabled('canZoomIn')).toBe(true);
+
+ expect(actions.notifyOperationAvailabilityChange()).toBe(true);
+ expect(actions.notifyZoomChange()).toBe(true);
+
+ expect(events).toEqual([
+ 'emit:availability:false',
+ 'emit:zoom:100%',
+ ]);
+ });
+
+ it('runs toolbar sync notifications and keeps zoom sync snapshot fields stable', () => {
+ const events: string[] = [];
+ const availability = {
+ download: true,
+ print: false,
+ exportHtml: true,
+ zoom: true,
+ zoomIn: true,
+ zoomOut: true,
+ zoomReset: false,
+ };
+ const zoomState = {
+ scale: 1.25,
+ label: '125%',
+ canZoomIn: true,
+ canZoomOut: true,
+ canReset: true,
+ minScale: 0.25,
+ maxScale: 4,
+ };
+ const toolbarActions = {
+ notifyOperationAvailabilityChange(nextAvailability = availability) {
+ events.push(`availability:${nextAvailability.zoomReset}`);
+ return true;
+ },
+ notifyZoomChange(nextState = zoomState) {
+ events.push(`zoom:${nextState.label}`);
+ return true;
+ },
+ };
+
+ expect(createFileViewerToolbarZoomSyncSnapshot(zoomState)).toEqual([
+ 1.25,
+ '125%',
+ true,
+ true,
+ true,
+ ]);
+ expect(runFileViewerToolbarAvailabilitySync({
+ toolbarActions,
+ availability,
+ })).toBe(true);
+ expect(runFileViewerToolbarZoomSync({
+ toolbarActions,
+ state: zoomState,
+ })).toBe(true);
+ expect(events).toEqual([
+ 'availability:false',
+ 'zoom:125%',
+ ]);
+ });
+
+ it('creates reusable toolbar controller action handlers for framework wrappers', () => {
+ const events: string[] = [];
+ const zoomState = {
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: false,
+ canReset: true,
+ minScale: 0.25,
+ maxScale: 4,
+ };
+ let buffer: ArrayBuffer | null = new ArrayBuffer(8);
+ let hasError = false;
+ let loading = false;
+ const controller = createFileViewerToolbarControllerActionHandlers({
+ getAdapter: () => ({ exportHtml: true }),
+ getBuffer: () => buffer,
+ getExtension: () => 'pdf',
+ getFile: () => null,
+ getHasError: () => hasError,
+ getLoading: () => loading,
+ getOptions: () => undefined,
+ getSourceUrl: () => null,
+ getToolbar: () => ({
+ download: true,
+ print: true,
+ exportHtml: true,
+ zoom: true,
+ }),
+ getRenderedReady: () => true,
+ getZoomState: () => zoomState,
+ zoomSyncState: zoomState,
+ onOperationAvailabilityChange: availability => {
+ events.push(`emit:availability:${availability.download}:${availability.zoomOut}`);
+ },
+ onZoomChange: state => {
+ events.push(`emit:zoom:${state.label}`);
+ },
+ });
+
+ const state = controller.resolveToolbarState();
+
+ expect(state.operationAvailability).toMatchObject({
+ download: true,
+ print: false,
+ exportHtml: true,
+ zoom: true,
+ zoomIn: true,
+ zoomOut: false,
+ zoomReset: true,
+ });
+ expect(state.visibleToolbar).toEqual({
+ download: true,
+ print: false,
+ exportHtml: true,
+ zoom: true,
+ search: true,
+ theme: true,
+ });
+ expect(state.toolbarPosition).toBe('bottom-right');
+ expect(state.toolbarDisabled).toBe(false);
+ expect(controller.createZoomSyncSnapshot()).toEqual([
+ 1,
+ '100%',
+ true,
+ false,
+ true,
+ ]);
+ expect(controller.isZoomButtonDisabled('canZoomIn')).toBe(false);
+ expect(controller.isZoomButtonDisabled('canZoomOut')).toBe(true);
+ expect(controller.syncOperationAvailability()).toBe(true);
+ expect(controller.syncZoomChange()).toBe(true);
+ expect(events).toEqual([
+ 'emit:availability:true:false',
+ 'emit:zoom:100%',
+ ]);
+
+ buffer = null;
+ hasError = true;
+ loading = true;
+ const disabledState = controller.resolveToolbarState();
+
+ expect(disabledState.operationAvailability.download).toBe(false);
+ expect(disabledState.operationAvailability.exportHtml).toBe(false);
+ expect(disabledState.toolbarDisabled).toBe(true);
+ expect(controller.isZoomButtonDisabled('canZoomIn')).toBe(true);
+ });
+
+ it('runs lifecycle hooks and operation guards in deterministic order', async () => {
+ const events: string[] = [];
+ const context = buildFileViewerLifecycleContext({
+ phase: 'load-start',
+ source: 'file',
+ filename: 'guard.pdf',
+ version: 1,
+ timestamp: 10,
+ });
+ const operationContext = buildFileViewerOperationContext('download', context, 12);
+
+ await runFileViewerLifecycleHook(context, {
+ onLoadStart: nextContext => events.push(`lifecycle:${nextContext.phase}`),
+ });
+
+ const allowed = await runFileViewerBeforeOperation({
+ context: operationContext,
+ options: {
+ beforeOperation: nextContext => {
+ events.push(`global:${nextContext.operation}`);
+ },
+ toolbar: {
+ beforeOperation: nextContext => {
+ events.push(`toolbar:${nextContext.operation}`);
+ },
+ beforeDownload: () => {
+ events.push('download:false');
+ return false;
+ },
+ },
+ },
+ onBefore: nextContext => events.push(`before:${nextContext.operation}`),
+ onCancel: nextContext => events.push(`cancel:${nextContext.operation}`),
+ });
+
+ expect(allowed).toBe(false);
+ expect(events).toEqual([
+ 'lifecycle:load-start',
+ 'before:download',
+ 'global:download',
+ 'toolbar:download',
+ 'download:false',
+ 'cancel:download',
+ ]);
+ });
+
+ it('blocks public operations through toolbar permission maps before custom hooks run', async () => {
+ const events: string[] = [];
+ const context = buildFileViewerLifecycleContext({
+ phase: 'load-complete',
+ source: 'url',
+ url: '/secure.pdf',
+ version: 1,
+ timestamp: 10,
+ });
+ const operationContext = buildFileViewerOperationContext('print', context, 12);
+
+ const allowed = await runFileViewerBeforeOperation({
+ context: operationContext,
+ options: {
+ beforeOperation: nextContext => {
+ events.push(`global:${nextContext.operation}`);
+ },
+ toolbar: {
+ permissions: {
+ print: false,
+ },
+ },
+ },
+ onBefore: nextContext => events.push(`before:${nextContext.operation}`),
+ onCancel: nextContext => events.push(`cancel:${nextContext.operation}`),
+ });
+
+ expect(allowed).toBe(false);
+ expect(events).toEqual([
+ 'before:print',
+ 'cancel:print',
+ ]);
+ });
+
+ it('normalizes toolbar visibility and operation availability in core', () => {
+ expect(normalizeFileViewerToolbar(undefined)).toEqual({
+ download: true,
+ print: true,
+ exportHtml: true,
+ zoom: true,
+ search: true,
+ theme: true,
+ order: [...DEFAULT_FILE_VIEWER_TOOLBAR_ORDER],
+ });
+ expect(normalizeFileViewerToolbar({ toolbar: false })).toEqual({
+ download: false,
+ print: false,
+ exportHtml: false,
+ zoom: false,
+ search: false,
+ theme: false,
+ order: [...DEFAULT_FILE_VIEWER_TOOLBAR_ORDER],
+ });
+ expect(normalizeFileViewerToolbar({
+ toolbar: {
+ download: true,
+ print: true,
+ exportHtml: true,
+ zoom: true,
+ order: ['zoom', 'search', 'download', 'print'],
+ items: {
+ print: false,
+ 'zoom-out': false,
+ },
+ permissions: {
+ 'export-html': false,
+ 'zoom-reset': false,
+ },
+ position: 'bottom-right',
+ },
+ })).toMatchObject({
+ download: true,
+ print: false,
+ exportHtml: false,
+ zoom: true,
+ search: true,
+ theme: true,
+ order: ['zoom', 'search', 'download', 'print', 'exportHtml', 'theme'],
+ items: {
+ print: false,
+ 'zoom-out': false,
+ },
+ permissions: {
+ 'export-html': false,
+ 'zoom-reset': false,
+ },
+ position: 'bottom-right',
+ });
+
+ const availability = resolveFileViewerOperationAvailability({
+ extension: 'pdf',
+ hasOriginalSource: true,
+ renderedReady: true,
+ adapter: { toHtml: () => 'pdf' },
+ zoomState: {
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: false,
+ canReset: false,
+ },
+ });
+
+ expect(availability).toMatchObject({
+ download: true,
+ print: true,
+ exportHtml: true,
+ zoom: true,
+ zoomIn: true,
+ zoomOut: false,
+ zoomReset: false,
+ });
+ expect(resolveVisibleFileViewerToolbar({ download: true, print: false, exportHtml: true, zoom: true }, availability)).toEqual({
+ download: true,
+ print: false,
+ exportHtml: true,
+ zoom: true,
+ search: true,
+ theme: true,
+ });
+ expect(hasVisibleFileViewerToolbarActions({
+ download: false,
+ print: false,
+ exportHtml: false,
+ zoom: false,
+ })).toBe(false);
+ expect(hasVisibleFileViewerToolbarActions({
+ download: false,
+ print: false,
+ exportHtml: true,
+ zoom: false,
+ })).toBe(true);
+ expect(isFileViewerZoomButtonDisabled({
+ action: 'canZoomIn',
+ availability,
+ zoomState: {
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: false,
+ canReset: false,
+ },
+ })).toBe(false);
+ expect(isFileViewerZoomButtonDisabled({
+ action: 'canZoomOut',
+ availability,
+ zoomState: {
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: false,
+ canReset: false,
+ },
+ })).toBe(true);
+ expect(isFileViewerZoomButtonDisabled({
+ action: 'canZoomIn',
+ availability,
+ toolbarDisabled: true,
+ zoomState: {
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: false,
+ canReset: false,
+ },
+ })).toBe(true);
+ expect(resolveFileViewerToolbarState({
+ extension: 'pdf',
+ hasOriginalSource: true,
+ renderedReady: true,
+ adapter: { toHtml: () => 'pdf' },
+ zoomState: {
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: false,
+ canReset: false,
+ },
+ toolbar: {
+ download: true,
+ print: false,
+ exportHtml: true,
+ zoom: true,
+ },
+ loading: true,
+ })).toMatchObject({
+ operationAvailability: {
+ download: true,
+ print: true,
+ exportHtml: true,
+ zoom: true,
+ },
+ visibleToolbar: {
+ download: true,
+ print: false,
+ exportHtml: true,
+ zoom: true,
+ theme: true,
+ },
+ toolbarOrder: [...DEFAULT_FILE_VIEWER_TOOLBAR_ORDER],
+ showToolbar: true,
+ toolbarPosition: 'bottom-right',
+ toolbarDisabled: true,
+ });
+ const permissionState = resolveFileViewerToolbarState({
+ extension: 'pdf',
+ hasOriginalSource: true,
+ renderedReady: true,
+ adapter: { toHtml: () => 'pdf' },
+ zoomState: {
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: true,
+ canReset: true,
+ },
+ toolbar: normalizeFileViewerToolbar({
+ toolbar: {
+ download: true,
+ print: true,
+ exportHtml: true,
+ zoom: true,
+ permissions: {
+ download: false,
+ 'zoom-in': false,
+ },
+ },
+ }),
+ options: {
+ toolbar: {
+ permissions: {
+ download: false,
+ 'zoom-in': false,
+ },
+ },
+ },
+ });
+ expect(permissionState.operationAvailability).toMatchObject({
+ download: false,
+ print: true,
+ exportHtml: true,
+ zoom: true,
+ zoomIn: false,
+ zoomOut: true,
+ zoomReset: true,
+ });
+ expect(permissionState.visibleToolbar).toMatchObject({
+ download: false,
+ print: true,
+ exportHtml: true,
+ zoom: true,
+ });
+ const onlyZoomIn = { permissions: { 'zoom-out': false, 'zoom-reset': false } };
+ const limitState = resolveFileViewerToolbarState({
+ extension: 'png', renderedReady: true,
+ zoomState: { scale: 5, label: '500%', canZoomIn: false, canZoomOut: true, canReset: true },
+ toolbar: normalizeFileViewerToolbar({ toolbar: onlyZoomIn }),
+ options: { toolbar: onlyZoomIn },
+ });
+ expect(limitState.operationAvailability).toMatchObject({ zoom: false, zoomIn: false, zoomOut: false, zoomReset: false });
+ expect(resolveFileViewerToolbarZoomButtons({
+ toolbar: normalizeFileViewerToolbar({ toolbar: onlyZoomIn }), renderedReady: true,
+ zoomState: { scale: 5, label: '500%', canZoomIn: false, canZoomOut: true, canReset: true },
+ })).toEqual({ zoomIn: true, zoomOut: false, zoomReset: false });
+ expect(limitState.visibleToolbar.zoom).toBe(true);
+ expect(isFileViewerToolbarOperationPermitted({ permissions: { print: false } }, 'print')).toBe(false);
+ expect(isFileViewerZoomButtonDisabled({
+ action: 'canZoomIn',
+ availability: permissionState.operationAvailability,
+ zoomState: {
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: true,
+ canReset: true,
+ },
+ })).toBe(true);
+ expect(resolveFileViewerOperationAvailability({
+ extension: 'docx',
+ source: {
+ file: new File(['demo'], 'demo.docx'),
+ },
+ renderedReady: false,
+ zoomState: {
+ scale: 1,
+ label: '100%',
+ canZoomIn: false,
+ canZoomOut: false,
+ canReset: false,
+ },
+ }).download).toBe(true);
+ expect(resolveFileViewerOperationAvailability({
+ extension: 'docx',
+ source: {},
+ renderedReady: false,
+ zoomState: {
+ scale: 1,
+ label: '100%',
+ canZoomIn: false,
+ canZoomOut: false,
+ canReset: false,
+ },
+ }).download).toBe(false);
+ expect(resolveFileViewerToolbarPosition(undefined, 'pdf')).toBe('bottom-right');
+ expect(resolveFileViewerToolbarPosition({ toolbar: { position: 'top' } }, 'pdf')).toBe('top');
+ expect(resolveFileViewerToolbarPosition({ toolbar: { position: 'top-center' } }, 'docx')).toBe('top-center');
+ });
+
+ it('resolves toolbar group order with defaults, aliases and duplicate filtering', () => {
+ expect(resolveFileViewerToolbarOrder(undefined)).toEqual([
+ 'search',
+ 'zoom',
+ 'download',
+ 'print',
+ 'exportHtml',
+ 'theme',
+ ]);
+ expect(resolveFileViewerToolbarOrder({
+ order: ['zoom', 'search', 'download', 'print'],
+ })).toEqual([
+ 'zoom',
+ 'search',
+ 'download',
+ 'print',
+ 'exportHtml',
+ 'theme',
+ ]);
+ expect(resolveFileViewerToolbarOrder({
+ order: ['zoom', 'export-html', 'zoom', 'exportHtml', 'download'],
+ })).toEqual([
+ 'zoom',
+ 'exportHtml',
+ 'download',
+ 'search',
+ 'print',
+ 'theme',
+ ]);
+ expect(resolveFileViewerToolbarState({
+ extension: 'docx',
+ hasOriginalSource: true,
+ renderedReady: true,
+ adapter: { toHtml: () => 'docx' },
+ zoomState: {
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: true,
+ canReset: true,
+ },
+ toolbar: normalizeFileViewerToolbar({
+ toolbar: {
+ order: ['download', 'zoom'],
+ },
+ }),
+ }).toolbarOrder).toEqual([
+ 'download',
+ 'zoom',
+ 'search',
+ 'print',
+ 'exportHtml',
+ 'theme',
+ ]);
+ });
+
+ it('resolves operation filenames from display names, original source metadata, and core defaults', () => {
+ expect(resolveFileViewerOperationFilename({
+ filename: '显示文档.pdf',
+ fallback: DEFAULT_FILE_VIEWER_DOWNLOAD_FILENAME,
+ })).toBe('显示文档.pdf');
+ expect(resolveFileViewerOperationFilename({
+ source: {
+ file: new File(['demo'], '原始文档.docx'),
+ },
+ fallback: DEFAULT_FILE_VIEWER_DOWNLOAD_FILENAME,
+ })).toBe('原始文档.docx');
+ expect(resolveFileViewerOperationFilename({
+ fallback: DEFAULT_FILE_VIEWER_DOWNLOAD_FILENAME,
+ })).toBe(DEFAULT_FILE_VIEWER_DOWNLOAD_FILENAME);
+ expect(resolveFileViewerOperationFilename({
+ fallback: DEFAULT_FILE_VIEWER_EXPORT_FILENAME,
+ })).toBe(DEFAULT_FILE_VIEWER_EXPORT_FILENAME);
+ expect(resolveFileViewerOperationFilename({})).toBe(DEFAULT_FILE_VIEWER_PREVIEW_TITLE);
+ expect(resolveFileViewerDisplayFilename({
+ kind: 'url',
+ filename: '线上报告.pdf',
+ extension: 'pdf',
+ url: '/docs/report.pdf',
+ })).toBe('线上报告.pdf');
+ expect(resolveFileViewerDisplayFilename(null)).toBe(DEFAULT_FILE_VIEWER_EXPORT_FILENAME);
+ expect(createFileViewerOriginalSourceStateFromNormalizedSource({
+ kind: 'url',
+ filename: '线上报告.pdf',
+ extension: 'pdf',
+ url: '/docs/report.pdf',
+ size: 1024,
+ })).toMatchObject({
+ buffer: null,
+ file: null,
+ url: '/docs/report.pdf',
+ filename: '线上报告.pdf',
+ });
+ });
+
+ it('clones operation availability snapshots without sharing mutable references', () => {
+ const availability = resolveFileViewerOperationAvailability({
+ extension: 'pdf',
+ hasOriginalSource: true,
+ renderedReady: true,
+ adapter: { toHtml: () => 'pdf' },
+ zoomState: {
+ scale: 1.25,
+ label: '125%',
+ canZoomIn: false,
+ canZoomOut: true,
+ canReset: true,
+ },
+ });
+
+ const snapshot = cloneFileViewerOperationAvailability(availability);
+
+ expect(snapshot).toEqual(availability);
+ expect(snapshot).not.toBe(availability);
+
+ snapshot.download = false;
+ snapshot.zoomReset = false;
+
+ expect(availability.download).toBe(true);
+ expect(availability.zoomReset).toBe(true);
+ });
+
+ it('creates public api facades with cloned operation availability snapshots', async () => {
+ const availability = {
+ download: true,
+ print: true,
+ exportHtml: false,
+ zoom: true,
+ zoomIn: true,
+ zoomOut: false,
+ zoomReset: true,
+ };
+ const calls: string[] = [];
+ const api = createFileViewerPublicApi({
+ getOperationAvailability: () => availability,
+ destroy: () => { calls.push('destroy'); },
+ downloadOriginalFile: async () => { calls.push('download'); },
+ printRenderedHtml: async () => { calls.push('print'); },
+ exportRenderedHtml: async () => { calls.push('export-html'); },
+ zoomIn: async () => ({
+ scale: 1.25,
+ label: '125%',
+ canZoomIn: true,
+ canZoomOut: true,
+ canReset: true,
+ }),
+ zoomOut: async () => ({
+ scale: 0.75,
+ label: '75%',
+ canZoomIn: true,
+ canZoomOut: true,
+ canReset: true,
+ }),
+ resetZoom: async () => ({
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: false,
+ canReset: false,
+ }),
+ getZoomState: () => ({
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: false,
+ canReset: false,
+ }),
+ getViewState: () => null,
+ applyViewState: async () => null,
+ getScrollContainer: () => null,
+ searchDocument: async query => ({
+ query,
+ total: 0,
+ currentIndex: -1,
+ current: null,
+ matches: [],
+ }),
+ clearDocumentSearch: async () => ({
+ query: '',
+ total: 0,
+ currentIndex: -1,
+ current: null,
+ matches: [],
+ }),
+ nextSearchResult: async () => ({
+ query: '',
+ total: 0,
+ currentIndex: -1,
+ current: null,
+ matches: [],
+ }),
+ previousSearchResult: async () => ({
+ query: '',
+ total: 0,
+ currentIndex: -1,
+ current: null,
+ matches: [],
+ }),
+ getSearchState: () => ({
+ query: '',
+ total: 0,
+ currentIndex: -1,
+ current: null,
+ matches: [],
+ }),
+ collectDocumentAnchors: async () => [],
+ scrollToAnchor: async () => true,
+ scrollToLine: async () => true,
+ getDocumentTextChunks: () => [],
+ });
+
+ const snapshot = api.getOperationAvailability();
+ snapshot.download = false;
+ expect(api.getOperationAvailability().download).toBe(true);
+
+ await api.downloadOriginalFile();
+ await api.printRenderedHtml();
+ await api.exportRenderedHtml();
+ api.destroy();
+
+ expect(calls).toEqual(['download', 'print', 'export-html', 'destroy']);
+ });
+
+ it('reports operation guard errors and cancels the operation', async () => {
+ const onError = vi.fn();
+ const onCancel = vi.fn();
+ const context = buildFileViewerOperationContext('print', buildFileViewerLifecycleContext({
+ phase: 'load-complete',
+ source: 'file',
+ filename: 'broken.pdf',
+ version: 1,
+ timestamp: 10,
+ }));
+
+ await expect(runFileViewerBeforeOperation({
+ context,
+ options: {
+ beforeOperation: () => {
+ throw new Error('denied');
+ },
+ },
+ onError,
+ onCancel,
+ })).resolves.toBe(false);
+
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(onCancel).toHaveBeenCalledWith(context);
+ });
+
+ it('executes download, export and print through framework-neutral core operations', async () => {
+ const document = new JSDOM(
+ 'Preview
',
+ ).window.document;
+ const root = document.getElementById('root') as HTMLElement;
+ const originalDocument = globalThis.document;
+ const originalWindow = globalThis.window;
+ const originalCreateObjectUrl = URL.createObjectURL;
+ const originalRevokeObjectUrl = URL.revokeObjectURL;
+ const originalCreateElement = document.createElement.bind(document);
+ const beforeOperations: string[] = [];
+ let downloadedName = '';
+ let downloadedSize = -1;
+ let printed = false;
+ let printHtml: string;
+
+ document.createElement = ((tagName: string) => {
+ const element = originalCreateElement(tagName);
+ if (tagName.toLowerCase() === 'a') {
+ Object.defineProperty(element, 'click', {
+ configurable: true,
+ value: () => {
+ downloadedName = (element as HTMLAnchorElement).download;
+ },
+ });
+ }
+ return element;
+ }) as Document['createElement'];
+ Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {
+ requestAnimationFrame: (callback: () => void) => {
+ callback();
+ return 1;
+ },
+ setTimeout: (callback: () => void) => {
+ callback();
+ return 1;
+ },
+ },
+ });
+ URL.createObjectURL = blob => {
+ downloadedSize = blob.size;
+ return 'blob:viewer-operation-test';
+ };
+ URL.revokeObjectURL = () => undefined;
+
+ const beforeOperation = (operation: FileViewerOperationType) => {
+ beforeOperations.push(operation);
+ return true;
+ };
+
+ const printWindow = new JSDOM(
+ '',
+ { pretendToBeVisual: true },
+ ).window as unknown as Window;
+ vi.spyOn(printWindow.document, 'write').mockImplementation(() => {
+ throw new Error('document.write is forbidden');
+ });
+ Object.defineProperty(printWindow.document, 'readyState', {
+ configurable: true,
+ value: 'complete',
+ });
+ Object.defineProperty(printWindow, 'focus', { configurable: true, value: () => undefined });
+ Object.defineProperty(printWindow, 'print', {
+ configurable: true,
+ value: () => {
+ printed = true;
+ },
+ });
+ Object.defineProperty(printWindow, 'requestAnimationFrame', {
+ configurable: true,
+ value: (callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ },
+ });
+
+ try {
+ const html = await executeFileViewerExportHtmlOperation({
+ source: root,
+ adapter: {
+ includeDocumentStyles: false,
+ toHtml: options => ``,
+ },
+ title: 'demo.viewer',
+ filename: 'demo.viewer',
+ download: false,
+ beforeOperation,
+ });
+ expect(html).toContain('data-mode="export"');
+ expect(html).toContain('demo.viewer');
+
+ await executeFileViewerPrintOperation({
+ source: root,
+ title: 'demo.viewer',
+ printWindow,
+ beforeOperation,
+ });
+ expect(printed).toBe(true);
+ printHtml = printWindow.document.documentElement.outerHTML;
+ expect(printHtml).toContain('Preview
');
+ expect(printWindow.document.compatMode).toBe('CSS1Compat');
+
+ await executeFileViewerDownloadOperation({
+ source: {
+ buffer: new Uint8Array([1, 2, 3]).buffer,
+ filename: 'demo.bin',
+ },
+ beforeOperation,
+ });
+ expect(downloadedName).toBe('demo.bin');
+ expect(downloadedSize).toBe(3);
+
+ const detachedPdfBuffer = new TextEncoder().encode('%PDF-detached').buffer;
+ structuredClone(detachedPdfBuffer, { transfer: [detachedPdfBuffer] });
+ const uploadedPdf = new File(['%PDF-complete'], 'uploaded.pdf', {
+ type: 'application/pdf',
+ });
+ expect(detachedPdfBuffer.byteLength).toBe(0);
+
+ await executeFileViewerDownloadOperation({
+ source: {
+ buffer: detachedPdfBuffer,
+ file: uploadedPdf,
+ filename: uploadedPdf.name,
+ mimeType: uploadedPdf.type,
+ },
+ beforeOperation,
+ });
+ expect(downloadedName).toBe('uploaded.pdf');
+ expect(downloadedSize).toBe(uploadedPdf.size);
+ expect(beforeOperations).toEqual(['export-html', 'print', 'download', 'download']);
+
+ await expect(executeFileViewerDownloadOperation({
+ source: {},
+ throwOnMissingSource: false,
+ })).resolves.toBe(false);
+ } finally {
+ document.createElement = originalCreateElement as Document['createElement'];
+ URL.createObjectURL = originalCreateObjectUrl;
+ URL.revokeObjectURL = originalRevokeObjectUrl;
+ Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
+ Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
+ }
+ });
+
+ it('creates framework-neutral file operation actions for wrappers', async () => {
+ const document = new JSDOM(
+ 'Action facade
',
+ ).window.document;
+ const root = document.getElementById('root') as HTMLElement;
+ const originalDocument = globalThis.document;
+ const originalWindow = globalThis.window;
+ const originalCreateObjectUrl = URL.createObjectURL;
+ const originalRevokeObjectUrl = URL.revokeObjectURL;
+ const originalCreateElement = document.createElement.bind(document);
+ const beforeOperations: string[] = [];
+ const errors: Array<{ operation: string; error: unknown }> = [];
+ const errorMessages: string[] = [];
+ let downloadedName = '';
+ let printHtml: string;
+ let printed = false;
+ let printAvailable = false;
+
+ document.createElement = ((tagName: string) => {
+ const element = originalCreateElement(tagName);
+ if (tagName.toLowerCase() === 'a') {
+ Object.defineProperty(element, 'click', {
+ configurable: true,
+ value: () => {
+ downloadedName = (element as HTMLAnchorElement).download;
+ },
+ });
+ }
+ return element;
+ }) as Document['createElement'];
+
+ const printWindow = new JSDOM(
+ '',
+ { pretendToBeVisual: true },
+ ).window as unknown as Window;
+ vi.spyOn(printWindow.document, 'write').mockImplementation(() => {
+ throw new Error('document.write is forbidden');
+ });
+ Object.defineProperty(printWindow.document, 'readyState', {
+ configurable: true,
+ value: 'complete',
+ });
+ Object.defineProperty(printWindow, 'focus', { configurable: true, value: () => undefined });
+ Object.defineProperty(printWindow, 'print', {
+ configurable: true,
+ value: () => {
+ printed = true;
+ },
+ });
+ Object.defineProperty(printWindow, 'requestAnimationFrame', {
+ configurable: true,
+ value: (callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ },
+ });
+
+ Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {
+ open: () => printWindow,
+ requestAnimationFrame: (callback: () => void) => {
+ callback();
+ return 1;
+ },
+ setTimeout: (callback: () => void) => {
+ callback();
+ return 1;
+ },
+ },
+ });
+ URL.createObjectURL = () => 'blob:viewer-action-test';
+ URL.revokeObjectURL = () => undefined;
+
+ const actions = createFileViewerOperationActionHandlers({
+ getBuffer: () => new Uint8Array([4, 5, 6]).buffer,
+ getFile: () => null,
+ getUrl: () => null,
+ getFilename: () => 'suite.viewer',
+ getMimeType: () => 'application/octet-stream',
+ getRenderedSource: () => root,
+ getAdapter: () => ({
+ includeDocumentStyles: false,
+ toHtml: options => ``,
+ }),
+ getWatermarkInlineStyle: () => '--viewer-watermark: demo;',
+ getPrintAvailable: () => printAvailable,
+ beforeOperation: operation => {
+ beforeOperations.push(operation);
+ return true;
+ },
+ onError: context => {
+ errors.push(context);
+ },
+ formatErrorMessage: (prefix, error) => `${prefix}:${error instanceof Error ? error.message : String(error)}`,
+ onErrorMessage: message => {
+ errorMessages.push(message);
+ },
+ });
+
+ try {
+ const html = await actions.exportRenderedHtml();
+ expect(html).toContain('data-mode="export"');
+ expect(downloadedName).toBe('suite.viewer.rendered.html');
+
+ await expect(actions.downloadOriginalFile()).resolves.toBe(true);
+ expect(downloadedName).toBe('suite.viewer');
+
+ await expect(actions.printRenderedHtml()).resolves.toBeUndefined();
+ expect(errors).toHaveLength(1);
+ expect(errors[0].operation).toBe('print');
+ expect(errorMessages[0]).toContain('打印失败:当前文件类型不支持完整打印');
+ expect(printed).toBe(false);
+
+ printAvailable = true;
+ await expect(actions.printRenderedHtml()).resolves.toBe(true);
+ expect(printed).toBe(true);
+ printHtml = printWindow.document.documentElement.outerHTML;
+ expect(printHtml).toContain('data-mode="print"');
+ expect(beforeOperations).toEqual(['export-html', 'download', 'print']);
+ } finally {
+ document.createElement = originalCreateElement as Document['createElement'];
+ URL.createObjectURL = originalCreateObjectUrl;
+ URL.revokeObjectURL = originalRevokeObjectUrl;
+ Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
+ Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
+ }
+ });
+
+ it('opens the print window before asynchronous snapshot work', async () => {
+ const sourceDocument = new JSDOM(
+ 'CAD',
+ { pretendToBeVisual: true },
+ ).window.document;
+ const root = sourceDocument.getElementById('root') as HTMLElement;
+ const originalDocument = globalThis.document;
+ const originalWindow = globalThis.window;
+ const printWindow = new JSDOM(
+ '',
+ { pretendToBeVisual: true },
+ ).window as unknown as Window;
+ const order: string[] = [];
+ Object.defineProperty(printWindow, 'focus', { configurable: true, value: () => undefined });
+ Object.defineProperty(printWindow, 'print', { configurable: true, value: () => undefined });
+ Object.defineProperty(printWindow, 'requestAnimationFrame', {
+ configurable: true,
+ value: (callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ },
+ });
+ Object.defineProperty(printWindow.document, 'readyState', {
+ configurable: true,
+ value: 'complete',
+ });
+ Object.defineProperty(globalThis, 'document', { configurable: true, value: sourceDocument });
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {
+ open: () => {
+ order.push('open');
+ return printWindow;
+ },
+ requestAnimationFrame: (callback: () => void) => {
+ callback();
+ return 1;
+ },
+ setTimeout: (callback: () => void) => {
+ callback();
+ return 1;
+ },
+ },
+ });
+
+ try {
+ await expect(executeFileViewerPrintOperation({
+ source: root,
+ autoPrint: false,
+ beforeOperation: async () => {
+ await Promise.resolve();
+ order.push('before');
+ return true;
+ },
+ })).resolves.toBe(true);
+ expect(order).toEqual(['open', 'before']);
+ expect(printWindow.document.documentElement.outerHTML).toContain('CAD');
+ } finally {
+ Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
+ Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
+ }
+ });
+
+ it('closes an owned print window when the asynchronous hook rejects', async () => {
+ const sourceDocument = new JSDOM(
+ 'CAD',
+ { pretendToBeVisual: true },
+ ).window.document;
+ const root = sourceDocument.getElementById('root') as HTMLElement;
+ const originalDocument = globalThis.document;
+ const originalWindow = globalThis.window;
+ const close = vi.fn();
+ const printWindow = new JSDOM(
+ '',
+ { pretendToBeVisual: true },
+ ).window as unknown as Window;
+ Object.defineProperty(printWindow, 'close', { configurable: true, value: close });
+ Object.defineProperty(globalThis, 'document', { configurable: true, value: sourceDocument });
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: { open: () => printWindow },
+ });
+
+ try {
+ await expect(executeFileViewerPrintOperation({
+ source: root,
+ autoPrint: false,
+ beforeOperation: async () => {
+ throw new Error('print preparation failed');
+ },
+ })).rejects.toThrow('print preparation failed');
+ expect(close).toHaveBeenCalledTimes(1);
+ } finally {
+ Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
+ Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
+ }
+ });
+
+ it('creates public operation action facades that preserve the void component contract', async () => {
+ const { document } = parseHTML('Document');
+ const root = document.querySelector('#root') as HTMLElement;
+ const beforeOperations: FileViewerOperationType[] = [];
+ const actions = createFileViewerPublicOperationActionHandlers({
+ getBuffer: () => null,
+ getFile: () => null,
+ getUrl: () => '/example/report.pdf',
+ getFilename: () => 'report.pdf',
+ getRenderedSource: () => root,
+ getPrintAvailable: () => true,
+ beforeOperation: operation => {
+ beforeOperations.push(operation);
+ return false;
+ },
+ });
+
+ await expect(actions.downloadOriginalFile()).resolves.toBeUndefined();
+ await expect(actions.exportRenderedHtml()).resolves.toBeUndefined();
+ await expect(actions.printRenderedHtml()).resolves.toBeUndefined();
+ expect(beforeOperations).toEqual(['download', 'export-html', 'print']);
+ });
+
+ it('formats operation action errors through core defaults', () => {
+ const message = resolveFileViewerOperationActionErrorMessage({
+ context: {
+ operation: 'export-html',
+ error: new Error('missing DOM'),
+ },
+ formatErrorMessage: (prefix, error) => `${prefix}:${error instanceof Error ? error.message : String(error)}`,
+ });
+ const customMessage = resolveFileViewerOperationActionErrorMessage({
+ context: {
+ operation: 'download',
+ error: 'offline',
+ },
+ prefixes: {
+ download: '保存失败',
+ },
+ formatErrorMessage: (prefix, error) => `${prefix}:${String(error)}`,
+ });
+
+ expect(message).toBe('导出 HTML 失败:missing DOM');
+ expect(customMessage).toBe('保存失败:offline');
+ });
+
+ it('normalizes original source state in core', () => {
+ const file = new File(['demo'], 'demo.docx', {
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ });
+ const source = createFileViewerOriginalSourceState({
+ file,
+ filename: 'display.docx',
+ });
+
+ expect(source).toEqual({
+ buffer: null,
+ file,
+ url: null,
+ filename: 'display.docx',
+ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ });
+ expect(resolveFileViewerOriginalFilename(source)).toBe('display.docx');
+ expect(createFileViewerOriginalSourceState({
+ buffer: new ArrayBuffer(2),
+ mimeType: 'application/octet-stream',
+ }).mimeType).toBe('application/octet-stream');
+ });
+});
diff --git a/test/vue-toolbar-hook.spec.ts b/test/vue-toolbar-hook.spec.ts
new file mode 100644
index 000000000..aedcfb7d1
--- /dev/null
+++ b/test/vue-toolbar-hook.spec.ts
@@ -0,0 +1,124 @@
+import { computed, effectScope, nextTick, reactive, ref, shallowRef } from 'vue'
+import { describe, expect, it } from 'vitest'
+import { useViewerToolbar } from '../packages/components/vue3/src/package/components/FileViewer/hooks/useViewerToolbar'
+import type {
+ FileViewerOperationAvailability,
+ FileViewerToolbarOptions,
+ FileViewerZoomState
+} from '../packages/components/vue3/src/package/common/type'
+
+describe('Vue FileViewer toolbar hook', () => {
+ it('keeps toolbar visibility, PDF floating position and emitted capability states in one facade', async () => {
+ const activeExportAdapter = shallowRef(null)
+ const currentBuffer = ref(new ArrayBuffer(8))
+ const currentExtend = computed(() => 'pdf')
+ const currentFile = ref(null)
+ const currentSourceUrl = ref(null)
+ const error = ref('')
+ const loading = ref(false)
+ const rendererSearchAvailable = ref(true)
+ const renderedReady = ref(true)
+ const normalizedToolbar = computed(() => ({
+ download: true,
+ print: true,
+ exportHtml: true,
+ zoom: true,
+ theme: true
+ }))
+ const zoomState = reactive({
+ scale: 1,
+ label: '100%',
+ canZoomIn: true,
+ canZoomOut: false,
+ canReset: false,
+ minScale: 0.25,
+ maxScale: 4
+ })
+ const availabilityEvents: FileViewerOperationAvailability[] = []
+ const zoomEvents: FileViewerZoomState[] = []
+ const scope = effectScope()
+
+ const toolbar = scope.run(() => useViewerToolbar({
+ activeExportAdapter,
+ currentBuffer,
+ currentExtend,
+ currentFile,
+ currentSourceUrl,
+ error,
+ getOptions: () => undefined,
+ getZoomState: () => ({ ...zoomState }),
+ loading,
+ normalizedToolbar,
+ rendererSearchAvailable,
+ renderedReady,
+ zoomState,
+ emitOperationAvailabilityChange: availability => {
+ availabilityEvents.push(availability)
+ },
+ emitZoomChange: state => {
+ zoomEvents.push(state)
+ }
+ }))
+
+ expect(toolbar).toBeTruthy()
+ expect(toolbar?.operationAvailability.value).toMatchObject({
+ download: true,
+ print: false,
+ exportHtml: true,
+ zoom: true,
+ zoomIn: true,
+ zoomOut: false
+ })
+ expect(toolbar?.visibleToolbar.value).toMatchObject({
+ download: true,
+ print: false,
+ exportHtml: true,
+ zoom: true,
+ theme: true
+ })
+ expect(toolbar?.toolbarOrder.value).toEqual([
+ 'search',
+ 'zoom',
+ 'download',
+ 'print',
+ 'exportHtml',
+ 'theme'
+ ])
+ expect(toolbar?.showToolbar.value).toBe(true)
+ expect(toolbar?.toolbarPosition.value).toBe('bottom-right')
+ expect(toolbar?.zoomButtonDisabled('canZoomIn')).toBe(false)
+ expect(toolbar?.zoomButtonDisabled('canZoomOut')).toBe(true)
+ expect(availabilityEvents[0]).toMatchObject(toolbar?.operationAvailability.value || {})
+ expect(zoomEvents[0]).toMatchObject({ label: '100%' })
+
+ const zoomEventCount = zoomEvents.length
+ zoomState.minScale = 0.5
+ zoomState.maxScale = 5
+ await nextTick()
+
+ expect(zoomEvents).toHaveLength(zoomEventCount)
+
+ expect(toolbar?.zoomButtons.value).toEqual({ zoomIn: true, zoomOut: true, zoomReset: true })
+ zoomState.canZoomIn = false
+ zoomState.canZoomOut = true
+ zoomState.canReset = true
+ await nextTick()
+ expect(toolbar?.zoomButtons.value.zoomIn).toBe(true)
+ expect(toolbar?.zoomButtonDisabled('canZoomIn')).toBe(true)
+ expect(toolbar?.visibleToolbar.value.zoom).toBe(true)
+
+ error.value = 'render failed'
+ await nextTick()
+
+ expect(toolbar?.toolbarDisabled.value).toBe(true)
+ expect(toolbar?.zoomButtons.value).toEqual({ zoomIn: false, zoomOut: false, zoomReset: false })
+ expect(availabilityEvents.at(-1)).toMatchObject({
+ download: true,
+ print: false,
+ exportHtml: false,
+ zoom: false
+ })
+
+ scope.stop()
+ })
+})
diff --git a/test/web-toolbar-order.spec.ts b/test/web-toolbar-order.spec.ts
new file mode 100644
index 000000000..f11039c7f
--- /dev/null
+++ b/test/web-toolbar-order.spec.ts
@@ -0,0 +1,562 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { parseHTML } from 'linkedom';
+import {
+ createFileViewerZoomState,
+ createRendererRegistry,
+ registerFileViewerZoomProvider,
+ unregisterFileViewerZoomProvider,
+ type FileViewerZoomProvider,
+ type RendererDefinition,
+} from '../packages/core/src';
+import { mountViewer, type ViewerController } from '../packages/components/web/src/controller';
+
+type DomGlobals = Pick;
+
+const previousGlobals: Partial = {};
+
+const installDom = () => {
+ const { document, window } = parseHTML('');
+ previousGlobals.window = globalThis.window;
+ previousGlobals.document = globalThis.document;
+ previousGlobals.HTMLElement = globalThis.HTMLElement;
+
+ Object.assign(globalThis, {
+ window,
+ document,
+ HTMLElement: window.HTMLElement,
+ ShadowRoot: window.ShadowRoot,
+ });
+
+ return document.getElementById('viewer') as HTMLElement;
+};
+
+const restoreDom = () => {
+ for (const key of ['window', 'document', 'HTMLElement'] as const) {
+ if (previousGlobals[key]) {
+ Object.assign(globalThis, { [key]: previousGlobals[key] });
+ } else {
+ delete (globalThis as Record)[key];
+ }
+ }
+};
+
+const toolbarItemName = (element: Element) => {
+ if (element.classList.contains('file-viewer-web-theme-button')) {
+ return 'theme';
+ }
+ if (element.classList.contains('file-viewer-web-search')) {
+ return 'search';
+ }
+ if (element.classList.contains('file-viewer-web-toolbar-group')) {
+ return 'zoom';
+ }
+ if (element.classList.contains('file-viewer-web-print-menu')) {
+ return element.querySelector(':scope > button')?.textContent?.trim() || 'print';
+ }
+ return element.textContent?.trim();
+};
+
+describe('@file-viewer/web toolbar order', () => {
+ afterEach(() => {
+ restoreDom();
+ });
+
+ it('keeps an unchanged source mounted while runtime options update', async () => {
+ const container = installDom();
+ let loads = 0;
+ let unloads = 0;
+ const renderer: RendererDefinition = {
+ id: 'runtime-option-fixture',
+ label: 'Runtime option fixture',
+ category: 'document',
+ extensions: ['fixture'],
+ capabilities: {
+ download: false,
+ print: false,
+ exportHtml: false,
+ zoom: false,
+ },
+ load: async ({ surface }) => {
+ loads += 1;
+ surface.container.innerHTML = ``;
+ return {
+ destroy: () => {
+ unloads += 1;
+ },
+ };
+ },
+ };
+ const controller = mountViewer(container, {
+ buffer: new ArrayBuffer(1),
+ filename: 'first.fixture',
+ options: { toolbar: false },
+ }, { registry: createRendererRegistry([renderer]) });
+
+ await new Promise((resolve, reject) => {
+ const unsubscribe = controller.subscribe(state => {
+ if (state.error) {
+ unsubscribe();
+ reject(state.error);
+ } else if (state.ready) {
+ unsubscribe();
+ resolve();
+ }
+ });
+ });
+ expect(loads).toBe(1);
+
+ const renderRoot = container.shadowRoot || container;
+ const renderedFixture = () => renderRoot.querySelector('section')?.textContent;
+ await controller.update({
+ options: {
+ toolbar: false,
+ ui: { density: 'compact' },
+ },
+ });
+ expect(loads).toBe(1);
+ expect(unloads).toBe(0);
+ expect(renderedFixture()).toBe('load-1');
+
+ await controller.update({ buffer: new ArrayBuffer(1) });
+ expect(loads).toBe(2);
+ expect(unloads).toBe(1);
+ expect(renderedFixture()).toBe('load-2');
+ controller.destroy();
+ });
+
+ it('rerenders an unchanged source when renderer-owned options change', async () => {
+ const container = installDom();
+ const reviewModes: string[] = [];
+ let loads = 0;
+ let unloads = 0;
+ const renderer: RendererDefinition = {
+ id: 'render-option-fixture',
+ label: 'Render option fixture',
+ category: 'document',
+ extensions: ['fixture'],
+ capabilities: {
+ download: false,
+ print: false,
+ exportHtml: false,
+ zoom: false,
+ },
+ load: async ({ surface, options }) => {
+ loads += 1;
+ const reviewMode = options.docx?.reviewMode || 'all';
+ reviewModes.push(reviewMode);
+ surface.container.innerHTML = ``;
+ return {
+ destroy: () => {
+ unloads += 1;
+ },
+ };
+ },
+ };
+ const controller = mountViewer(container, {
+ buffer: new ArrayBuffer(1),
+ filename: 'revisions.fixture',
+ options: { docx: { reviewMode: 'all' } },
+ }, { registry: createRendererRegistry([renderer]) });
+
+ await new Promise((resolve, reject) => {
+ const unsubscribe = controller.subscribe(state => {
+ if (state.error) {
+ unsubscribe();
+ reject(state.error);
+ } else if (state.ready) {
+ unsubscribe();
+ resolve();
+ }
+ });
+ });
+ expect(loads).toBe(1);
+
+ // A parent may recreate an equivalent options object on every render.
+ await controller.update({ options: { docx: { reviewMode: 'all' } } });
+ expect(loads).toBe(1);
+ expect(unloads).toBe(0);
+
+ await controller.update({ options: { docx: { reviewMode: 'final' } } });
+ const renderRoot = container.shadowRoot || container;
+ expect(loads).toBe(2);
+ expect(unloads).toBe(1);
+ expect(reviewModes).toEqual(['all', 'final']);
+ expect(renderRoot.querySelector('section')?.textContent).toBe('final');
+ controller.destroy();
+ });
+
+ it('renders built-in toolbar groups in configured order', async () => {
+ const container = installDom();
+ let scale = 1;
+ const renderer: RendererDefinition = {
+ id: 'toolbar-fixture',
+ label: 'Toolbar fixture',
+ category: 'document',
+ extensions: ['fixture'],
+ capabilities: {
+ download: true,
+ print: true,
+ exportHtml: true,
+ zoom: 'provider',
+ search: true,
+ },
+ load: async ({ surface }) => {
+ surface.container.innerHTML = '';
+ const zoomHost = surface.container.querySelector('#zoom-host') as HTMLElement;
+ const getZoomState = () => createFileViewerZoomState({
+ scale,
+ canZoomIn: scale < 2,
+ canZoomOut: scale > 0.5,
+ canReset: scale !== 1,
+ });
+ const zoomProvider: FileViewerZoomProvider = {
+ zoomIn: () => {
+ scale = 1.25;
+ return getZoomState();
+ },
+ zoomOut: () => {
+ scale = 0.75;
+ return getZoomState();
+ },
+ resetZoom: () => {
+ scale = 1;
+ return getZoomState();
+ },
+ getState: getZoomState,
+ };
+ registerFileViewerZoomProvider(zoomHost, zoomProvider);
+ return {
+ destroy: () => {
+ unregisterFileViewerZoomProvider(zoomHost);
+ },
+ getAvailability: () => ({
+ zoomIn: true,
+ zoomOut: true,
+ zoomReset: true,
+ }),
+ };
+ },
+ };
+ const registry = createRendererRegistry([renderer]);
+
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error('Timed out waiting for toolbar fixture')), 1000);
+ mountViewer(container, {
+ buffer: new ArrayBuffer(1),
+ filename: 'demo.fixture',
+ options: {
+ messages: {
+ 'toolbar.download': 'download-action',
+ 'toolbar.print': 'print-action',
+ 'toolbar.exportHtml': 'export-action',
+ },
+ toolbar: {
+ order: ['zoom', 'search', 'download', 'print', 'exportHtml', 'theme'],
+ position: 'top-center',
+ },
+ },
+ onStateChange(state) {
+ if (state.error) {
+ clearTimeout(timeout);
+ reject(state.error);
+ } else if (state.ready) {
+ clearTimeout(timeout);
+ resolve();
+ }
+ },
+ }, { registry });
+ });
+
+ expect(container.shadowRoot).toBeTruthy();
+ expect(container.querySelector('.file-viewer-web-toolbar')).toBeNull();
+ const renderRoot = container.shadowRoot || container;
+ const toolbar = renderRoot.querySelector('.file-viewer-web-toolbar');
+ expect(toolbar?.getAttribute('data-toolbar-position')).toBe('top-center');
+ expect(Array.from(toolbar?.children || []).map(toolbarItemName)).toEqual([
+ 'zoom',
+ 'search',
+ 'download-action',
+ 'print-action',
+ 'export-action',
+ 'theme',
+ ]);
+ expect(renderRoot.querySelector('style')?.textContent).toContain(
+ '.file-viewer-web-toolbar[data-toolbar-position="top-center"]{justify-content:center}'
+ );
+ });
+
+ it('toggles light and dark mode from a configurable toolbar button (GitHub #97)', async () => {
+ const container = installDom();
+ const events: string[] = [];
+ const colorSchemeListeners = new Set<(event: MediaQueryListEvent) => void>();
+ let systemDark = false;
+ Object.defineProperty(globalThis.window, 'matchMedia', {
+ configurable: true,
+ value: () => ({
+ get matches() {
+ return systemDark;
+ },
+ media: '(prefers-color-scheme: dark)',
+ addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => {
+ colorSchemeListeners.add(listener);
+ },
+ removeEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => {
+ colorSchemeListeners.delete(listener);
+ },
+ }),
+ });
+ const setSystemDark = (matches: boolean) => {
+ systemDark = matches;
+ colorSchemeListeners.forEach(listener => listener({ matches } as MediaQueryListEvent));
+ };
+ const controller = mountViewer(container, {
+ options: {
+ locale: 'en-US',
+ theme: 'system',
+ toolbar: {
+ download: false,
+ print: false,
+ exportHtml: false,
+ zoom: false,
+ search: false,
+ theme: true,
+ order: ['theme'],
+ },
+ },
+ onEvent(event) {
+ if (event.type === 'theme-change') {
+ events.push(event.payload);
+ }
+ },
+ });
+
+ const renderRoot = container.shadowRoot || container;
+ const shell = renderRoot.querySelector('.file-viewer-web-shell') as HTMLElement;
+ const toolbar = renderRoot.querySelector('.file-viewer-web-toolbar') as HTMLElement;
+ const readThemeButton = () => renderRoot.querySelector('.file-viewer-web-theme-button') as HTMLButtonElement | null;
+
+ expect(toolbar.hidden).toBe(false);
+ expect(shell.dataset.viewerTheme).toBe('system');
+ expect(readThemeButton()?.getAttribute('aria-label')).toBe('Switch to dark mode');
+ expect(readThemeButton()?.getAttribute('aria-pressed')).toBe('false');
+
+ setSystemDark(true);
+ expect(readThemeButton()?.getAttribute('aria-label')).toBe('Switch to light mode');
+ expect(readThemeButton()?.getAttribute('aria-pressed')).toBe('true');
+
+ setSystemDark(false);
+ readThemeButton()?.click();
+ await Promise.resolve();
+
+ expect(shell.dataset.viewerTheme).toBe('dark');
+ expect(events).toEqual(['dark']);
+ expect(readThemeButton()?.getAttribute('aria-label')).toBe('Switch to light mode');
+ expect(readThemeButton()?.getAttribute('aria-pressed')).toBe('true');
+
+ await controller.update({
+ options: {
+ theme: 'dark',
+ toolbar: false,
+ },
+ });
+ expect(toolbar.hidden).toBe(true);
+
+ await controller.update({
+ options: {
+ theme: 'light',
+ toolbar: {
+ download: false,
+ print: false,
+ exportHtml: false,
+ zoom: false,
+ search: false,
+ theme: true,
+ position: 'auto',
+ },
+ },
+ });
+ expect(toolbar.hidden).toBe(false);
+ expect(readThemeButton()).toBeTruthy();
+ });
+
+ it('exposes a compact print dropdown with direct and mask options (GitHub #92)', async () => {
+ const container = installDom();
+ const renderer: RendererDefinition = {
+ id: 'print-menu-fixture',
+ label: 'Print menu fixture',
+ category: 'document',
+ extensions: ['fixture'],
+ capabilities: {
+ download: false,
+ print: true,
+ exportHtml: false,
+ zoom: false,
+ search: false,
+ },
+ load: async ({ surface }) => {
+ surface.container.innerHTML = '';
+ return {
+ destroy: async () => undefined,
+ getAvailability: () => ({
+ zoomIn: false,
+ zoomOut: false,
+ zoomReset: false,
+ }),
+ };
+ },
+ };
+ const registry = createRendererRegistry([renderer]);
+
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error('Timed out waiting for print menu fixture')), 1000);
+ mountViewer(container, {
+ buffer: new ArrayBuffer(1),
+ filename: 'demo.fixture',
+ options: {
+ messages: {
+ 'toolbar.print': 'print-action',
+ 'toolbar.printDirect': 'print-direct',
+ 'toolbar.printMask': 'print-mask',
+ },
+ toolbar: {
+ order: ['print'],
+ position: 'top-right',
+ },
+ },
+ onStateChange(state) {
+ if (state.error) {
+ clearTimeout(timeout);
+ reject(state.error);
+ } else if (state.ready) {
+ clearTimeout(timeout);
+ resolve();
+ }
+ },
+ }, { registry });
+ });
+
+ const renderRoot = container.shadowRoot || container;
+ const menu = renderRoot.querySelector('.file-viewer-web-print-menu');
+ expect(menu).toBeTruthy();
+ expect(menu?.dataset.open).not.toBe('true');
+ const labels = Array.from(menu?.querySelectorAll('.file-viewer-web-print-menu-panel button') || [])
+ .map(button => button.textContent?.trim());
+ expect(labels).toEqual(['print-direct', 'print-mask']);
+ const trigger = menu?.querySelector(':scope > button') as HTMLButtonElement | null;
+ expect(trigger?.textContent?.trim()).toBe('print-action');
+ trigger?.click();
+ expect(menu?.dataset.open).toBe('true');
+ });
+
+ it('keeps zoom controls stable at limits and honors reset visibility without blocking APIs (GitHub #398, #88)', async () => {
+ const container = installDom();
+ let scale = 1;
+ const renderer: RendererDefinition = {
+ id: 'zoom-reset-fixture',
+ label: 'Zoom reset fixture',
+ category: 'document',
+ extensions: ['fixture'],
+ capabilities: {
+ download: false,
+ print: false,
+ exportHtml: false,
+ zoom: 'provider',
+ },
+ load: async ({ surface }) => {
+ surface.container.innerHTML = '';
+ const zoomHost = surface.container.querySelector('#zoom-host') as HTMLElement;
+ const getZoomState = () => createFileViewerZoomState({
+ scale,
+ canZoomIn: scale < 2,
+ canZoomOut: scale > 0.5,
+ canReset: scale !== 1,
+ });
+ const zoomProvider: FileViewerZoomProvider = {
+ zoomIn: () => {
+ scale = Math.min(2, Math.round((scale + 0.1) * 10) / 10);
+ return getZoomState();
+ },
+ zoomOut: () => {
+ scale = Math.max(0.5, Math.round((scale - 0.1) * 10) / 10);
+ return getZoomState();
+ },
+ resetZoom: () => {
+ scale = 1;
+ return getZoomState();
+ },
+ getState: getZoomState,
+ };
+ registerFileViewerZoomProvider(zoomHost, zoomProvider);
+ return {
+ destroy: () => {
+ unregisterFileViewerZoomProvider(zoomHost);
+ },
+ getAvailability: () => ({
+ zoomIn: true,
+ zoomOut: true,
+ zoomReset: true,
+ }),
+ };
+ },
+ };
+ const registry = createRendererRegistry([renderer]);
+
+ let controller: ViewerController | undefined;
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error('Timed out waiting for zoom fixture')), 1000);
+ controller = mountViewer(container, {
+ buffer: new ArrayBuffer(1),
+ filename: 'demo.fixture',
+ onStateChange(state) {
+ if (state.error) {
+ clearTimeout(timeout);
+ reject(state.error);
+ } else if (state.ready) {
+ clearTimeout(timeout);
+ resolve();
+ }
+ },
+ }, { registry });
+ });
+
+ const renderRoot = container.shadowRoot || container;
+ const readZoomGroupLabels = () => {
+ const group = renderRoot.querySelector('.file-viewer-web-toolbar-group');
+ return Array.from(group?.children || []).map(child => child.textContent?.trim());
+ };
+
+ // Keep the reset button's slot even when it is disabled at the initial scale.
+ expect(readZoomGroupLabels()).toEqual(['-', '100%', '1:1', '+']);
+ const readButtons = () => Array.from(renderRoot.querySelectorAll('.file-viewer-web-toolbar-group button')) as HTMLButtonElement[];
+ expect(readButtons()[2].disabled).toBe(true);
+
+ await controller?.zoomIn();
+
+ // Retain the #88 trailing "+" order; default reset space is now reserved
+ // from the first paint rather than inserted after the first click.
+ const labels = readZoomGroupLabels();
+ expect(labels).toEqual(['-', '110%', '1:1', '+']);
+ expect(labels[labels.length - 1]).toBe('+');
+
+ for (let click = 0; click < 24; click += 1) await controller?.zoomIn();
+ expect(scale).toBe(2);
+ expect(readZoomGroupLabels()).toEqual(['-', '200%', '1:1', '+']);
+ expect(readButtons().at(-1)?.disabled).toBe(true);
+ for (let click = 0; click < 36; click += 1) await controller?.zoomOut();
+ expect(scale).toBe(0.5);
+ expect(readZoomGroupLabels()).toEqual(['-', '50%', '1:1', '+']);
+ expect(readButtons()[0].disabled).toBe(true);
+
+ await controller?.update({ options: { toolbar: { items: { 'zoom-reset': false } } } });
+ expect(readZoomGroupLabels()).toEqual(['-', '50%', '+']);
+ await controller?.resetZoom();
+ expect(scale).toBe(1);
+ expect(readZoomGroupLabels()).toEqual(['-', '100%', '+']);
+
+ await controller?.update({ options: { toolbar: { permissions: { 'zoom-in': false } } } });
+ expect(readZoomGroupLabels()).toEqual(['-', '100%', '1:1']);
+ await controller?.zoomIn();
+ expect(scale).toBe(1);
+ controller?.destroy();
+ });
+});