@file-viewer/thumbnail 2.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ Apache License 2.0
2
+
3
+ Copyright 2026 Flyfish File Viewer contributors
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
package/README.en.md ADDED
@@ -0,0 +1,36 @@
1
+ # @file-viewer/thumbnail
2
+
3
+ Browser thumbnail generation for Flyfish File Viewer. It reuses `createViewer()`, installed renderers, optional native thumbnail adapters, a bounded viewer pool, ordered batch results, and completion-order streaming.
4
+
5
+ ```bash
6
+ pnpm add @file-viewer/thumbnail @file-viewer/preset-all
7
+ ```
8
+
9
+ ```ts
10
+ import { createFileViewerThumbnailGenerator } from '@file-viewer/thumbnail'
11
+ import allPreset from '@file-viewer/preset-all'
12
+
13
+ const generator = createFileViewerThumbnailGenerator({
14
+ // Uses the same preset/renderers configuration as createViewer().
15
+ viewerOptions: { preset: allPreset },
16
+ concurrency: 2,
17
+ })
18
+
19
+ const result = await generator.generate({ file, filename: file.name })
20
+ for await (const item of generator.generateStream(files)) {
21
+ if (item.ok) await upload(item.result.blob)
22
+ }
23
+ await generator.destroy()
24
+ ```
25
+
26
+ Each item can override `width`, `height`, `format: 'webp' | 'jpeg' | 'png'`, `quality`, `fit`, `background`, `timeoutMs`, and `signal`. `generate()` returns the Blob, actual MIME type, renderer ID, duration, `embedded | provider-native | provider-dom | dom-fallback` strategy, and `degraded` state. Batch results remain input-ordered and isolate failures; stream results are completion-ordered, carry the original index, and keep pending Blob results bounded by the concurrency window.
27
+
28
+ Failures use `FileViewerThumbnailError` with stable codes: `browser-required`, `unsupported`, `timeout`, `aborted`, `capture-failed`, `capture-unavailable`, `tainted-canvas`, `empty-output`, `destroyed`, and `invalid-options`.
29
+
30
+ - Requires a real browser. Server applications must invoke it inside Chromium.
31
+ - The string form `preset: 'all'` only works after a Vite plugin or preset side-effect import has registered it. Pass the imported preset object, as above, for bundler-neutral setup.
32
+ - Embedded previews take priority over rendering. EPUB uses its declared cover; OOXML and 3MF use package thumbnail relationships; OpenDocument and XMind use their packaged thumbnails; and Apple Numbers uses its Quick Look thumbnail when present.
33
+ - Missing, corrupt, empty, or undecodable embedded images safely fall back to renderer-native or first-content capture.
34
+ - PDF and images use native capture; Office, OFD, and ebooks expose a first-content target when no embedded preview is available.
35
+ - Other renderers use a DOM fallback and return `degraded: true`.
36
+ - Source files and generated thumbnails are never persisted by this package.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @file-viewer/thumbnail
2
+
3
+ Flyfish File Viewer 的浏览器缩略图生成器。它复用 `createViewer()`、已安装的 renderer 与可选的原生缩略图 adapter,支持固定并发、批量有序结果和低内存流式输出。
4
+
5
+ ```bash
6
+ pnpm add @file-viewer/thumbnail @file-viewer/preset-all
7
+ ```
8
+
9
+ ```ts
10
+ import { createFileViewerThumbnailGenerator } from '@file-viewer/thumbnail'
11
+ import allPreset from '@file-viewer/preset-all'
12
+
13
+ const generator = createFileViewerThumbnailGenerator({
14
+ // 与 createViewer() 使用相同的 preset/renderers 配置。
15
+ viewerOptions: { preset: allPreset },
16
+ concurrency: 2,
17
+ timeoutMs: 30_000,
18
+ })
19
+
20
+ const thumbnail = await generator.generate({
21
+ file,
22
+ filename: file.name,
23
+ }, {
24
+ width: 320,
25
+ height: 240,
26
+ format: 'webp',
27
+ quality: 0.8,
28
+ })
29
+
30
+ for await (const item of generator.generateStream(files)) {
31
+ if (item.ok) await upload(item.result.blob)
32
+ }
33
+
34
+ await generator.destroy()
35
+ ```
36
+
37
+ 单项可覆盖 `width`、`height`、`format: 'webp' | 'jpeg' | 'png'`、`quality`、`fit`、`background`、`timeoutMs` 和 `signal`。`generate()` 返回 Blob、实际 MIME、renderer ID、耗时、`embedded | provider-native | provider-dom | dom-fallback` 策略及 `degraded`;`generateBatch()` 逐项返回成功或失败且保持输入顺序,`generateStream()` 按完成顺序返回原始索引,并把待消费 Blob 数量限制在并发窗口内。
38
+
39
+ 失败会使用 `FileViewerThumbnailError`,稳定错误码包括 `browser-required`、`unsupported`、`timeout`、`aborted`、`capture-failed`、`capture-unavailable`、`tainted-canvas`、`empty-output`、`destroyed` 和 `invalid-options`。
40
+
41
+ - 只支持真实浏览器;服务端需要自行在 Chromium 页面中调用。
42
+ - 字符串形式的 `preset: 'all'` 只在 Vite 插件或 preset side-effect import 已完成注册时有效;跨 bundler 示例应直接传入上面导入的 preset 对象。
43
+ - 内嵌预览始终优先于完整渲染:EPUB 使用声明的封面,OOXML 与 3MF 使用 package thumbnail relationship,OpenDocument 与 XMind 使用包内缩略图,Apple Numbers 存在 Quick Look 缩略图时直接复用。
44
+ - 内嵌图片缺失、损坏、为空或浏览器无法解码时,会安全回退到 renderer 原生直出或首页捕获。
45
+ - PDF 与图片使用原生直出;没有内嵌预览时,Office、OFD、电子书等使用 renderer 提供的首页目标。
46
+ - 其他 renderer 使用 DOM fallback,并在结果中设置 `degraded: true`。
47
+ - 不持久化源文件或缩略图;缓存与上传由调用方负责。
@@ -0,0 +1,10 @@
1
+ import type { ResolvedFileViewerThumbnailOptions } from './types.js';
2
+ export interface ReusableFileViewerDomCaptureContext {
3
+ target?: Element;
4
+ key?: string;
5
+ context?: unknown;
6
+ destroy?: (context: unknown) => void;
7
+ }
8
+ export declare const destroyReusableFileViewerDomCaptureContext: (reusable?: ReusableFileViewerDomCaptureContext) => void;
9
+ export declare const normalizeThumbnailBlob: (documentRef: Document, source: Blob, options: ResolvedFileViewerThumbnailOptions) => Promise<Blob>;
10
+ export declare const captureThumbnailElement: (documentRef: Document, target: Element, options: ResolvedFileViewerThumbnailOptions, reusable?: ReusableFileViewerDomCaptureContext) => Promise<Blob>;
@@ -0,0 +1,222 @@
1
+ import { FileViewerThumbnailError } from './types.js';
2
+ export const destroyReusableFileViewerDomCaptureContext = (reusable) => {
3
+ var _a;
4
+ if (reusable === null || reusable === void 0 ? void 0 : reusable.context) {
5
+ (_a = reusable.destroy) === null || _a === void 0 ? void 0 : _a.call(reusable, reusable.context);
6
+ }
7
+ if (reusable) {
8
+ reusable.target = undefined;
9
+ reusable.key = undefined;
10
+ reusable.context = undefined;
11
+ reusable.destroy = undefined;
12
+ }
13
+ };
14
+ const HIDDEN_CAPTURE_CLASSES = new Set([
15
+ 'file-viewer-toolbar',
16
+ 'archive-preview-toolbar',
17
+ 'pdf-toolbar',
18
+ 'pdf-nav',
19
+ 'epub-toolbar',
20
+ 'epub-toc',
21
+ 'sheet-loading',
22
+ ]);
23
+ const mimeTypeForFormat = (format) => (format === 'png' ? 'image/png' : `image/${format}`);
24
+ const canvasToBlob = (canvas, format, quality) => new Promise((resolve, reject) => {
25
+ try {
26
+ canvas.toBlob(blob => {
27
+ if (blob) {
28
+ const expectedMimeType = mimeTypeForFormat(format);
29
+ if (blob.type !== expectedMimeType) {
30
+ reject(new FileViewerThumbnailError('capture-unavailable', `This browser cannot encode ${expectedMimeType} thumbnails.`));
31
+ }
32
+ else {
33
+ resolve(blob);
34
+ }
35
+ }
36
+ else {
37
+ reject(new FileViewerThumbnailError('capture-failed', 'Canvas encoding returned no data.'));
38
+ }
39
+ }, mimeTypeForFormat(format), quality);
40
+ }
41
+ catch (error) {
42
+ reject(new FileViewerThumbnailError('tainted-canvas', 'Canvas pixels cannot be read.', error));
43
+ }
44
+ });
45
+ const loadBlobImage = async (documentRef, blob) => {
46
+ const view = documentRef.defaultView;
47
+ if (view === null || view === void 0 ? void 0 : view.createImageBitmap) {
48
+ try {
49
+ return await view.createImageBitmap(blob);
50
+ }
51
+ catch {
52
+ // Some browsers expose createImageBitmap but reject otherwise valid image
53
+ // formats. The image element decoder supports a wider set of inputs.
54
+ }
55
+ }
56
+ const url = URL.createObjectURL(blob);
57
+ try {
58
+ const image = documentRef.createElement('img');
59
+ await new Promise((resolve, reject) => {
60
+ image.onload = () => resolve();
61
+ image.onerror = () => reject(new FileViewerThumbnailError('capture-failed', 'Captured image could not be decoded.'));
62
+ image.src = url;
63
+ });
64
+ return image;
65
+ }
66
+ finally {
67
+ URL.revokeObjectURL(url);
68
+ }
69
+ };
70
+ const getImageSize = (image) => {
71
+ if ('naturalWidth' in image) {
72
+ return { width: image.naturalWidth, height: image.naturalHeight };
73
+ }
74
+ if ('videoWidth' in image) {
75
+ return { width: image.videoWidth, height: image.videoHeight };
76
+ }
77
+ return {
78
+ width: 'width' in image ? Number(image.width) : 0,
79
+ height: 'height' in image ? Number(image.height) : 0,
80
+ };
81
+ };
82
+ const closeImageSource = (image) => {
83
+ if ('close' in image && typeof image.close === 'function') {
84
+ image.close();
85
+ }
86
+ };
87
+ const assertCanvasNotBlank = (canvas, background) => {
88
+ const context = canvas.getContext('2d', { willReadFrequently: true });
89
+ if (!context) {
90
+ throw new FileViewerThumbnailError('capture-unavailable', 'A 2D canvas context is required.');
91
+ }
92
+ try {
93
+ const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
94
+ const probe = canvas.ownerDocument.createElement('canvas').getContext('2d');
95
+ if (!probe) {
96
+ return;
97
+ }
98
+ probe.fillStyle = background;
99
+ probe.fillRect(0, 0, 1, 1);
100
+ const base = probe.getImageData(0, 0, 1, 1).data;
101
+ let differentPixels = 0;
102
+ for (let index = 0; index < pixels.length; index += 4) {
103
+ const distance = Math.abs(pixels[index] - base[0]) +
104
+ Math.abs(pixels[index + 1] - base[1]) +
105
+ Math.abs(pixels[index + 2] - base[2]);
106
+ if (pixels[index + 3] > 8 && distance > 24) {
107
+ differentPixels += 1;
108
+ if (differentPixels >= 8) {
109
+ return;
110
+ }
111
+ }
112
+ }
113
+ }
114
+ catch (error) {
115
+ throw new FileViewerThumbnailError('tainted-canvas', 'Thumbnail pixels cannot be inspected.', error);
116
+ }
117
+ throw new FileViewerThumbnailError('empty-output', 'The renderer produced an empty thumbnail.');
118
+ };
119
+ export const normalizeThumbnailBlob = async (documentRef, source, options) => {
120
+ const image = await loadBlobImage(documentRef, source);
121
+ const sourceSize = getImageSize(image);
122
+ if (!sourceSize.width || !sourceSize.height) {
123
+ closeImageSource(image);
124
+ throw new FileViewerThumbnailError('empty-output', 'The captured image has no dimensions.');
125
+ }
126
+ const canvas = documentRef.createElement('canvas');
127
+ canvas.width = options.width;
128
+ canvas.height = options.height;
129
+ const context = canvas.getContext('2d');
130
+ if (!context) {
131
+ closeImageSource(image);
132
+ throw new FileViewerThumbnailError('capture-unavailable', 'A 2D canvas context is required.');
133
+ }
134
+ try {
135
+ context.fillStyle = options.background;
136
+ context.fillRect(0, 0, canvas.width, canvas.height);
137
+ const scale = options.fit === 'cover'
138
+ ? Math.max(options.width / sourceSize.width, options.height / sourceSize.height)
139
+ : Math.min(options.width / sourceSize.width, options.height / sourceSize.height);
140
+ const width = sourceSize.width * scale;
141
+ const height = sourceSize.height * scale;
142
+ context.drawImage(image, (options.width - width) / 2, (options.height - height) / 2, width, height);
143
+ }
144
+ finally {
145
+ closeImageSource(image);
146
+ }
147
+ assertCanvasNotBlank(canvas, options.background);
148
+ return canvasToBlob(canvas, options.format, options.quality);
149
+ };
150
+ const captureCanvas = (canvas) => new Promise((resolve, reject) => {
151
+ try {
152
+ canvas.toBlob(blob => blob
153
+ ? resolve(blob)
154
+ : reject(new FileViewerThumbnailError('capture-failed', 'Canvas capture returned no data.')), 'image/png');
155
+ }
156
+ catch (error) {
157
+ reject(new FileViewerThumbnailError('tainted-canvas', 'Canvas capture was blocked by cross-origin data.', error));
158
+ }
159
+ });
160
+ export const captureThumbnailElement = async (documentRef, target, options, reusable) => {
161
+ const view = documentRef.defaultView;
162
+ if (!view) {
163
+ throw new FileViewerThumbnailError('browser-required', 'Thumbnail capture requires a browser window.');
164
+ }
165
+ if (target instanceof view.HTMLIFrameElement) {
166
+ throw new FileViewerThumbnailError('capture-unavailable', 'Iframe content requires a renderer thumbnail adapter.');
167
+ }
168
+ if (target instanceof view.HTMLCanvasElement) {
169
+ return normalizeThumbnailBlob(documentRef, await captureCanvas(target), options);
170
+ }
171
+ try {
172
+ const { createContext, destroyContext, domToBlob } = await import('modern-screenshot');
173
+ const bounds = target.getBoundingClientRect();
174
+ if (!bounds.width || !bounds.height) {
175
+ throw new FileViewerThumbnailError('empty-output', 'The thumbnail target has no layout dimensions.');
176
+ }
177
+ const contextOptions = {
178
+ backgroundColor: options.background,
179
+ width: Math.ceil(bounds.width),
180
+ height: Math.ceil(bounds.height),
181
+ scale: 1,
182
+ filter(node) {
183
+ if (!(node instanceof view.HTMLElement)) {
184
+ return true;
185
+ }
186
+ const role = node.getAttribute('role');
187
+ const isInteractive = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(node.tagName) ||
188
+ node.tagName === 'NAV' || role === 'button' || role === 'toolbar' ||
189
+ role === 'navigation' || role === 'progressbar';
190
+ return !isInteractive &&
191
+ !node.hasAttribute('data-thumbnail-exclude') &&
192
+ !Array.from(node.classList).some(className => HIDDEN_CAPTURE_CLASSES.has(className)) &&
193
+ node.getAttribute('aria-busy') !== 'true';
194
+ },
195
+ };
196
+ const contextKey = `${contextOptions.width}:${contextOptions.height}:${options.background}`;
197
+ if (reusable && (reusable.target !== target || reusable.key !== contextKey)) {
198
+ destroyReusableFileViewerDomCaptureContext(reusable);
199
+ }
200
+ if (reusable && !reusable.context) {
201
+ reusable.target = target;
202
+ reusable.key = contextKey;
203
+ reusable.context = await createContext(target, { ...contextOptions, autoDestruct: false });
204
+ reusable.destroy = context => destroyContext(context);
205
+ }
206
+ const blob = (reusable === null || reusable === void 0 ? void 0 : reusable.context)
207
+ ? await domToBlob(reusable.context)
208
+ : await domToBlob(target, contextOptions);
209
+ if (!blob) {
210
+ throw new FileViewerThumbnailError('capture-failed', 'DOM capture returned no data.');
211
+ }
212
+ return normalizeThumbnailBlob(documentRef, blob, options);
213
+ }
214
+ catch (error) {
215
+ if (error instanceof FileViewerThumbnailError) {
216
+ throw error;
217
+ }
218
+ const message = error instanceof Error ? error.message : String(error);
219
+ const code = /security|taint|cross.origin/i.test(message) ? 'tainted-canvas' : 'capture-failed';
220
+ throw new FileViewerThumbnailError(code, `Unable to capture renderer output: ${message}`, error);
221
+ }
222
+ };
@@ -0,0 +1,2 @@
1
+ import type { NormalizedFileViewerSource } from '@file-viewer/core';
2
+ export declare const extractEpubCover: (source: NormalizedFileViewerSource, signal?: AbortSignal) => Promise<Blob | null>;
@@ -0,0 +1,43 @@
1
+ import { MAX_EMBEDDED_THUMBNAIL_BYTES, raceWithAbort, throwIfAborted, } from './shared.js';
2
+ const resolveEpubFactory = (module) => {
3
+ let value = module;
4
+ for (let depth = 0; depth < 3 && value && typeof value !== 'function'; depth += 1) {
5
+ value = value.default;
6
+ }
7
+ if (typeof value !== 'function') {
8
+ throw new Error('epubjs module does not expose a callable factory.');
9
+ }
10
+ return value;
11
+ };
12
+ export const extractEpubCover = async (source, signal) => {
13
+ var _a;
14
+ if (!source.buffer) {
15
+ return null;
16
+ }
17
+ throwIfAborted(signal);
18
+ const ePub = resolveEpubFactory(await import('epubjs'));
19
+ const book = ePub(source.buffer.slice(0), { openAs: 'binary', replacements: 'blobUrl' });
20
+ let destroyed = false;
21
+ const destroyBook = () => {
22
+ if (!destroyed) {
23
+ destroyed = true;
24
+ book.destroy();
25
+ }
26
+ };
27
+ try {
28
+ await raceWithAbort(book.ready, signal, destroyBook);
29
+ const coverUrl = await raceWithAbort(((_a = book.coverUrl) === null || _a === void 0 ? void 0 : _a.call(book)) || Promise.resolve(null), signal, destroyBook);
30
+ if (!coverUrl) {
31
+ return null;
32
+ }
33
+ const response = await fetch(coverUrl, { signal });
34
+ if (!response.ok) {
35
+ return null;
36
+ }
37
+ const blob = await response.blob();
38
+ return blob.size && blob.size <= MAX_EMBEDDED_THUMBNAIL_BYTES ? blob : null;
39
+ }
40
+ finally {
41
+ destroyBook();
42
+ }
43
+ };
@@ -0,0 +1,3 @@
1
+ import type { NormalizedFileViewerSource } from '@file-viewer/core';
2
+ export declare const canExtractEmbeddedThumbnail: (extension: string) => boolean;
3
+ export declare const extractEmbeddedThumbnail: (source: NormalizedFileViewerSource, documentRef: Document, signal?: AbortSignal) => Promise<Blob | null>;
@@ -0,0 +1,9 @@
1
+ import { extractEpubCover } from './epub.js';
2
+ import { canExtractZipPackageThumbnail, extractZipPackageThumbnail, } from './package.js';
3
+ export const canExtractEmbeddedThumbnail = (extension) => (extension === 'epub' || canExtractZipPackageThumbnail(extension));
4
+ export const extractEmbeddedThumbnail = async (source, documentRef, signal) => {
5
+ if (source.extension === 'epub') {
6
+ return extractEpubCover(source, signal);
7
+ }
8
+ return extractZipPackageThumbnail(source, documentRef, signal);
9
+ };
@@ -0,0 +1,3 @@
1
+ import type { NormalizedFileViewerSource } from '@file-viewer/core';
2
+ export declare const canExtractZipPackageThumbnail: (extension: string) => boolean;
3
+ export declare const extractZipPackageThumbnail: (source: NormalizedFileViewerSource, documentRef: Document, signal?: AbortSignal) => Promise<Blob | null>;
@@ -0,0 +1,145 @@
1
+ import { MAX_EMBEDDED_THUMBNAIL_BYTES, raceWithAbort, throwIfAborted, toImageBlob, } from './shared.js';
2
+ const OPEN_XML_EXTENSIONS = new Set([
3
+ 'docx', 'docm', 'dotx', 'dotm',
4
+ 'pptx', 'pptm', 'potx', 'potm', 'ppsx', 'ppsm',
5
+ 'xlsx', 'xltx', 'xlsm', 'xltm', 'xlsb',
6
+ ]);
7
+ const OPEN_DOCUMENT_EXTENSIONS = new Set(['odt', 'odp', 'ods']);
8
+ const OPEN_XML_MAIN_PARTS = {
9
+ docx: 'word/document.xml', docm: 'word/document.xml',
10
+ dotx: 'word/document.xml', dotm: 'word/document.xml',
11
+ pptx: 'ppt/presentation.xml', pptm: 'ppt/presentation.xml',
12
+ potx: 'ppt/presentation.xml', potm: 'ppt/presentation.xml',
13
+ ppsx: 'ppt/presentation.xml', ppsm: 'ppt/presentation.xml',
14
+ xlsx: 'xl/workbook.xml', xltx: 'xl/workbook.xml',
15
+ xlsm: 'xl/workbook.xml', xltm: 'xl/workbook.xml',
16
+ xlsb: 'xl/workbook.bin',
17
+ };
18
+ const normalizeZipPath = (path) => {
19
+ let decoded = path.trim().replace(/\\/g, '/').split(/[?#]/, 1)[0] || '';
20
+ try {
21
+ decoded = decodeURIComponent(decoded);
22
+ }
23
+ catch {
24
+ // Keep the original package path when it contains malformed escapes.
25
+ }
26
+ try {
27
+ return new URL(decoded, 'https://file-viewer.invalid/').pathname.replace(/^\/+/, '');
28
+ }
29
+ catch {
30
+ return decoded.replace(/^\/+/, '');
31
+ }
32
+ };
33
+ const findZipEntry = (zip, path) => {
34
+ const normalized = normalizeZipPath(path);
35
+ const exact = zip.file(normalized);
36
+ if (exact && !exact.dir) {
37
+ return exact;
38
+ }
39
+ const lower = normalized.toLowerCase();
40
+ return Object.values(zip.files).find(entry => !entry.dir && entry.name.toLowerCase() === lower) || null;
41
+ };
42
+ const readZipImage = async (zip, paths, signal) => {
43
+ var _a;
44
+ for (const path of paths) {
45
+ throwIfAborted(signal);
46
+ const entry = findZipEntry(zip, path);
47
+ if (!entry) {
48
+ continue;
49
+ }
50
+ const declaredSize = Number((_a = entry._data) === null || _a === void 0 ? void 0 : _a.uncompressedSize);
51
+ if (Number.isFinite(declaredSize) && declaredSize > MAX_EMBEDDED_THUMBNAIL_BYTES) {
52
+ continue;
53
+ }
54
+ const blob = toImageBlob(await raceWithAbort(entry.async('uint8array'), signal), entry.name);
55
+ if (blob) {
56
+ return blob;
57
+ }
58
+ }
59
+ return null;
60
+ };
61
+ const readPackageRelationshipTarget = async (zip, documentRef, typePattern, signal) => {
62
+ var _a;
63
+ const relationshipEntry = findZipEntry(zip, '_rels/.rels');
64
+ if (!relationshipEntry) {
65
+ return '';
66
+ }
67
+ const bytes = await raceWithAbort(relationshipEntry.async('uint8array'), signal);
68
+ const Parser = ((_a = documentRef.defaultView) === null || _a === void 0 ? void 0 : _a.DOMParser) || globalThis.DOMParser;
69
+ if (!Parser) {
70
+ return '';
71
+ }
72
+ const relationshipDocument = new Parser().parseFromString(new TextDecoder().decode(bytes), 'application/xml');
73
+ const relationships = Array.from(relationshipDocument.getElementsByTagName('*'))
74
+ .filter(element => (element.localName || element.tagName).toLowerCase() === 'relationship');
75
+ for (const relationship of relationships) {
76
+ const type = relationship.getAttribute('Type') || '';
77
+ const targetMode = relationship.getAttribute('TargetMode') || '';
78
+ if (typePattern.test(type) && !/^external$/i.test(targetMode)) {
79
+ return normalizeZipPath(relationship.getAttribute('Target') || '');
80
+ }
81
+ }
82
+ return '';
83
+ };
84
+ const extractOpenXmlThumbnail = async (zip, extension, documentRef, signal) => {
85
+ const mainPart = OPEN_XML_MAIN_PARTS[extension];
86
+ if (!findZipEntry(zip, '[Content_Types].xml') || !mainPart || !findZipEntry(zip, mainPart)) {
87
+ return null;
88
+ }
89
+ const relationshipTarget = await readPackageRelationshipTarget(zip, documentRef, /\/metadata\/thumbnail$/i, signal);
90
+ return readZipImage(zip, [
91
+ relationshipTarget,
92
+ 'docProps/thumbnail.jpeg', 'docProps/thumbnail.jpg',
93
+ 'docProps/thumbnail.png', 'docProps/thumbnail.webp',
94
+ ].filter(Boolean), signal);
95
+ };
96
+ const extractThreeMfThumbnail = async (zip, documentRef, signal) => {
97
+ const hasModel = Object.values(zip.files).some(entry => !entry.dir && /^3d\/.*\.model$/i.test(entry.name));
98
+ if (!findZipEntry(zip, '[Content_Types].xml') || !hasModel) {
99
+ return null;
100
+ }
101
+ const relationshipTarget = await readPackageRelationshipTarget(zip, documentRef, /\/thumbnail$/i, signal);
102
+ return readZipImage(zip, [
103
+ relationshipTarget,
104
+ 'Metadata/thumbnail.png', 'Metadata/thumbnail.jpg', 'Metadata/thumbnail.jpeg',
105
+ ].filter(Boolean), signal);
106
+ };
107
+ export const canExtractZipPackageThumbnail = (extension) => (extension === 'xmind' || extension === 'numbers' || extension === '3mf' ||
108
+ OPEN_XML_EXTENSIONS.has(extension) || OPEN_DOCUMENT_EXTENSIONS.has(extension));
109
+ export const extractZipPackageThumbnail = async (source, documentRef, signal) => {
110
+ if (!source.buffer || !canExtractZipPackageThumbnail(source.extension)) {
111
+ return null;
112
+ }
113
+ throwIfAborted(signal);
114
+ const module = await import('jszip');
115
+ const JSZip = (module.default || module);
116
+ const zip = await raceWithAbort(JSZip.loadAsync(source.buffer), signal);
117
+ throwIfAborted(signal);
118
+ if (OPEN_XML_EXTENSIONS.has(source.extension)) {
119
+ return extractOpenXmlThumbnail(zip, source.extension, documentRef, signal);
120
+ }
121
+ if (OPEN_DOCUMENT_EXTENSIONS.has(source.extension)) {
122
+ if (!findZipEntry(zip, 'mimetype') || !findZipEntry(zip, 'content.xml'))
123
+ return null;
124
+ return readZipImage(zip, [
125
+ 'Thumbnails/thumbnail.png', 'Thumbnails/thumbnail.jpg',
126
+ 'Thumbnails/thumbnail.jpeg', 'Thumbnails/thumbnail.webp',
127
+ ], signal);
128
+ }
129
+ if (source.extension === 'xmind') {
130
+ if (!findZipEntry(zip, 'content.json') && !findZipEntry(zip, 'content.xml'))
131
+ return null;
132
+ return readZipImage(zip, [
133
+ 'Thumbnails/thumbnail.png', 'Thumbnails/thumbnail.jpg',
134
+ 'Thumbnails/thumbnail.jpeg', 'preview.png', 'thumbnail.png',
135
+ ], signal);
136
+ }
137
+ if (source.extension === 'numbers') {
138
+ if (!findZipEntry(zip, 'Index/Document.iwa') && !findZipEntry(zip, 'index.xml'))
139
+ return null;
140
+ return readZipImage(zip, [
141
+ 'QuickLook/Thumbnail.jpg', 'QuickLook/Thumbnail.jpeg', 'QuickLook/Thumbnail.png',
142
+ ], signal);
143
+ }
144
+ return extractThreeMfThumbnail(zip, documentRef, signal);
145
+ };
@@ -0,0 +1,4 @@
1
+ export declare const MAX_EMBEDDED_THUMBNAIL_BYTES: number;
2
+ export declare const throwIfAborted: (signal?: AbortSignal) => void;
3
+ export declare const raceWithAbort: <T>(operation: Promise<T>, signal?: AbortSignal, onAbort?: () => void) => Promise<T>;
4
+ export declare const toImageBlob: (bytes: Uint8Array, path: string) => Blob | null;
@@ -0,0 +1,69 @@
1
+ export const MAX_EMBEDDED_THUMBNAIL_BYTES = 20 * 1024 * 1024;
2
+ const copyToArrayBuffer = (bytes) => {
3
+ const copy = new Uint8Array(bytes.byteLength);
4
+ copy.set(bytes);
5
+ return copy.buffer;
6
+ };
7
+ const abortError = (signal) => (signal.reason instanceof Error
8
+ ? signal.reason
9
+ : new DOMException('Thumbnail extraction was aborted.', 'AbortError'));
10
+ export const throwIfAborted = (signal) => {
11
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
12
+ throw abortError(signal);
13
+ }
14
+ };
15
+ export const raceWithAbort = (operation, signal, onAbort) => {
16
+ if (!signal) {
17
+ return operation;
18
+ }
19
+ throwIfAborted(signal);
20
+ return new Promise((resolve, reject) => {
21
+ const abort = () => {
22
+ onAbort === null || onAbort === void 0 ? void 0 : onAbort();
23
+ reject(abortError(signal));
24
+ };
25
+ signal.addEventListener('abort', abort, { once: true });
26
+ operation.then(value => {
27
+ signal.removeEventListener('abort', abort);
28
+ resolve(value);
29
+ }, error => {
30
+ signal.removeEventListener('abort', abort);
31
+ reject(error);
32
+ });
33
+ });
34
+ };
35
+ const mimeTypeFromBytes = (bytes, path) => {
36
+ var _a, _b;
37
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
38
+ return 'image/jpeg';
39
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
40
+ return 'image/png';
41
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38)
42
+ return 'image/gif';
43
+ if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
44
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50)
45
+ return 'image/webp';
46
+ if (bytes[0] === 0x42 && bytes[1] === 0x4d)
47
+ return 'image/bmp';
48
+ const extension = (_b = (_a = path.split(/[?#]/, 1)[0]) === null || _a === void 0 ? void 0 : _a.split('.').pop()) === null || _b === void 0 ? void 0 : _b.toLowerCase();
49
+ if (extension === 'jpg' || extension === 'jpeg')
50
+ return 'image/jpeg';
51
+ if (extension === 'png')
52
+ return 'image/png';
53
+ if (extension === 'gif')
54
+ return 'image/gif';
55
+ if (extension === 'webp')
56
+ return 'image/webp';
57
+ if (extension === 'bmp')
58
+ return 'image/bmp';
59
+ if (extension === 'svg' || extension === 'svgz')
60
+ return 'image/svg+xml';
61
+ return '';
62
+ };
63
+ export const toImageBlob = (bytes, path) => {
64
+ if (!bytes.byteLength || bytes.byteLength > MAX_EMBEDDED_THUMBNAIL_BYTES) {
65
+ return null;
66
+ }
67
+ const type = mimeTypeFromBytes(bytes, path);
68
+ return type ? new Blob([copyToArrayBuffer(bytes)], { type }) : null;
69
+ };
@@ -0,0 +1,2 @@
1
+ import { type CreateFileViewerThumbnailGeneratorOptions, type FileViewerThumbnailGenerator } from './types.js';
2
+ export declare const createFileViewerThumbnailGenerator: (createOptions?: CreateFileViewerThumbnailGeneratorOptions) => FileViewerThumbnailGenerator;
@@ -0,0 +1,360 @@
1
+ import { createViewer, normalizeSource, readFileViewerBuffer, } from '@file-viewer/core';
2
+ import { captureThumbnailElement, destroyReusableFileViewerDomCaptureContext, normalizeThumbnailBlob, } from './capture.js';
3
+ import { canExtractEmbeddedThumbnail, extractEmbeddedThumbnail } from './embedded/index.js';
4
+ import { resolveFileViewerThumbnailConcurrency, resolveFileViewerThumbnailOptions, toCaptureOptions, } from './options.js';
5
+ import { FileViewerThumbnailError, } from './types.js';
6
+ const toThumbnailError = (error) => {
7
+ if (error instanceof FileViewerThumbnailError) {
8
+ return error;
9
+ }
10
+ if (error instanceof DOMException && error.name === 'AbortError') {
11
+ return new FileViewerThumbnailError('aborted', 'Thumbnail generation was aborted.', error);
12
+ }
13
+ const message = error instanceof Error ? error.message : String(error);
14
+ return new FileViewerThumbnailError('capture-failed', message, error);
15
+ };
16
+ const throwIfAborted = (signal) => {
17
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
18
+ throw new FileViewerThumbnailError('aborted', 'Thumbnail generation was aborted.', signal.reason);
19
+ }
20
+ };
21
+ const withDeadline = async (operation, timeoutMs, signal, onTimeout) => new Promise((resolve, reject) => {
22
+ throwIfAborted(signal);
23
+ let settled = false;
24
+ const finish = (callback) => {
25
+ if (settled) {
26
+ return;
27
+ }
28
+ settled = true;
29
+ clearTimeout(timer);
30
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onAbort);
31
+ callback();
32
+ };
33
+ const timer = setTimeout(() => finish(() => {
34
+ const error = new FileViewerThumbnailError('timeout', `Thumbnail generation exceeded ${timeoutMs}ms.`);
35
+ onTimeout === null || onTimeout === void 0 ? void 0 : onTimeout(error);
36
+ reject(error);
37
+ }), timeoutMs);
38
+ const onAbort = () => finish(() => reject((signal === null || signal === void 0 ? void 0 : signal.reason) instanceof FileViewerThumbnailError
39
+ ? signal.reason
40
+ : new FileViewerThumbnailError('aborted', 'Thumbnail generation was aborted.', signal === null || signal === void 0 ? void 0 : signal.reason)));
41
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onAbort, { once: true });
42
+ operation.then(value => finish(() => resolve(value)), error => finish(() => reject(error)));
43
+ });
44
+ const waitForCaptureReadiness = async (documentRef, signal) => {
45
+ var _a;
46
+ throwIfAborted(signal);
47
+ await ((_a = documentRef.fonts) === null || _a === void 0 ? void 0 : _a.ready.catch(() => undefined));
48
+ const view = documentRef.defaultView;
49
+ await new Promise(resolve => {
50
+ var _a;
51
+ if (!(view === null || view === void 0 ? void 0 : view.requestAnimationFrame)) {
52
+ (_a = view === null || view === void 0 ? void 0 : view.setTimeout(() => resolve(), 0)) !== null && _a !== void 0 ? _a : resolve();
53
+ return;
54
+ }
55
+ view.requestAnimationFrame(() => view.requestAnimationFrame(() => resolve()));
56
+ });
57
+ throwIfAborted(signal);
58
+ };
59
+ const createPoolSlot = (documentRef, options) => {
60
+ const host = documentRef.createElement('div');
61
+ host.dataset.fileViewerThumbnailSlot = '';
62
+ host.style.cssText = [
63
+ 'position:fixed',
64
+ 'left:-100000px',
65
+ 'top:0',
66
+ 'width:320px',
67
+ 'height:240px',
68
+ 'overflow:hidden',
69
+ 'pointer-events:none',
70
+ 'contain:layout style paint',
71
+ 'z-index:-2147483648',
72
+ ].join(';');
73
+ documentRef.body.append(host);
74
+ const viewer = createViewer(host, {
75
+ options: {
76
+ ...options.viewerOptions,
77
+ toolbar: false,
78
+ watermark: false,
79
+ fit: 'contain',
80
+ },
81
+ renderPurpose: 'thumbnail',
82
+ });
83
+ return { host, viewer, captureContext: {} };
84
+ };
85
+ const materializeThumbnailSource = async (source, signal) => {
86
+ var _a;
87
+ if (source.buffer) {
88
+ return source;
89
+ }
90
+ if (source.file) {
91
+ return { ...source, buffer: await readFileViewerBuffer(source.file) };
92
+ }
93
+ if (source.url) {
94
+ const response = await fetch(source.url, { signal });
95
+ if (!response.ok) {
96
+ throw new FileViewerThumbnailError('capture-failed', `Unable to download thumbnail source: HTTP ${response.status}.`);
97
+ }
98
+ return {
99
+ ...source,
100
+ buffer: await response.arrayBuffer(),
101
+ size: (_a = source.size) !== null && _a !== void 0 ? _a : (Number(response.headers.get('content-length')) || undefined),
102
+ };
103
+ }
104
+ throw new FileViewerThumbnailError('unsupported', 'Thumbnail source has no file bytes or URL.');
105
+ };
106
+ const generateInSlot = async (slot, source, options, generatorSignal) => {
107
+ var _a, _b, _c, _d, _e, _f;
108
+ const startedAt = performance.now();
109
+ const remainingTime = () => Math.max(1, options.timeoutMs - (performance.now() - startedAt));
110
+ const taskController = new AbortController();
111
+ const sourceSignals = [options.signal, generatorSignal].filter((signal) => Boolean(signal));
112
+ const abortListeners = [];
113
+ for (const signal of sourceSignals) {
114
+ if (signal.aborted) {
115
+ taskController.abort(signal.reason);
116
+ break;
117
+ }
118
+ const onAbort = () => taskController.abort(signal.reason);
119
+ signal.addEventListener('abort', onAbort, { once: true });
120
+ abortListeners.push(() => signal.removeEventListener('abort', onAbort));
121
+ }
122
+ const taskOptions = { ...options, signal: taskController.signal };
123
+ const runWithDeadline = (operation) => withDeadline(operation, remainingTime(), taskController.signal, error => taskController.abort(error));
124
+ slot.host.style.width = `${options.width}px`;
125
+ slot.host.style.height = `${options.height}px`;
126
+ throwIfAborted(taskOptions.signal);
127
+ try {
128
+ const materializedSource = await runWithDeadline(materializeThumbnailSource(source, taskOptions.signal));
129
+ const normalizedMaterializedSource = normalizeSource(materializedSource);
130
+ await runWithDeadline(((_b = (_a = slot.viewer).prepare) === null || _b === void 0 ? void 0 : _b.call(_a)) || Promise.resolve());
131
+ const preparedRenderer = slot.viewer.getRenderer(normalizedMaterializedSource.extension);
132
+ if (!(preparedRenderer === null || preparedRenderer === void 0 ? void 0 : preparedRenderer.load)) {
133
+ throw new FileViewerThumbnailError('unsupported', `No installed renderer can generate a .${normalizedMaterializedSource.extension || source.type || ''} thumbnail.`);
134
+ }
135
+ const captureOptions = toCaptureOptions(taskOptions);
136
+ if (canExtractEmbeddedThumbnail(normalizedMaterializedSource.extension)) {
137
+ try {
138
+ const embedded = await runWithDeadline(extractEmbeddedThumbnail(normalizedMaterializedSource, slot.host.ownerDocument, taskOptions.signal));
139
+ if (embedded) {
140
+ const blob = await runWithDeadline(normalizeThumbnailBlob(slot.host.ownerDocument, embedded, taskOptions));
141
+ return {
142
+ blob,
143
+ width: options.width,
144
+ height: options.height,
145
+ mimeType: blob.type,
146
+ filename: normalizedMaterializedSource.filename,
147
+ extension: normalizedMaterializedSource.extension,
148
+ rendererId: preparedRenderer.id,
149
+ strategy: 'embedded',
150
+ degraded: false,
151
+ durationMs: Math.max(0, performance.now() - startedAt),
152
+ };
153
+ }
154
+ }
155
+ catch (error) {
156
+ if (error instanceof FileViewerThumbnailError &&
157
+ (error.code === 'timeout' || error.code === 'aborted')) {
158
+ throw error;
159
+ }
160
+ if (taskOptions.signal.aborted) {
161
+ const reason = taskOptions.signal.reason;
162
+ if (reason instanceof FileViewerThumbnailError) {
163
+ throw reason;
164
+ }
165
+ throw new FileViewerThumbnailError('aborted', 'Thumbnail generation was aborted.', reason);
166
+ }
167
+ // Missing, corrupt, or browser-incompatible embedded images are hints,
168
+ // not fatal document errors. Continue through the renderer fast path.
169
+ }
170
+ }
171
+ const session = await runWithDeadline(slot.viewer.load(materializedSource, { signal: taskOptions.signal }));
172
+ const normalizedSource = slot.viewer.getSource();
173
+ const renderer = slot.viewer.getRenderer();
174
+ if (!session || !normalizedSource || !(renderer === null || renderer === void 0 ? void 0 : renderer.load)) {
175
+ throw new FileViewerThumbnailError('unsupported', `No installed renderer can generate a .${(normalizedSource === null || normalizedSource === void 0 ? void 0 : normalizedSource.extension) || source.type || ''} thumbnail.`);
176
+ }
177
+ await runWithDeadline(waitForCaptureReadiness(slot.host.ownerDocument, taskOptions.signal));
178
+ const adapter = ((_d = (_c = slot.viewer).getThumbnailAdapter) === null || _d === void 0 ? void 0 : _d.call(_c)) || null;
179
+ const captured = await runWithDeadline((async () => {
180
+ var _a, _b, _c;
181
+ await ((_a = adapter === null || adapter === void 0 ? void 0 : adapter.beforeCapture) === null || _a === void 0 ? void 0 : _a.call(adapter, captureOptions));
182
+ throwIfAborted(taskOptions.signal);
183
+ let blob = await ((_b = adapter === null || adapter === void 0 ? void 0 : adapter.capture) === null || _b === void 0 ? void 0 : _b.call(adapter, captureOptions)) || null;
184
+ let strategy = (adapter === null || adapter === void 0 ? void 0 : adapter.captureSource) === 'embedded'
185
+ ? 'embedded'
186
+ : 'provider-native';
187
+ if (blob) {
188
+ blob = await normalizeThumbnailBlob(slot.host.ownerDocument, blob, taskOptions);
189
+ }
190
+ else {
191
+ const adapterTarget = await ((_c = adapter === null || adapter === void 0 ? void 0 : adapter.getTarget) === null || _c === void 0 ? void 0 : _c.call(adapter, captureOptions)) || null;
192
+ strategy = adapterTarget ? 'provider-dom' : 'dom-fallback';
193
+ blob = await captureThumbnailElement(slot.host.ownerDocument, adapterTarget || slot.host, taskOptions, slot.captureContext);
194
+ }
195
+ return { blob, strategy };
196
+ })());
197
+ const { blob, strategy } = captured;
198
+ return {
199
+ blob,
200
+ width: options.width,
201
+ height: options.height,
202
+ mimeType: blob.type,
203
+ filename: normalizedSource.filename,
204
+ extension: normalizedSource.extension,
205
+ rendererId: renderer.id,
206
+ strategy,
207
+ degraded: strategy === 'dom-fallback',
208
+ durationMs: Math.max(0, performance.now() - startedAt),
209
+ };
210
+ }
211
+ catch (error) {
212
+ throw toThumbnailError(error);
213
+ }
214
+ finally {
215
+ abortListeners.forEach(remove => remove());
216
+ taskController.abort();
217
+ const unloading = (_f = (_e = slot.viewer).unload) === null || _f === void 0 ? void 0 : _f.call(_e, 'replace');
218
+ await (unloading === null || unloading === void 0 ? void 0 : unloading.catch(() => undefined));
219
+ }
220
+ };
221
+ export const createFileViewerThumbnailGenerator = (createOptions = {}) => {
222
+ var _a;
223
+ const documentRef = createOptions.document || (typeof document !== 'undefined' ? document : undefined);
224
+ if (!(documentRef === null || documentRef === void 0 ? void 0 : documentRef.body) || !documentRef.defaultView) {
225
+ throw new FileViewerThumbnailError('browser-required', 'Thumbnail generation requires a browser document.');
226
+ }
227
+ const concurrency = resolveFileViewerThumbnailConcurrency(createOptions.concurrency);
228
+ const defaultTimeoutMs = (_a = createOptions.timeoutMs) !== null && _a !== void 0 ? _a : 30000;
229
+ const slots = Array.from({ length: concurrency }, () => createPoolSlot(documentRef, createOptions));
230
+ const availableSlots = [...slots];
231
+ const jobs = [];
232
+ const active = new Set();
233
+ const destroyController = new AbortController();
234
+ let destroyed = false;
235
+ const drain = () => {
236
+ var _a, _b;
237
+ while (!destroyed && availableSlots.length && jobs.length) {
238
+ const slot = availableSlots.shift();
239
+ const job = jobs.shift();
240
+ (_a = job.removeAbort) === null || _a === void 0 ? void 0 : _a.call(job);
241
+ if ((_b = job.signal) === null || _b === void 0 ? void 0 : _b.aborted) {
242
+ availableSlots.push(slot);
243
+ job.reject(new FileViewerThumbnailError('aborted', 'Thumbnail generation was aborted.', job.signal.reason));
244
+ continue;
245
+ }
246
+ const task = job.run(slot);
247
+ active.add(task);
248
+ void task.then(job.resolve, job.reject).finally(() => {
249
+ active.delete(task);
250
+ availableSlots.push(slot);
251
+ drain();
252
+ });
253
+ }
254
+ };
255
+ const enqueue = (job) => new Promise((resolve, reject) => {
256
+ var _a;
257
+ if (destroyed) {
258
+ reject(new FileViewerThumbnailError('destroyed', 'The thumbnail generator has been destroyed.'));
259
+ return;
260
+ }
261
+ if ((_a = job.signal) === null || _a === void 0 ? void 0 : _a.aborted) {
262
+ reject(new FileViewerThumbnailError('aborted', 'Thumbnail generation was aborted.', job.signal.reason));
263
+ return;
264
+ }
265
+ const queued = { ...job, resolve, reject };
266
+ if (job.signal) {
267
+ const onAbort = () => {
268
+ var _a;
269
+ const index = jobs.indexOf(queued);
270
+ if (index >= 0) {
271
+ jobs.splice(index, 1);
272
+ reject(new FileViewerThumbnailError('aborted', 'Thumbnail generation was aborted.', (_a = job.signal) === null || _a === void 0 ? void 0 : _a.reason));
273
+ }
274
+ };
275
+ job.signal.addEventListener('abort', onAbort, { once: true });
276
+ queued.removeAbort = () => { var _a; return (_a = job.signal) === null || _a === void 0 ? void 0 : _a.removeEventListener('abort', onAbort); };
277
+ }
278
+ jobs.push(queued);
279
+ drain();
280
+ });
281
+ const generate = async (source, options) => {
282
+ const resolved = resolveFileViewerThumbnailOptions(options, defaultTimeoutMs);
283
+ return enqueue({
284
+ signal: resolved.signal,
285
+ run: slot => generateInSlot(slot, source, resolved, destroyController.signal),
286
+ });
287
+ };
288
+ const toBatchItem = async (source, index, options) => {
289
+ try {
290
+ return { ok: true, index, source, result: await generate(source, options) };
291
+ }
292
+ catch (error) {
293
+ return { ok: false, index, source, error: toThumbnailError(error) };
294
+ }
295
+ };
296
+ return {
297
+ generate,
298
+ generateBatch(sources, options) {
299
+ return Promise.all(sources.map((source, index) => toBatchItem(source, index, options)));
300
+ },
301
+ async *generateStream(sources, options) {
302
+ var _a, _b, _c;
303
+ const streamController = new AbortController();
304
+ const onSourceAbort = () => { var _a; return streamController.abort((_a = options === null || options === void 0 ? void 0 : options.signal) === null || _a === void 0 ? void 0 : _a.reason); };
305
+ if ((_a = options === null || options === void 0 ? void 0 : options.signal) === null || _a === void 0 ? void 0 : _a.aborted) {
306
+ streamController.abort(options.signal.reason);
307
+ }
308
+ else {
309
+ (_b = options === null || options === void 0 ? void 0 : options.signal) === null || _b === void 0 ? void 0 : _b.addEventListener('abort', onSourceAbort, { once: true });
310
+ }
311
+ const streamOptions = { ...options, signal: streamController.signal };
312
+ const pending = [];
313
+ let nextIndex = 0;
314
+ const fillWindow = () => {
315
+ while (nextIndex < sources.length && pending.length < concurrency) {
316
+ const index = nextIndex++;
317
+ pending.push({ index, promise: toBatchItem(sources[index], index, streamOptions) });
318
+ }
319
+ };
320
+ try {
321
+ fillWindow();
322
+ while (pending.length) {
323
+ const completed = await Promise.race(pending.map(item => item.promise.then(value => ({ index: item.index, value }))));
324
+ const pendingIndex = pending.findIndex(item => item.index === completed.index);
325
+ if (pendingIndex >= 0) {
326
+ pending.splice(pendingIndex, 1);
327
+ }
328
+ // Keep at most one concurrency window resident so a slow stream
329
+ // consumer cannot accumulate a Blob for every source in memory.
330
+ fillWindow();
331
+ yield completed.value;
332
+ }
333
+ }
334
+ finally {
335
+ (_c = options === null || options === void 0 ? void 0 : options.signal) === null || _c === void 0 ? void 0 : _c.removeEventListener('abort', onSourceAbort);
336
+ streamController.abort('Thumbnail stream closed.');
337
+ }
338
+ },
339
+ async destroy() {
340
+ if (destroyed) {
341
+ return;
342
+ }
343
+ destroyed = true;
344
+ const destroyedError = new FileViewerThumbnailError('destroyed', 'The thumbnail generator has been destroyed.');
345
+ destroyController.abort(destroyedError);
346
+ jobs.splice(0).forEach(job => {
347
+ var _a;
348
+ (_a = job.removeAbort) === null || _a === void 0 ? void 0 : _a.call(job);
349
+ job.reject(destroyedError);
350
+ });
351
+ await Promise.allSettled([...active]);
352
+ await Promise.all(slots.map(slot => slot.viewer.destroy('component-unmount')));
353
+ slots.forEach(slot => {
354
+ destroyReusableFileViewerDomCaptureContext(slot.captureContext);
355
+ slot.host.remove();
356
+ });
357
+ availableSlots.length = 0;
358
+ },
359
+ };
360
+ };
@@ -0,0 +1,3 @@
1
+ export { DEFAULT_FILE_VIEWER_THUMBNAIL_OPTIONS, resolveFileViewerThumbnailConcurrency, resolveFileViewerThumbnailOptions, } from './options.js';
2
+ export { createFileViewerThumbnailGenerator } from './generator.js';
3
+ export { FileViewerThumbnailError, type CreateFileViewerThumbnailGeneratorOptions, type FileViewerThumbnailBatchFailure, type FileViewerThumbnailBatchItem, type FileViewerThumbnailBatchSuccess, type FileViewerThumbnailErrorCode, type FileViewerThumbnailGenerator, type FileViewerThumbnailOptions, type FileViewerThumbnailResult, type FileViewerThumbnailStrategy, type ResolvedFileViewerThumbnailOptions, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { DEFAULT_FILE_VIEWER_THUMBNAIL_OPTIONS, resolveFileViewerThumbnailConcurrency, resolveFileViewerThumbnailOptions, } from './options.js';
2
+ export { createFileViewerThumbnailGenerator } from './generator.js';
3
+ export { FileViewerThumbnailError, } from './types.js';
@@ -0,0 +1,14 @@
1
+ import type { FileViewerThumbnailCaptureOptions } from '@file-viewer/core';
2
+ import { type FileViewerThumbnailOptions, type ResolvedFileViewerThumbnailOptions } from './types.js';
3
+ export declare const DEFAULT_FILE_VIEWER_THUMBNAIL_OPTIONS: Readonly<{
4
+ readonly width: 320;
5
+ readonly height: 240;
6
+ readonly format: "webp";
7
+ readonly quality: 0.8;
8
+ readonly fit: "contain";
9
+ readonly background: "#f1f5f9";
10
+ readonly timeoutMs: 30000;
11
+ }>;
12
+ export declare const resolveFileViewerThumbnailOptions: (options?: FileViewerThumbnailOptions, defaultTimeoutMs?: number) => ResolvedFileViewerThumbnailOptions;
13
+ export declare const toCaptureOptions: (options: ResolvedFileViewerThumbnailOptions) => FileViewerThumbnailCaptureOptions;
14
+ export declare const resolveFileViewerThumbnailConcurrency: (value?: number) => number;
@@ -0,0 +1,61 @@
1
+ import { FileViewerThumbnailError, } from './types.js';
2
+ export const DEFAULT_FILE_VIEWER_THUMBNAIL_OPTIONS = Object.freeze({
3
+ width: 320,
4
+ height: 240,
5
+ format: 'webp',
6
+ quality: 0.8,
7
+ fit: 'contain',
8
+ background: '#f1f5f9',
9
+ timeoutMs: 30000,
10
+ });
11
+ const resolveInteger = (value, fallback, label) => {
12
+ const resolved = value !== null && value !== void 0 ? value : fallback;
13
+ if (!Number.isFinite(resolved) || resolved < 1 || resolved > 8192) {
14
+ throw new FileViewerThumbnailError('invalid-options', `${label} must be between 1 and 8192.`);
15
+ }
16
+ return Math.round(resolved);
17
+ };
18
+ export const resolveFileViewerThumbnailOptions = (options = {}, defaultTimeoutMs = DEFAULT_FILE_VIEWER_THUMBNAIL_OPTIONS.timeoutMs) => {
19
+ var _a, _b;
20
+ const format = options.format || DEFAULT_FILE_VIEWER_THUMBNAIL_OPTIONS.format;
21
+ const fit = options.fit || DEFAULT_FILE_VIEWER_THUMBNAIL_OPTIONS.fit;
22
+ const quality = (_a = options.quality) !== null && _a !== void 0 ? _a : DEFAULT_FILE_VIEWER_THUMBNAIL_OPTIONS.quality;
23
+ const timeoutMs = (_b = options.timeoutMs) !== null && _b !== void 0 ? _b : defaultTimeoutMs;
24
+ if (!['webp', 'jpeg', 'png'].includes(format)) {
25
+ throw new FileViewerThumbnailError('invalid-options', `Unsupported thumbnail format: ${format}`);
26
+ }
27
+ if (fit !== 'contain' && fit !== 'cover') {
28
+ throw new FileViewerThumbnailError('invalid-options', `Unsupported thumbnail fit: ${fit}`);
29
+ }
30
+ if (!Number.isFinite(quality) || quality < 0 || quality > 1) {
31
+ throw new FileViewerThumbnailError('invalid-options', 'quality must be between 0 and 1.');
32
+ }
33
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1) {
34
+ throw new FileViewerThumbnailError('invalid-options', 'timeoutMs must be greater than 0.');
35
+ }
36
+ return {
37
+ width: resolveInteger(options.width, DEFAULT_FILE_VIEWER_THUMBNAIL_OPTIONS.width, 'width'),
38
+ height: resolveInteger(options.height, DEFAULT_FILE_VIEWER_THUMBNAIL_OPTIONS.height, 'height'),
39
+ format,
40
+ quality,
41
+ fit,
42
+ background: options.background || DEFAULT_FILE_VIEWER_THUMBNAIL_OPTIONS.background,
43
+ timeoutMs,
44
+ signal: options.signal,
45
+ };
46
+ };
47
+ export const toCaptureOptions = (options) => ({
48
+ width: options.width,
49
+ height: options.height,
50
+ format: options.format,
51
+ quality: options.quality,
52
+ fit: options.fit,
53
+ background: options.background,
54
+ signal: options.signal,
55
+ });
56
+ export const resolveFileViewerThumbnailConcurrency = (value = 2) => {
57
+ if (!Number.isInteger(value) || value < 1 || value > 8) {
58
+ throw new FileViewerThumbnailError('invalid-options', 'concurrency must be an integer between 1 and 8.');
59
+ }
60
+ return value;
61
+ };
@@ -0,0 +1,65 @@
1
+ import type { FileViewerOptions, FileViewerSource, FileViewerThumbnailFit, FileViewerThumbnailFormat } from '@file-viewer/core';
2
+ export type FileViewerThumbnailErrorCode = 'browser-required' | 'unsupported' | 'timeout' | 'aborted' | 'capture-failed' | 'capture-unavailable' | 'tainted-canvas' | 'empty-output' | 'destroyed' | 'invalid-options';
3
+ export declare class FileViewerThumbnailError extends Error {
4
+ readonly code: FileViewerThumbnailErrorCode;
5
+ readonly cause?: unknown;
6
+ constructor(code: FileViewerThumbnailErrorCode, message: string, cause?: unknown);
7
+ }
8
+ export interface FileViewerThumbnailOptions {
9
+ width?: number;
10
+ height?: number;
11
+ format?: FileViewerThumbnailFormat;
12
+ quality?: number;
13
+ fit?: FileViewerThumbnailFit;
14
+ background?: string;
15
+ timeoutMs?: number;
16
+ signal?: AbortSignal;
17
+ }
18
+ export interface ResolvedFileViewerThumbnailOptions {
19
+ width: number;
20
+ height: number;
21
+ format: FileViewerThumbnailFormat;
22
+ quality: number;
23
+ fit: FileViewerThumbnailFit;
24
+ background: string;
25
+ timeoutMs: number;
26
+ signal?: AbortSignal;
27
+ }
28
+ export type FileViewerThumbnailStrategy = 'embedded' | 'provider-native' | 'provider-dom' | 'dom-fallback';
29
+ export interface FileViewerThumbnailResult {
30
+ blob: Blob;
31
+ width: number;
32
+ height: number;
33
+ mimeType: string;
34
+ filename: string;
35
+ extension: string;
36
+ rendererId?: string;
37
+ strategy: FileViewerThumbnailStrategy;
38
+ degraded: boolean;
39
+ durationMs: number;
40
+ }
41
+ export interface FileViewerThumbnailBatchSuccess {
42
+ ok: true;
43
+ index: number;
44
+ source: FileViewerSource;
45
+ result: FileViewerThumbnailResult;
46
+ }
47
+ export interface FileViewerThumbnailBatchFailure {
48
+ ok: false;
49
+ index: number;
50
+ source: FileViewerSource;
51
+ error: FileViewerThumbnailError;
52
+ }
53
+ export type FileViewerThumbnailBatchItem = FileViewerThumbnailBatchSuccess | FileViewerThumbnailBatchFailure;
54
+ export interface CreateFileViewerThumbnailGeneratorOptions {
55
+ viewerOptions?: FileViewerOptions;
56
+ concurrency?: number;
57
+ timeoutMs?: number;
58
+ document?: Document;
59
+ }
60
+ export interface FileViewerThumbnailGenerator {
61
+ generate(source: FileViewerSource, options?: FileViewerThumbnailOptions): Promise<FileViewerThumbnailResult>;
62
+ generateBatch(sources: readonly FileViewerSource[], options?: FileViewerThumbnailOptions): Promise<FileViewerThumbnailBatchItem[]>;
63
+ generateStream(sources: readonly FileViewerSource[], options?: FileViewerThumbnailOptions): AsyncIterable<FileViewerThumbnailBatchItem>;
64
+ destroy(): Promise<void>;
65
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ export class FileViewerThumbnailError extends Error {
2
+ constructor(code, message, cause) {
3
+ super(message);
4
+ this.name = 'FileViewerThumbnailError';
5
+ this.code = code;
6
+ this.cause = cause;
7
+ }
8
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@file-viewer/thumbnail",
3
+ "version": "2.1.26",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "High-performance browser thumbnail generation for Flyfish File Viewer.",
7
+ "keywords": [
8
+ "file-viewer",
9
+ "thumbnail",
10
+ "preview",
11
+ "batch",
12
+ "browser"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public",
16
+ "registry": "https://registry.npmjs.org/"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/flyfish-dev/file-viewer.git",
21
+ "directory": "packages/thumbnail"
22
+ },
23
+ "main": "./dist/index.js",
24
+ "module": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "README.en.md",
38
+ "LICENSE"
39
+ ],
40
+ "dependencies": {
41
+ "@file-viewer/core": "2.1.26",
42
+ "epubjs": "^0.3.93",
43
+ "jszip": "^3.10.1",
44
+ "modern-screenshot": "^4.7.0"
45
+ },
46
+ "devDependencies": {
47
+ "happy-dom": "^20.8.4",
48
+ "typescript": "^6.0.3",
49
+ "vitest": "3.2.4"
50
+ },
51
+ "license": "Apache-2.0",
52
+ "scripts": {
53
+ "build": "node scripts/clean-dist.mjs && tsc -b tsconfig.json --force",
54
+ "type-check": "tsc -b tsconfig.json && tsc -p tsconfig.test.json --noEmit",
55
+ "test": "vitest run"
56
+ }
57
+ }