@file-viewer/renderer-image 2.3.0 → 2.4.0

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/README.en.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Standalone image renderer package for Flyfish File Viewer. It handles browser-side preview, lightbox viewing, and unified zoom for `png`, `jpg`, `webp`, `avif`, `gif`, `svg`, `tiff`, `ico`, `heic`, `heif`, `jxl`, and related image files.
4
4
 
5
+ TIFF opens the MIT-licensed UTIF.js decoder only after the format is selected. It supports CCITT Group 4 and vertically stacked multipage previews with group zoom, rotation, fit-width behavior, page status, and double-click or keyboard lightbox access. Source bytes, page count, dimensions, per-page pixels, and cumulative decoded pixels are bounded; page RGBA buffers are released after progressive conversion. Native PNG/JPEG/WEBP paths do not load the TIFF decoder.
6
+
5
7
  ## Usage
6
8
 
7
9
  ```ts
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Flyfish File Viewer 的独立图片 renderer 包。它负责 `png`、`jpg`、`webp`、`avif`、`gif`、`svg`、`tiff`、`ico`、`heic`、`heif`、`jxl` 等图片类文件的浏览器端预览、灯箱查看和统一缩放。
4
4
 
5
+ TIFF 会在命中格式后按需加载 MIT 许可的 UTIF.js,支持 CCITT Group 4 和多页纵向预览,并提供整组缩放、旋转、适宽、页码状态与双击/键盘放大。解码前会限制源文件大小、页数、单页尺寸和累计像素,逐页转换后立即释放 RGBA 缓冲;普通 PNG/JPEG/WEBP 等路径不加载 TIFF 解码器。
6
+
5
7
  ## 使用
6
8
 
7
9
  ```ts
@@ -0,0 +1,4 @@
1
+ # Third-party notices
2
+
3
+ - `UTIF.js` is loaded only when a TIFF file is opened. It is MIT licensed; File Viewer bounds encoded bytes, page count, dimensions, per-page pixels, and cumulative decoded pixels before conversion.
4
+ - `heic2any` remains scoped to HEIC/HEIF conversion and is not loaded for ordinary browser-native images.
package/dist/image.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type FileRenderContext, type FileViewerRenderedInstance } from '@file-viewer/core';
2
+ export declare const hasTiffSignature: (buffer: ArrayBuffer) => boolean;
2
3
  export declare const normalizeImageRotation: (rotation: number) => number;
3
4
  export declare const resolveRotatedImageSize: (naturalWidth: number, naturalHeight: number, scale: number, rotation: number) => {
4
5
  imageWidth: number;
package/dist/image.js CHANGED
@@ -83,6 +83,13 @@ const resolveImageUrl = async (buffer, type) => {
83
83
  }
84
84
  return readBlobDataUrl(new Blob([buffer], { type: getImageBlobType(normalizedType) }));
85
85
  };
86
+ export const hasTiffSignature = (buffer) => {
87
+ if (buffer.byteLength < 4)
88
+ return false;
89
+ const bytes = new Uint8Array(buffer, 0, 4);
90
+ return ((bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
91
+ (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a));
92
+ };
86
93
  const waitForImageReady = async (image) => {
87
94
  if (image.complete) {
88
95
  if (image.naturalWidth > 0 && image.naturalHeight > 0)
@@ -210,6 +217,11 @@ const createLightbox = (documentRef, src, t) => {
210
217
  };
211
218
  export default async function renderImage(buffer, target, type, context) {
212
219
  var _a, _b;
220
+ const normalizedType = (type || '').trim().toLowerCase();
221
+ if ((normalizedType === 'tif' || normalizedType === 'tiff') && hasTiffSignature(buffer)) {
222
+ const { renderTiff } = await import('./tiff.js');
223
+ return renderTiff(buffer, target, context);
224
+ }
213
225
  const t = createFileViewerTranslator(context === null || context === void 0 ? void 0 : context.options);
214
226
  const documentRef = target.ownerDocument || document;
215
227
  const src = await resolveImageUrl(buffer, type);
@@ -254,7 +266,6 @@ export default async function renderImage(buffer, target, type, context) {
254
266
  image.tabIndex = 0;
255
267
  image.setAttribute('role', 'button');
256
268
  image.setAttribute('aria-haspopup', 'dialog');
257
- const normalizedType = (type || '').trim().toLowerCase();
258
269
  (_a = context === null || context === void 0 ? void 0 : context.registerThumbnailAdapter) === null || _a === void 0 ? void 0 : _a.call(context, {
259
270
  capture: () => normalizedType === 'heic' || normalizedType === 'heif'
260
271
  ? null
package/dist/tiff.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { type FileRenderContext, type FileViewerRenderedInstance } from '@file-viewer/core';
2
+ type TiffIfd = Record<string, unknown>;
3
+ type UtifModule = {
4
+ decode(buffer: ArrayBuffer): TiffIfd[];
5
+ decodeImage(buffer: ArrayBuffer, ifd: TiffIfd): void;
6
+ toRGBA8(ifd: TiffIfd): Uint8Array;
7
+ };
8
+ export declare const validateTiffPageSafety: (ifds: TiffIfd[]) => number;
9
+ export declare function renderTiff(buffer: ArrayBuffer, target: HTMLDivElement, context?: FileRenderContext): Promise<FileViewerRenderedInstance>;
10
+ export declare function renderTiffWithDecoder(buffer: ArrayBuffer, target: HTMLDivElement, utif: UtifModule, context?: FileRenderContext): Promise<FileViewerRenderedInstance>;
11
+ export {};
package/dist/tiff.js ADDED
@@ -0,0 +1,481 @@
1
+ import { createFileViewerTranslator, createFileViewerViewStateChange, createFileViewerViewStateChangeEmitter, createFileViewerZoomChangeEmitter, registerFileViewerViewStateProvider, registerFileViewerZoomProvider, resolveFileViewerFitScale, unregisterFileViewerViewStateProvider, unregisterFileViewerZoomProvider, } from '@file-viewer/core';
2
+ const MAX_TIFF_SOURCE_BYTES = 32 * 1024 * 1024;
3
+ const MAX_TIFF_PAGES = 64;
4
+ const MAX_TIFF_DIMENSION = 16384;
5
+ const MAX_TIFF_PAGE_PIXELS = 32000000;
6
+ const MAX_TIFF_TOTAL_PIXELS = 128000000;
7
+ const PAGE_GAP = 24;
8
+ const tiffStyle = `
9
+ .image-viewer.tiff-viewer{position:relative;width:100%;height:100%;overflow:auto;background:var(--file-viewer-render-surface-background,#eef1f4);box-sizing:border-box}
10
+ .tiff-toolbar{position:sticky;top:12px;z-index:5;display:flex;align-items:center;gap:6px;width:max-content;height:42px;margin:12px 12px -54px auto;padding:5px 7px;border:1px solid rgba(148,163,184,.34);border-radius:10px;background:rgba(255,255,255,.92);box-shadow:0 10px 28px rgba(15,23,42,.14);backdrop-filter:blur(12px);box-sizing:border-box}
11
+ .tiff-toolbar button{display:grid;width:30px;height:30px;place-items:center;padding:0;border:1px solid transparent;border-radius:7px;background:transparent;color:#334155;font:600 19px/1 system-ui,sans-serif;cursor:pointer;box-sizing:border-box}
12
+ .tiff-toolbar button:hover{border-color:#bfd2ea;background:#edf5ff;color:#1769d8}
13
+ .tiff-toolbar button:focus-visible{outline:3px solid #60a5fa;outline-offset:1px}
14
+ .tiff-page-meter,.tiff-rotation-meter{min-width:38px;color:#64748b;font:700 12px/1 system-ui,sans-serif;text-align:center}
15
+ .tiff-page-meter{min-width:52px;padding-right:6px;border-right:1px solid rgba(148,163,184,.35)}
16
+ .tiff-stage{min-width:100%;display:flex;flex-direction:column;align-items:center;gap:${PAGE_GAP}px;padding:70px 24px 24px;box-sizing:border-box}
17
+ .tiff-frame{position:relative;flex:0 0 auto;background:#fff;box-shadow:0 18px 48px rgba(15,23,42,.16)}
18
+ .tiff-frame img{position:absolute;top:50%;left:50%;display:block;width:auto;max-width:none;margin:0;border:0;background:#fff;cursor:zoom-in;transform:translate(-50%,-50%) rotate(var(--image-rotation,0deg));transform-origin:center center;transition:transform .18s ease}
19
+ .tiff-frame img:focus-visible{outline:3px solid #2563eb;outline-offset:4px}
20
+ .tiff-lightbox{position:absolute;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:40px;background:rgba(15,23,42,.9);box-sizing:border-box;opacity:0;visibility:hidden;pointer-events:none;transition:opacity .18s ease,visibility 0s linear .18s}
21
+ .tiff-lightbox[data-open='true']{opacity:1;visibility:visible;pointer-events:auto;transition-delay:0s}
22
+ .tiff-lightbox img{display:block;max-width:100%;max-height:100%;object-fit:contain;background:#fff;box-shadow:0 30px 80px rgba(0,0,0,.4);transform:rotate(var(--image-rotation,0deg)) scale(.985);transition:transform .18s ease}
23
+ .tiff-lightbox[data-open='true'] img{transform:rotate(var(--image-rotation,0deg)) scale(1)}
24
+ .tiff-lightbox button{position:absolute;top:16px;right:16px;display:grid;width:40px;height:40px;place-items:center;padding:0;border:1px solid rgba(255,255,255,.7);border-radius:999px;background:rgba(255,255,255,.96);color:#172033;font:400 27px/1 Arial,sans-serif;cursor:pointer;box-shadow:0 12px 28px rgba(0,0,0,.24)}
25
+ .tiff-lightbox button:focus-visible{outline:3px solid #60a5fa;outline-offset:2px}
26
+ [data-viewer-theme='dark'] .tiff-viewer{background:var(--file-viewer-render-surface-background,#101820)}
27
+ [data-viewer-theme='dark'] .tiff-toolbar{border-color:rgba(148,163,184,.28);background:rgba(17,24,39,.88)}
28
+ [data-viewer-theme='dark'] .tiff-toolbar button{color:#dbe5f2}
29
+ [data-viewer-theme='dark'] .tiff-page-meter,[data-viewer-theme='dark'] .tiff-rotation-meter{color:#b7c5d7}
30
+ @media (max-width:767px){.tiff-toolbar{top:8px;margin:8px 8px -50px auto}.tiff-stage{padding:62px 12px 12px;gap:12px}.tiff-lightbox{padding:16px}.tiff-lightbox button{top:12px;right:12px}}
31
+ @media (prefers-reduced-motion:reduce){.tiff-frame img,.tiff-lightbox,.tiff-lightbox img{transition:none}}
32
+ `;
33
+ const normalizeRotation = (rotation) => ((Math.round(rotation / 90) * 90) % 360 + 360) % 360;
34
+ const getDimension = (ifd, tag, fallback) => {
35
+ const tagged = ifd[tag];
36
+ const value = Array.isArray(tagged) ? tagged[0] : tagged;
37
+ const resolved = Number(value !== null && value !== void 0 ? value : ifd[fallback]);
38
+ return Number.isFinite(resolved) ? resolved : 0;
39
+ };
40
+ const assertPageDimensions = (width, height, pageNumber) => {
41
+ if (!Number.isInteger(width) ||
42
+ !Number.isInteger(height) ||
43
+ width <= 0 ||
44
+ height <= 0 ||
45
+ width > MAX_TIFF_DIMENSION ||
46
+ height > MAX_TIFF_DIMENSION ||
47
+ width * height > MAX_TIFF_PAGE_PIXELS) {
48
+ throw new Error(`TIFF page ${pageNumber} exceeds the image safety limit.`);
49
+ }
50
+ };
51
+ export const validateTiffPageSafety = (ifds) => {
52
+ if (!ifds.length) {
53
+ throw new Error('TIFF does not contain a decodable page.');
54
+ }
55
+ if (ifds.length > MAX_TIFF_PAGES) {
56
+ throw new Error(`TIFF contains more than ${MAX_TIFF_PAGES} pages.`);
57
+ }
58
+ let totalPixels = 0;
59
+ for (let index = 0; index < ifds.length; index += 1) {
60
+ const width = getDimension(ifds[index], 't256', 'width');
61
+ const height = getDimension(ifds[index], 't257', 'height');
62
+ assertPageDimensions(width, height, index + 1);
63
+ totalPixels += width * height;
64
+ if (totalPixels > MAX_TIFF_TOTAL_PIXELS) {
65
+ throw new Error('TIFF cumulative decoded pixels exceed the image safety limit.');
66
+ }
67
+ }
68
+ return ifds.length;
69
+ };
70
+ const canvasToPng = async (documentRef, rgba, width, height) => {
71
+ const canvas = documentRef.createElement('canvas');
72
+ canvas.width = width;
73
+ canvas.height = height;
74
+ const context = canvas.getContext('2d');
75
+ if (!context) {
76
+ throw new Error('Canvas 2D is unavailable for TIFF conversion.');
77
+ }
78
+ const imageData = context.createImageData(width, height);
79
+ imageData.data.set(rgba);
80
+ context.putImageData(imageData, 0, 0);
81
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
82
+ canvas.width = 1;
83
+ canvas.height = 1;
84
+ if (!blob) {
85
+ throw new Error('Unable to encode the TIFF page as PNG.');
86
+ }
87
+ return blob;
88
+ };
89
+ const waitForImage = async (image) => {
90
+ if (image.complete && image.naturalWidth > 0)
91
+ return;
92
+ await new Promise((resolve, reject) => {
93
+ image.addEventListener('load', () => resolve(), { once: true });
94
+ image.addEventListener('error', () => reject(new Error('Unable to display the decoded TIFF page.')), { once: true });
95
+ });
96
+ };
97
+ const nextPaint = async (windowRef) => {
98
+ if (!(windowRef === null || windowRef === void 0 ? void 0 : windowRef.requestAnimationFrame))
99
+ return;
100
+ await new Promise(resolve => windowRef.requestAnimationFrame(() => resolve()));
101
+ };
102
+ const createLightbox = (documentRef, t) => {
103
+ const element = documentRef.createElement('div');
104
+ element.className = 'tiff-lightbox';
105
+ element.dataset.open = 'false';
106
+ element.setAttribute('role', 'dialog');
107
+ element.setAttribute('aria-modal', 'true');
108
+ element.setAttribute('aria-hidden', 'true');
109
+ const image = documentRef.createElement('img');
110
+ image.alt = t('image.lightbox.alt');
111
+ const closeButton = documentRef.createElement('button');
112
+ closeButton.type = 'button';
113
+ closeButton.setAttribute('aria-label', t('image.lightbox.close'));
114
+ closeButton.textContent = '×';
115
+ let previousFocus = null;
116
+ const close = () => {
117
+ if (element.dataset.open !== 'true')
118
+ return;
119
+ element.dataset.open = 'false';
120
+ element.setAttribute('aria-hidden', 'true');
121
+ previousFocus === null || previousFocus === void 0 ? void 0 : previousFocus.focus({ preventScroll: true });
122
+ previousFocus = null;
123
+ };
124
+ const onKeyDown = (event) => {
125
+ if (event.key === 'Escape') {
126
+ event.preventDefault();
127
+ close();
128
+ }
129
+ };
130
+ closeButton.addEventListener('click', close);
131
+ element.addEventListener('click', event => {
132
+ if (event.target === element)
133
+ close();
134
+ });
135
+ documentRef.addEventListener('keydown', onKeyDown);
136
+ element.append(image, closeButton);
137
+ return {
138
+ element,
139
+ open(page, rotation) {
140
+ previousFocus = page.image;
141
+ image.src = page.objectUrl;
142
+ image.style.setProperty('--image-rotation', `${normalizeRotation(rotation)}deg`);
143
+ element.dataset.open = 'true';
144
+ element.setAttribute('aria-hidden', 'false');
145
+ closeButton.focus({ preventScroll: true });
146
+ },
147
+ setRotation(rotation) {
148
+ image.style.setProperty('--image-rotation', `${normalizeRotation(rotation)}deg`);
149
+ },
150
+ destroy() {
151
+ closeButton.removeEventListener('click', close);
152
+ documentRef.removeEventListener('keydown', onKeyDown);
153
+ element.remove();
154
+ },
155
+ };
156
+ };
157
+ const assertTiffSourceSafety = (buffer) => {
158
+ if (buffer.byteLength <= 0 || buffer.byteLength > MAX_TIFF_SOURCE_BYTES) {
159
+ throw new Error('TIFF source exceeds the image safety limit.');
160
+ }
161
+ };
162
+ export async function renderTiff(buffer, target, context) {
163
+ assertTiffSourceSafety(buffer);
164
+ const imported = await import('utif');
165
+ return renderTiffWithDecoder(buffer, target, imported.default || imported, context);
166
+ }
167
+ export async function renderTiffWithDecoder(buffer, target, utif, context) {
168
+ var _a, _b;
169
+ assertTiffSourceSafety(buffer);
170
+ const t = createFileViewerTranslator(context === null || context === void 0 ? void 0 : context.options);
171
+ const documentRef = target.ownerDocument || document;
172
+ const windowRef = documentRef.defaultView;
173
+ const urlApi = (windowRef === null || windowRef === void 0 ? void 0 : windowRef.URL) || URL;
174
+ const ifds = utif.decode(buffer);
175
+ const pageCount = validateTiffPageSafety(ifds);
176
+ let destroyed = false;
177
+ let currentRotation = 0;
178
+ let fitScale = 1;
179
+ let userZoom = 1;
180
+ let currentScale = 1;
181
+ let currentPage = 1;
182
+ let scrollFrame = 0;
183
+ const pages = [];
184
+ const zoomEmitter = createFileViewerZoomChangeEmitter();
185
+ const viewStateEmitter = createFileViewerViewStateChangeEmitter();
186
+ const style = documentRef.createElement('style');
187
+ style.textContent = tiffStyle;
188
+ const root = documentRef.createElement('div');
189
+ root.className = 'image-viewer tiff-viewer';
190
+ root.dataset.viewerZoomProvider = 'image';
191
+ root.dataset.tiffPageCount = String(pageCount);
192
+ const toolbar = documentRef.createElement('div');
193
+ toolbar.className = 'tiff-toolbar';
194
+ toolbar.setAttribute('role', 'toolbar');
195
+ toolbar.setAttribute('aria-label', t('image.toolbar.rotation'));
196
+ const pageMeter = documentRef.createElement('span');
197
+ pageMeter.className = 'tiff-page-meter';
198
+ pageMeter.setAttribute('aria-live', 'polite');
199
+ pageMeter.textContent = `1 / ${pageCount}`;
200
+ const createButton = (label, icon) => {
201
+ const button = documentRef.createElement('button');
202
+ button.type = 'button';
203
+ button.title = label;
204
+ button.setAttribute('aria-label', label);
205
+ button.textContent = icon;
206
+ return button;
207
+ };
208
+ const rotateLeftButton = createButton(t('image.toolbar.rotateLeft'), '↺');
209
+ const rotationMeter = documentRef.createElement('span');
210
+ rotationMeter.className = 'tiff-rotation-meter';
211
+ rotationMeter.textContent = '0°';
212
+ const rotateRightButton = createButton(t('image.toolbar.rotateRight'), '↻');
213
+ toolbar.append(pageMeter, rotateLeftButton, rotationMeter, rotateRightButton);
214
+ const stage = documentRef.createElement('div');
215
+ stage.className = 'tiff-stage';
216
+ root.append(toolbar, stage);
217
+ const lightbox = createLightbox(documentRef, t);
218
+ target.replaceChildren(style, root);
219
+ (((_a = context === null || context === void 0 ? void 0 : context.surface) === null || _a === void 0 ? void 0 : _a.shadowRoot) || target).append(lightbox.element);
220
+ const pageExtents = () => pages.map(page => currentRotation % 180 === 0
221
+ ? { width: page.width, height: page.height }
222
+ : { width: page.height, height: page.width });
223
+ const contentSize = () => {
224
+ const extents = pageExtents();
225
+ return {
226
+ width: Math.max(1, ...extents.map(item => item.width)),
227
+ height: Math.max(1, extents.reduce((sum, item) => sum + item.height, 0) + PAGE_GAP * Math.max(0, extents.length - 1)),
228
+ };
229
+ };
230
+ const getMinScale = () => Math.min(0.1, fitScale || 0.1);
231
+ const clampScale = (value) => Math.min(5, Math.max(getMinScale(), Number(value.toFixed(3))));
232
+ const computeFitScale = () => {
233
+ const size = contentSize();
234
+ return Math.min(1, Math.max(1, root.clientWidth - 48) / size.width);
235
+ };
236
+ const applyLayout = () => {
237
+ fitScale = computeFitScale();
238
+ currentScale = clampScale(fitScale * userZoom);
239
+ pages.forEach(page => {
240
+ const swapsAxes = currentRotation % 180 !== 0;
241
+ const imageWidth = Math.max(1, Math.round(page.width * currentScale));
242
+ const imageHeight = Math.max(1, Math.round(page.height * currentScale));
243
+ page.image.style.width = `${imageWidth}px`;
244
+ page.image.style.height = `${imageHeight}px`;
245
+ page.image.style.setProperty('--image-rotation', `${currentRotation}deg`);
246
+ page.frame.style.width = `${swapsAxes ? imageHeight : imageWidth}px`;
247
+ page.frame.style.height = `${swapsAxes ? imageWidth : imageHeight}px`;
248
+ });
249
+ };
250
+ const getZoomState = () => ({
251
+ scale: currentScale,
252
+ label: `${Math.round(currentScale * 100)}%`,
253
+ canZoomIn: currentScale < 5,
254
+ canZoomOut: currentScale > getMinScale(),
255
+ canReset: Math.abs(userZoom - 1) > 0.001,
256
+ minScale: getMinScale(),
257
+ maxScale: 5,
258
+ });
259
+ const readScrollState = () => {
260
+ const maxTop = Math.max(0, root.scrollHeight - root.clientHeight);
261
+ const maxLeft = Math.max(0, root.scrollWidth - root.clientWidth);
262
+ return {
263
+ top: root.scrollTop,
264
+ left: root.scrollLeft,
265
+ width: root.scrollWidth,
266
+ height: root.scrollHeight,
267
+ clientWidth: root.clientWidth,
268
+ clientHeight: root.clientHeight,
269
+ topRatio: maxTop ? root.scrollTop / maxTop : 0,
270
+ leftRatio: maxLeft ? root.scrollLeft / maxLeft : 0,
271
+ };
272
+ };
273
+ const getViewState = () => ({
274
+ renderer: 'image',
275
+ page: currentPage,
276
+ scale: currentScale,
277
+ zoom: getZoomState(),
278
+ rotation: currentRotation,
279
+ scroll: readScrollState(),
280
+ });
281
+ const emitViewState = (action, source = 'viewer') => {
282
+ const state = getViewState();
283
+ if (!destroyed)
284
+ viewStateEmitter.emit(createFileViewerViewStateChange(state, { action, source }));
285
+ return state;
286
+ };
287
+ const setZoom = (scale, action = 'zoom-change', source = 'api', notify = true) => {
288
+ userZoom = clampScale(scale) / Math.max(fitScale, 0.001);
289
+ applyLayout();
290
+ zoomEmitter.emit();
291
+ if (notify)
292
+ emitViewState(action, source);
293
+ return getZoomState();
294
+ };
295
+ const applyRotation = (rotation, action, source, notify = true) => {
296
+ currentRotation = normalizeRotation(rotation);
297
+ rotationMeter.textContent = `${currentRotation}°`;
298
+ lightbox.setRotation(currentRotation);
299
+ applyLayout();
300
+ zoomEmitter.emit();
301
+ return notify ? emitViewState(action, source) : getViewState();
302
+ };
303
+ const fit = (request) => {
304
+ var _a, _b;
305
+ if (!pages.length) {
306
+ return { applied: false, mode: request.mode, resize: request.resize, source: request.source, reason: 'image-not-ready', provider: 'zoom' };
307
+ }
308
+ const size = contentSize();
309
+ const mode = request.mode === 'auto' ? 'width' : request.mode;
310
+ const scale = resolveFileViewerFitScale({
311
+ mode,
312
+ viewportWidth: Math.max(1, request.viewportWidth || root.clientWidth),
313
+ viewportHeight: Math.max(1, request.viewportHeight || root.clientHeight),
314
+ contentWidth: size.width,
315
+ contentHeight: size.height,
316
+ currentScale,
317
+ minScale: (_a = request.minScale) !== null && _a !== void 0 ? _a : getMinScale(),
318
+ maxScale: (_b = request.maxScale) !== null && _b !== void 0 ? _b : 5,
319
+ });
320
+ if (!scale) {
321
+ return { applied: false, mode: request.mode, resize: request.resize, source: request.source, reason: 'unmeasurable', provider: 'zoom' };
322
+ }
323
+ setZoom(scale, 'fit', request.source);
324
+ return { applied: true, mode: request.mode, resize: request.resize, scale: currentScale, source: request.source, provider: 'zoom' };
325
+ };
326
+ const updateCurrentPage = () => {
327
+ if (!pages.length)
328
+ return;
329
+ const viewportCenter = root.getBoundingClientRect().top + root.clientHeight / 2;
330
+ let bestIndex = 0;
331
+ let bestDistance = Number.POSITIVE_INFINITY;
332
+ pages.forEach((page, index) => {
333
+ const rect = page.frame.getBoundingClientRect();
334
+ const distance = Math.abs(rect.top + rect.height / 2 - viewportCenter);
335
+ if (distance < bestDistance) {
336
+ bestDistance = distance;
337
+ bestIndex = index;
338
+ }
339
+ });
340
+ const nextPage = bestIndex + 1;
341
+ if (nextPage !== currentPage) {
342
+ currentPage = nextPage;
343
+ pageMeter.textContent = `${currentPage} / ${pageCount}`;
344
+ }
345
+ };
346
+ const onScroll = () => {
347
+ if (scrollFrame || destroyed)
348
+ return;
349
+ scrollFrame = (windowRef === null || windowRef === void 0 ? void 0 : windowRef.requestAnimationFrame(() => {
350
+ scrollFrame = 0;
351
+ updateCurrentPage();
352
+ emitViewState('scroll', 'viewer');
353
+ })) || 0;
354
+ };
355
+ const resizeObserver = new ResizeObserver(() => {
356
+ applyLayout();
357
+ updateCurrentPage();
358
+ zoomEmitter.emit();
359
+ });
360
+ resizeObserver.observe(root);
361
+ registerFileViewerZoomProvider(root, {
362
+ zoomIn: () => setZoom(currentScale + 0.15, 'zoom-in', 'api'),
363
+ zoomOut: () => setZoom(currentScale - 0.15, 'zoom-out', 'api'),
364
+ resetZoom: () => {
365
+ userZoom = 1;
366
+ applyLayout();
367
+ zoomEmitter.emit();
368
+ emitViewState('zoom-reset', 'api');
369
+ return getZoomState();
370
+ },
371
+ setZoom,
372
+ fit,
373
+ getState: getZoomState,
374
+ subscribe: zoomEmitter.subscribe,
375
+ });
376
+ registerFileViewerViewStateProvider(root, {
377
+ getState: getViewState,
378
+ fit,
379
+ subscribe: viewStateEmitter.subscribe,
380
+ async applyState(state, options = {}) {
381
+ var _a, _b, _c;
382
+ const source = options.source || 'api';
383
+ if (Number.isFinite(Number(state.rotation))) {
384
+ applyRotation(Number(state.rotation), 'rotation-change', source, false);
385
+ }
386
+ const scale = Number((_a = state.scale) !== null && _a !== void 0 ? _a : (_b = state.zoom) === null || _b === void 0 ? void 0 : _b.scale);
387
+ if (Number.isFinite(scale))
388
+ setZoom(scale, 'zoom-change', source, false);
389
+ const page = Math.min(pages.length, Math.max(1, Math.round(Number(state.page) || 1)));
390
+ (_c = pages[page - 1]) === null || _c === void 0 ? void 0 : _c.frame.scrollIntoView({ block: 'center', inline: 'center' });
391
+ currentPage = page;
392
+ pageMeter.textContent = `${currentPage} / ${pageCount}`;
393
+ return options.notify === false ? getViewState() : emitViewState(options.action || 'restore', source);
394
+ },
395
+ });
396
+ const rotateLeft = () => applyRotation(currentRotation - 90, 'rotate-left', 'user');
397
+ const rotateRight = () => applyRotation(currentRotation + 90, 'rotate-right', 'user');
398
+ rotateLeftButton.addEventListener('click', rotateLeft);
399
+ rotateRightButton.addEventListener('click', rotateRight);
400
+ root.addEventListener('scroll', onScroll, { passive: true });
401
+ const cleanup = () => {
402
+ var _a;
403
+ if (destroyed)
404
+ return;
405
+ destroyed = true;
406
+ (_a = context === null || context === void 0 ? void 0 : context.registerThumbnailAdapter) === null || _a === void 0 ? void 0 : _a.call(context, null);
407
+ unregisterFileViewerViewStateProvider(root);
408
+ unregisterFileViewerZoomProvider(root);
409
+ resizeObserver.disconnect();
410
+ if (scrollFrame && (windowRef === null || windowRef === void 0 ? void 0 : windowRef.cancelAnimationFrame))
411
+ windowRef.cancelAnimationFrame(scrollFrame);
412
+ root.removeEventListener('scroll', onScroll);
413
+ rotateLeftButton.removeEventListener('click', rotateLeft);
414
+ rotateRightButton.removeEventListener('click', rotateRight);
415
+ lightbox.destroy();
416
+ pages.forEach(page => urlApi.revokeObjectURL(page.objectUrl));
417
+ pages.length = 0;
418
+ target.replaceChildren();
419
+ };
420
+ try {
421
+ for (let index = 0; index < pageCount; index += 1) {
422
+ const ifd = ifds[index];
423
+ utif.decodeImage(buffer, ifd);
424
+ const width = getDimension(ifd, 't256', 'width');
425
+ const height = getDimension(ifd, 't257', 'height');
426
+ assertPageDimensions(width, height, index + 1);
427
+ let rgba = utif.toRGBA8(ifd);
428
+ if (rgba.byteLength !== width * height * 4) {
429
+ throw new Error(`TIFF page ${index + 1} returned an unexpected pixel buffer.`);
430
+ }
431
+ const blob = await canvasToPng(documentRef, rgba, width, height);
432
+ rgba = null;
433
+ for (const key of ['data', 'rgba', 'pixels']) {
434
+ if (key in ifd)
435
+ delete ifd[key];
436
+ }
437
+ const objectUrl = urlApi.createObjectURL(blob);
438
+ const frame = documentRef.createElement('div');
439
+ frame.className = 'tiff-frame';
440
+ frame.dataset.pageNumber = String(index + 1);
441
+ const image = documentRef.createElement('img');
442
+ image.src = objectUrl;
443
+ image.alt = `${t('image.alt')} ${index + 1}`;
444
+ image.tabIndex = 0;
445
+ image.setAttribute('role', 'button');
446
+ image.setAttribute('aria-haspopup', 'dialog');
447
+ frame.append(image);
448
+ stage.append(frame);
449
+ const page = { blob, frame, image, objectUrl, width, height };
450
+ pages.push(page);
451
+ const open = () => lightbox.open(page, currentRotation);
452
+ image.addEventListener('dblclick', open);
453
+ image.addEventListener('keydown', event => {
454
+ if (event.key === 'Enter' || event.key === ' ') {
455
+ event.preventDefault();
456
+ open();
457
+ }
458
+ });
459
+ await waitForImage(image);
460
+ if (index === 0) {
461
+ (_b = context === null || context === void 0 ? void 0 : context.registerThumbnailAdapter) === null || _b === void 0 ? void 0 : _b.call(context, {
462
+ capture: () => page.blob,
463
+ getTarget: () => page.image,
464
+ });
465
+ }
466
+ applyLayout();
467
+ await nextPaint(windowRef);
468
+ }
469
+ ifds.length = 0;
470
+ updateCurrentPage();
471
+ windowRef === null || windowRef === void 0 ? void 0 : windowRef.requestAnimationFrame(() => emitViewState('init', 'viewer'));
472
+ }
473
+ catch (error) {
474
+ cleanup();
475
+ throw error;
476
+ }
477
+ return {
478
+ $el: target,
479
+ unmount: cleanup,
480
+ };
481
+ }
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "@file-viewer/renderer-image",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "Standalone image renderer plugin for File Viewer with native image preview, HEIC/HEIF conversion, lightbox, and unified zoom support.",
6
+ "description": "Standalone image renderer plugin for File Viewer with native image preview, bounded multipage TIFF and HEIC/HEIF conversion, lightbox, and unified zoom support.",
7
7
  "keywords": [
8
8
  "file-viewer",
9
9
  "renderer",
10
10
  "image",
11
11
  "heic",
12
12
  "heif",
13
+ "tiff",
13
14
  "document-preview",
14
15
  "document-viewer",
15
16
  "file-preview",
@@ -51,11 +52,13 @@
51
52
  "dist",
52
53
  "README.md",
53
54
  "README.en.md",
55
+ "THIRD_PARTY_NOTICES.md",
54
56
  "LICENSE"
55
57
  ],
56
58
  "dependencies": {
57
- "@file-viewer/core": "2.3.0",
58
- "heic2any": "^0.0.4"
59
+ "@file-viewer/core": "2.4.0",
60
+ "heic2any": "^0.0.4",
61
+ "utif": "^3.1.0"
59
62
  },
60
63
  "devDependencies": {
61
64
  "typescript": "^6.0.3"