| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652 |
- import { beforeEach, describe, expect, it } from 'vitest';
- import { parseHTML } from 'linkedom';
- import {
- DEFAULT_RENDERER_DEFINITIONS,
- DEFAULT_SUPPORTED_EXTENSIONS,
- clearFileViewerAutoRendererPresets,
- createFileViewerRendererDispatcher,
- createFileViewerUnsupportedState,
- createFileViewerZoomState,
- createRendererRegistry,
- createViewer,
- getExtension,
- normalizeSource,
- readFileViewerBuffer,
- registerFileViewerAutoRendererPreset,
- registerFileViewerZoomProvider,
- resolveFileViewerRendererInstallHint,
- unregisterFileViewerZoomProvider,
- type FileViewerZoomProvider,
- type RendererDefinition,
- wrapFileViewerFileRef,
- } from '../packages/core/src';
- import { allRenderers } from '../packages/presets/all/src';
- const setRect = (element: Element, rect: Partial<DOMRect>) => {
- Object.defineProperty(element, 'getBoundingClientRect', {
- configurable: true,
- value: () => ({
- bottom: rect.bottom ?? (rect.top || 0) + (rect.height || 0),
- height: rect.height ?? 0,
- left: rect.left ?? 0,
- right: rect.right ?? (rect.left || 0) + (rect.width || 0),
- top: rect.top ?? 0,
- width: rect.width ?? 0,
- x: rect.x ?? rect.left ?? 0,
- y: rect.y ?? rect.top ?? 0,
- toJSON: () => ({}),
- } as DOMRect),
- });
- };
- describe('@file-viewer/core registry', () => {
- beforeEach(() => {
- clearFileViewerAutoRendererPresets();
- });
- it('keeps the current public format matrix at 206 extensions', () => {
- expect(DEFAULT_SUPPORTED_EXTENSIONS).toHaveLength(206);
- expect(DEFAULT_SUPPORTED_EXTENSIONS).toContain('pdf');
- expect(DEFAULT_SUPPORTED_EXTENSIONS).toContain('docx');
- expect(DEFAULT_SUPPORTED_EXTENSIONS).toContain('dwf');
- expect(DEFAULT_SUPPORTED_EXTENSIONS).toContain('zip');
- expect(DEFAULT_SUPPORTED_EXTENSIONS).toContain('typst');
- });
- it('distinguishes missing renderer assembly from truly unsupported formats', () => {
- const pdfHint = resolveFileViewerRendererInstallHint('pdf');
- expect(pdfHint).toMatchObject({
- rendererId: 'pdf',
- rendererPackage: '@file-viewer/renderer-pdf',
- presetPackage: '@file-viewer/preset-office',
- vitePreset: 'office',
- });
- const missingPdf = createFileViewerUnsupportedState('pdf');
- expect(missingPdf.title).toBe('需要装配预览能力');
- expect(missingPdf.message).toContain('已在支持矩阵中');
- expect(missingPdf.description).toContain('@file-viewer/preset-office');
- expect(missingPdf.description).toContain('@file-viewer/renderer-pdf');
- const unsupported = createFileViewerUnsupportedState('unknown-format');
- expect(resolveFileViewerRendererInstallHint('unknown-format')).toBeNull();
- expect(unsupported.title).toBe('暂不支持在线预览');
- expect(unsupported.message).toContain('不支持.unknown-format格式');
- });
- it('normalizes file names, query strings and explicit type overrides', () => {
- expect(getExtension('/docs/report.PDF?token=1')).toBe('pdf');
- expect(normalizeSource({ url: '/example/PDF%20%E6%B2%89%E6%B5%B8.pdf?token=1' })).toMatchObject({
- kind: 'url',
- filename: 'PDF 沉浸.pdf',
- extension: 'pdf',
- });
- expect(normalizeSource({ url: 'https://example.com/a/b/合同.docx?download=1' })).toMatchObject({
- kind: 'url',
- filename: '合同.docx',
- extension: 'docx',
- });
- expect(normalizeSource({ buffer: new ArrayBuffer(8), filename: 'raw.bin', type: '.ofd' })).toMatchObject({
- kind: 'buffer',
- filename: 'raw.bin',
- extension: 'ofd',
- size: 8,
- });
- });
- it('wraps browser file inputs and reads ArrayBuffer data in core', async () => {
- const file = wrapFileViewerFileRef(new Blob(['hello'], { type: 'text/plain' }), '/tmp/%E6%96%87%E6%A1%A3.txt?download=1');
- expect(file.name).toBe('文档.txt');
- expect(file.type).toBe('text/plain');
- expect(await readFileViewerBuffer(file)).toEqual(new TextEncoder().encode('hello').buffer);
- const rawFile = wrapFileViewerFileRef(new Uint8Array([1, 2, 3]).buffer, 'raw.bin');
- expect(rawFile.name).toBe('raw.bin');
- expect(await readFileViewerBuffer(rawFile)).toEqual(new Uint8Array([1, 2, 3]).buffer);
- });
- it('resolves renderers by extension and rejects duplicate extension ownership', () => {
- const registry = createRendererRegistry();
- expect(registry.getByExtension('.PDF')?.id).toBe('pdf');
- expect(registry.getByExtension('dwfx')?.id).toBe('cad');
- expect(registry.getByExtension('zip')?.id).toBe('archive');
- expect(() => {
- registry.register({
- id: 'duplicate-pdf',
- label: 'Duplicate PDF',
- category: 'document',
- extensions: ['pdf'],
- });
- }).toThrow(/already registered/);
- });
- it('builds framework-neutral renderer dispatchers from renderer ids', () => {
- const registry = createRendererRegistry([
- {
- id: 'alpha',
- label: 'Alpha',
- category: 'document',
- extensions: ['Foo', '.bar'],
- },
- {
- id: 'missing',
- label: 'Missing',
- category: 'document',
- extensions: ['miss'],
- },
- ]);
- const alphaHandler = () => 'alpha';
- const fallbackHandler = () => 'fallback';
- const dispatcher = createFileViewerRendererDispatcher({
- registry,
- handlers: [
- {
- rendererId: 'alpha',
- handler: alphaHandler,
- },
- ],
- fallbackHandler,
- });
- expect(dispatcher.missingRendererIds).toEqual(['missing']);
- expect(dispatcher.get('foo')).toBe(alphaHandler);
- expect(dispatcher.get('.BAR')).toBe(alphaHandler);
- expect(dispatcher.resolve('miss')).toBe(fallbackHandler);
- expect(dispatcher.has('error')).toBe(true);
- expect(dispatcher.listExtensions()).toEqual(['bar', 'error', 'foo']);
- });
- it('runs a minimal framework-neutral load and unload lifecycle', async () => {
- const events: string[] = [];
- const renderer: RendererDefinition = {
- id: 'fixture',
- label: 'Fixture',
- category: 'document',
- extensions: ['fixture'],
- capabilities: { download: true, print: true, exportHtml: true, zoom: true, search: true },
- load: async ({ surface, source }) => {
- surface.container.dataset.loaded = source.filename;
- return {
- destroy: () => {
- surface.container.dataset.destroyed = 'true';
- },
- getAvailability: () => ({
- zoomIn: true,
- zoomOut: true,
- zoomReset: true,
- }),
- };
- },
- };
- const registry = createRendererRegistry([
- ...DEFAULT_RENDERER_DEFINITIONS,
- renderer,
- ]);
- const { document } = parseHTML('<main id="viewer"></main>');
- const container = document.getElementById('viewer') as HTMLElement;
- const viewer = createViewer(container, {
- registry,
- options: {
- hooks: {
- onLoadStart: context => events.push(`${context.phase}:${context.version}:${context.type}`),
- onLoadComplete: context => events.push(`${context.phase}:${context.version}:${context.filename}`),
- onUnloadStart: context => events.push(`${context.phase}:${context.version}:${context.reason}`),
- onUnloadComplete: context => events.push(`${context.phase}:${context.version}:${context.reason}`),
- },
- },
- });
- await viewer.load({ buffer: new ArrayBuffer(1), filename: 'demo.fixture' });
- expect(container.dataset.loaded).toBe('demo.fixture');
- expect(viewer.getCapabilities()).toMatchObject({
- download: true,
- print: true,
- exportHtml: true,
- zoom: true,
- zoomIn: true,
- zoomOut: true,
- zoomReset: true,
- });
- await viewer.destroy();
- expect(container.dataset.destroyed).toBe('true');
- expect(events).toEqual([
- 'load-start:1:fixture',
- 'load-complete:1:demo.fixture',
- 'unload-start:1:component-unmount',
- 'unload-complete:1:component-unmount',
- ]);
- });
- it('installs renderer plugins in replace mode for lightweight viewers', async () => {
- const { document } = parseHTML('<main id="viewer"></main>');
- const container = document.getElementById('viewer') as HTMLElement;
- const viewer = createViewer(container, {
- options: {
- rendererMode: 'replace',
- renderers: {
- id: 'fixture-plugin',
- definitions: [{
- id: 'fixture-plugin-renderer',
- label: 'Fixture Plugin',
- category: 'document',
- extensions: ['lite'],
- capabilities: { download: true, print: false, exportHtml: false, zoom: false, search: false },
- }],
- handlers: [{
- rendererId: 'fixture-plugin-renderer',
- handler: async (_buffer, target, type) => {
- target.dataset.pluginRenderer = type || '';
- return {
- unmount() {
- target.dataset.pluginDestroyed = 'true';
- },
- };
- },
- }],
- },
- },
- });
- await viewer.load({ buffer: new ArrayBuffer(1), filename: 'demo.lite' });
- expect(container.dataset.pluginRenderer).toBe('lite');
- expect(viewer.getRenderer('lite')?.id).toBe('fixture-plugin-renderer');
- expect(viewer.getRenderer('pdf')).toBeUndefined();
- await viewer.destroy();
- expect(container.dataset.pluginDestroyed).toBe('true');
- });
- it('supports lite built-in renderers without registering heavy document handlers', async () => {
- const { document } = parseHTML('<main id="viewer"></main>');
- const container = document.getElementById('viewer') as HTMLElement;
- const viewer = createViewer(container, {
- options: {
- builtinRenderers: 'lite',
- },
- });
- await viewer.load({ buffer: new ArrayBuffer(1), filename: 'demo.pdf' });
- expect(viewer.getRenderer('pdf')).toBeUndefined();
- expect(viewer.getRenderer('png')?.load).toBeTypeOf('function');
- const missingRendererText = container.querySelector('.file-viewer-missing-renderer')?.textContent || '';
- expect(missingRendererText).toContain('.pdf 格式已在支持矩阵中');
- expect(missingRendererText).toContain('@file-viewer/preset-office');
- });
- it('extends the bundled renderer matrix with renderer presets by default', async () => {
- const { document } = parseHTML('<main id="viewer"></main>');
- const container = document.getElementById('viewer') as HTMLElement;
- const viewer = createViewer(container, {
- options: {
- renderers: [{
- id: 'fixture-preset',
- label: 'Fixture preset',
- renderers: [{
- id: 'fixture-extend-plugin',
- definitions: [{
- id: 'fixture-extend-renderer',
- label: 'Fixture Extend',
- category: 'document',
- extensions: ['plus'],
- capabilities: { download: true, print: false, exportHtml: false, zoom: false, search: false },
- }],
- handlers: [{
- rendererId: 'fixture-extend-renderer',
- handler: async (_buffer, target, type) => {
- target.dataset.extendedRenderer = type || '';
- return { unmount() {} };
- },
- }],
- }],
- }],
- },
- });
- await viewer.load({ buffer: new ArrayBuffer(1), filename: 'demo.plus' });
- expect(container.dataset.extendedRenderer).toBe('plus');
- expect(viewer.getRenderer('plus')?.id).toBe('fixture-extend-renderer');
- expect(viewer.getRenderer('pdf')?.id).toBe('pdf');
- expect(viewer.getRenderer('pdf')?.load).toBeUndefined();
- });
- it('consumes auto-registered renderer presets while preserving strict manual control', async () => {
- registerFileViewerAutoRendererPreset({
- id: 'fixture-auto-preset',
- label: 'Fixture auto preset',
- renderers: [{
- id: 'fixture-auto-plugin',
- definitions: [{
- id: 'fixture-auto-renderer',
- label: 'Fixture Auto',
- category: 'document',
- extensions: ['auto'],
- capabilities: { download: true, print: false, exportHtml: false, zoom: false, search: false },
- }],
- handlers: [{
- rendererId: 'fixture-auto-renderer',
- handler: async (_buffer, target, type) => {
- target.dataset.autoRenderer = type || '';
- return { unmount() {} };
- },
- }],
- }],
- }, {
- id: 'fixture-auto',
- packageName: '@file-viewer/preset-fixture',
- });
- const { document } = parseHTML('<main id="auto"></main><main id="strict"></main>');
- const autoContainer = document.getElementById('auto') as HTMLElement;
- const autoViewer = createViewer(autoContainer, {
- options: {
- builtinRenderers: 'none',
- },
- });
- await autoViewer.load({ buffer: new ArrayBuffer(1), filename: 'demo.auto' });
- expect(autoContainer.dataset.autoRenderer).toBe('auto');
- expect(autoViewer.getRenderer('auto')?.id).toBe('fixture-auto-renderer');
- const strictContainer = document.getElementById('strict') as HTMLElement;
- const strictViewer = createViewer(strictContainer, {
- options: {
- builtinRenderers: 'none',
- autoRenderers: false,
- },
- });
- await strictViewer.load({ buffer: new ArrayBuffer(1), filename: 'demo.auto' });
- expect(strictViewer.getRenderer('auto')).toBeUndefined();
- expect(strictContainer.querySelector('.file-viewer-missing-renderer')?.textContent).toContain('不支持.auto格式');
- });
- it('can restore the full format matrix through @file-viewer/preset-all in replace mode', async () => {
- const { document } = parseHTML('<main id="viewer"></main>');
- const container = document.getElementById('viewer') as HTMLElement;
- const viewer = createViewer(container, {
- options: {
- rendererMode: 'replace',
- renderers: allRenderers,
- },
- });
- await viewer.load({ filename: 'empty' });
- expect(viewer.getRenderer('pdf')?.load).toBeTypeOf('function');
- expect(viewer.getRenderer('docx')?.load).toBeTypeOf('function');
- expect(viewer.getRenderer('dwg')?.load).toBeTypeOf('function');
- expect(viewer.getRenderer('zip')?.load).toBeTypeOf('function');
- expect(viewer.getRenderer('typst')?.load).toBeTypeOf('function');
- });
- it('exposes framework-neutral viewer interaction APIs', async () => {
- const { document } = parseHTML('<main id="viewer"></main>');
- const container = document.getElementById('viewer') as HTMLElement;
- const beforeOperations: string[] = [];
- let scale = 1;
- let scrolledToLine = 0;
- Object.defineProperty(container, 'clientHeight', { configurable: true, value: 120 });
- Object.defineProperty(container, 'scrollHeight', { configurable: true, value: 520 });
- Object.defineProperty(container, 'clientWidth', { configurable: true, value: 320 });
- Object.defineProperty(container, 'scrollWidth', { configurable: true, value: 320 });
- Object.defineProperty(container, 'scrollTop', { configurable: true, writable: true, value: 0 });
- Object.defineProperty(container, 'scrollLeft', { configurable: true, writable: true, value: 0 });
- container.scrollTo = (options?: ScrollToOptions | number, y?: number) => {
- if (typeof options === 'number') {
- container.scrollLeft = options;
- container.scrollTop = y || 0;
- return;
- }
- container.scrollTop = Number(options?.top || 0);
- container.scrollLeft = Number(options?.left || 0);
- };
- setRect(container, { top: 0, left: 0, width: 320, height: 120 });
- const renderer: RendererDefinition = {
- id: 'core-api-fixture',
- label: 'Core API Fixture',
- category: 'document',
- extensions: ['coreapi'],
- capabilities: { download: true, zoom: 'provider', search: true },
- load: async ({ surface }) => {
- surface.container.innerHTML = `
- <section id="zoom-host" data-viewer-zoom-provider="fixture">
- <article>
- <h1>Alpha PDF heading</h1>
- <p>Beta pdf line with enough content for text chunks.</p>
- </article>
- </section>
- `;
- const zoomHost = surface.container.querySelector('#zoom-host') as HTMLElement;
- const heading = surface.container.querySelector('h1') as HTMLElement;
- const paragraph = surface.container.querySelector('p') as HTMLElement;
- const getState = () => createFileViewerZoomState({
- scale,
- canZoomIn: scale < 2,
- canZoomOut: scale > 0.5,
- canReset: scale !== 1,
- });
- const zoomProvider: FileViewerZoomProvider = {
- zoomIn: () => {
- scale = 1.25;
- return getState();
- },
- zoomOut: () => {
- scale = 0.75;
- return getState();
- },
- resetZoom: () => {
- scale = 1;
- return getState();
- },
- getState,
- };
- registerFileViewerZoomProvider(zoomHost, zoomProvider);
- setRect(zoomHost, { top: 0, left: 0, width: 320, height: 160 });
- setRect(heading, { top: 12, left: 16, width: 260, height: 32 });
- setRect(paragraph, { top: 68, left: 16, width: 280, height: 24 });
- heading.scrollIntoView = () => {
- scrolledToLine = 1;
- };
- paragraph.scrollIntoView = () => {
- scrolledToLine = 2;
- };
- return {
- destroy: () => {
- unregisterFileViewerZoomProvider(zoomHost);
- },
- getAvailability: () => ({
- zoomIn: true,
- zoomOut: true,
- zoomReset: true,
- }),
- };
- },
- };
- const registry = createRendererRegistry([
- ...DEFAULT_RENDERER_DEFINITIONS,
- renderer,
- ]);
- const viewer = createViewer(container, {
- registry,
- options: {
- search: { maxMatches: 10 },
- ai: { chunkSize: 200 },
- beforeOperation: context => {
- beforeOperations.push(context.operation);
- return context.operation !== 'zoom-out';
- },
- },
- });
- await viewer.load({ buffer: new ArrayBuffer(4), filename: 'demo.coreapi' });
- await expect(viewer.zoomIn()).resolves.toMatchObject({ scale: 1.25, label: '125%' });
- await expect(viewer.zoomOut()).resolves.toMatchObject({ scale: 1.25 });
- expect(beforeOperations).toEqual(['zoom-in', 'zoom-out']);
- expect(viewer.getZoomState()).toMatchObject({ scale: 1.25, canReset: true });
- await expect(viewer.search('pdf')).resolves.toMatchObject({ query: 'pdf', total: 2, currentIndex: 0 });
- await expect(viewer.nextSearchResult()).resolves.toMatchObject({ currentIndex: 1 });
- expect(viewer.getSearchState().matches.map(match => match.text)).toEqual(['PDF', 'pdf']);
- const anchors = await viewer.collectDocumentAnchors();
- expect(anchors.map(anchor => anchor.label)).toEqual([
- 'Alpha PDF heading',
- 'Beta pdf line with enough content for text chunks.',
- ]);
- expect(viewer.getCurrentDocumentAnchor()?.line).toBe(1);
- await expect(viewer.scrollToLine(2)).resolves.toBe(true);
- expect(scrolledToLine).toBe(2);
- expect(viewer.getDocumentTextChunks()).toHaveLength(2);
- await viewer.destroy();
- });
- it('exposes framework-neutral viewer file operations', async () => {
- const { document } = parseHTML('<html><body><main id="viewer"></main></body></html>');
- const container = document.getElementById('viewer') as HTMLElement;
- const beforeOperations: string[] = [];
- let downloadedName = '';
- let revokedUrl = '';
- let printed = false;
- let printHtml = '';
- const originalDocument = globalThis.document;
- const originalWindow = globalThis.window;
- const originalCreateObjectUrl = URL.createObjectURL;
- const originalRevokeObjectUrl = URL.revokeObjectURL;
- const originalCreateElement = document.createElement.bind(document);
- 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: {
- setTimeout: (callback: () => void) => {
- callback();
- return 1;
- },
- },
- });
- URL.createObjectURL = () => 'blob:viewer-test';
- URL.revokeObjectURL = url => {
- revokedUrl = url;
- };
- try {
- const renderer: RendererDefinition = {
- id: 'core-file-operation-fixture',
- label: 'Core File Operation Fixture',
- category: 'document',
- extensions: ['coreops'],
- capabilities: { download: true, print: 'adapter', exportHtml: 'adapter' },
- load: async ({ surface, registerExportAdapter }) => {
- surface.container.innerHTML = '<article><h1>Rendered preview</h1></article>';
- registerExportAdapter?.({
- exportHtml: true,
- print: true,
- includeDocumentStyles: false,
- printStyle: '@page { size: A4; }',
- toHtml: options => `<section data-mode="${options.mode}">Adapter ${options.title}</section>`,
- });
- return {};
- },
- };
- const viewer = createViewer(container, {
- registry: createRendererRegistry([
- ...DEFAULT_RENDERER_DEFINITIONS,
- renderer,
- ]),
- options: {
- watermark: { text: 'Confidential' },
- beforeOperation: context => {
- beforeOperations.push(context.operation);
- },
- },
- });
- await viewer.load({ buffer: new Uint8Array([1, 2, 3]).buffer, filename: 'demo.coreops' });
- expect(viewer.getExportAdapter()).toMatchObject({
- exportHtml: true,
- print: true,
- });
- const html = await viewer.exportHtml({ download: false });
- expect(html).toContain('data-mode="export"');
- expect(html).toContain('Adapter demo.coreops');
- expect(html).toContain('viewer-export-watermark');
- expect(html).toContain('Confidential');
- const printWindow = {
- document: {
- readyState: 'complete',
- images: [],
- open: () => undefined,
- write: (nextHtml: string) => {
- printHtml = nextHtml;
- },
- close: () => undefined,
- },
- focus: () => undefined,
- print: () => {
- printed = true;
- },
- requestAnimationFrame: (callback: () => void) => {
- callback();
- return 1;
- },
- setTimeout: (callback: () => void) => {
- callback();
- return 1;
- },
- } as unknown as Window;
- await viewer.print({ printWindow });
- expect(printed).toBe(true);
- expect(printHtml).toContain('data-mode="print"');
- expect(printHtml).toContain('@page { size: A4; }');
- await viewer.download();
- expect(downloadedName).toBe('demo.coreops');
- expect(revokedUrl).toBe('blob:viewer-test');
- expect(beforeOperations).toEqual(['export-html', 'print', 'download']);
- await viewer.destroy();
- expect(viewer.getExportAdapter()).toBeNull();
- } 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 });
- }
- });
- });
|