@file-viewer/renderer-dicom 3.0.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/dist/dicom.js ADDED
@@ -0,0 +1,525 @@
1
+ import { createFileViewerViewStateChange, createFileViewerViewStateChangeEmitter, createFileViewerZoomChangeEmitter, registerFileViewerViewStateProvider, registerFileViewerZoomProvider, unregisterFileViewerViewStateProvider, unregisterFileViewerZoomProvider, } from '@file-viewer/core';
2
+ import { cache, Enums, getWebWorkerManager, registerImageLoader, init as initCornerstone, isCornerstoneInitialized, TiledRenderingEngine, } from '@cornerstonejs/core';
3
+ import { wadouri } from '@cornerstonejs/dicom-image-loader';
4
+ import { Enums as MetadataEnums, metaData as cornerstoneMetadata, registerDefaultProviders, } from '@cornerstonejs/metadata';
5
+ import { inspectDicomPart10 } from './inspect.js';
6
+ const MIN_ZOOM = 0.1;
7
+ const MAX_ZOOM = 10;
8
+ const MAX_CODEC_WORKERS = 4;
9
+ const CODEC_WORKER_NAME = 'dicomImageLoader';
10
+ let dicomLoaderInitialized = false;
11
+ let renderingEngineSerial = 0;
12
+ const dicomStyle = `
13
+ .dicom-viewer{position:relative;display:flex;width:100%;height:100%;min-height:360px;flex-direction:column;overflow:hidden;background:#05080d;color:#e2e8f0;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-sizing:border-box}
14
+ .dicom-viewer *{box-sizing:border-box}
15
+ .dicom-toolbar{position:relative;z-index:3;display:flex;min-height:48px;align-items:center;gap:8px;padding:7px 10px;border-bottom:1px solid rgba(148,163,184,.22);background:rgba(15,23,42,.96);box-shadow:0 8px 24px rgba(0,0,0,.22)}
16
+ .dicom-tool-group{display:flex;align-items:center;gap:5px}
17
+ .dicom-toolbar button{display:grid;min-width:32px;height:32px;place-items:center;border:1px solid rgba(148,163,184,.26);border-radius:7px;padding:0 8px;background:#172033;color:#e5edf7;font:700 13px/1 system-ui,sans-serif;cursor:pointer}
18
+ .dicom-toolbar button:hover:not(:disabled){border-color:#60a5fa;background:#1e3a5f;color:#fff}
19
+ .dicom-toolbar button:focus-visible,.dicom-toolbar input:focus-visible,.dicom-stage:focus-visible{outline:2px solid #60a5fa;outline-offset:2px}
20
+ .dicom-toolbar button:disabled{cursor:not-allowed;opacity:.42}
21
+ .dicom-frame-meter,.dicom-zoom-meter{min-width:58px;color:#cbd5e1;font:700 12px/1 system-ui,sans-serif;text-align:center;white-space:nowrap}
22
+ .dicom-window-controls{display:flex;align-items:center;gap:7px;margin-left:auto}
23
+ .dicom-window-controls label{display:flex;align-items:center;gap:4px;color:#94a3b8;font:700 11px/1 system-ui,sans-serif}
24
+ .dicom-window-controls input{width:76px;height:30px;border:1px solid rgba(148,163,184,.3);border-radius:6px;padding:0 7px;background:#0b1220;color:#f8fafc;font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace}
25
+ .dicom-body{position:relative;min-height:0;flex:1;overflow:hidden;background:#05080d}
26
+ .dicom-stage{position:absolute;inset:0;overflow:hidden;touch-action:none;cursor:grab;outline:none;user-select:none;-webkit-user-select:none}
27
+ .dicom-stage.is-panning{cursor:grabbing}
28
+ .dicom-stage canvas{display:block}
29
+ .dicom-meta{position:absolute;z-index:2;left:10px;bottom:10px;max-width:calc(100% - 20px);border:1px solid rgba(148,163,184,.22);border-radius:7px;padding:6px 8px;background:rgba(2,6,23,.72);color:#cbd5e1;font:600 11px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace;pointer-events:none;backdrop-filter:blur(8px)}
30
+ .dicom-state{position:absolute;inset:0;z-index:4;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(5,8,13,.92);color:#cbd5e1;font-size:13px;font-weight:700;text-align:center}
31
+ .dicom-state[hidden]{display:none}
32
+ .dicom-state.error{color:#fca5a5}
33
+ @media(max-width:720px){.dicom-toolbar{align-items:flex-start;flex-wrap:wrap}.dicom-window-controls{width:100%;margin-left:0}.dicom-window-controls label{flex:1}.dicom-window-controls input{width:100%;min-width:0}.dicom-body{min-height:300px}}
34
+ `;
35
+ const copyByLocale = {
36
+ 'de-DE': {
37
+ fit: 'Ansicht einpassen', loading: 'DICOM wird geladen ...', next: 'Nächstes Bild', previous: 'Vorheriges Bild',
38
+ rotateLeft: 'Nach links drehen', rotateRight: 'Nach rechts drehen', windowCenter: 'Fenstermitte',
39
+ windowWidth: 'Fensterbreite', zoomIn: 'Vergrößern', zoomOut: 'Verkleinern',
40
+ },
41
+ 'en-US': {
42
+ fit: 'Fit to view', loading: 'Loading DICOM ...', next: 'Next frame', previous: 'Previous frame',
43
+ rotateLeft: 'Rotate left', rotateRight: 'Rotate right', windowCenter: 'Window center',
44
+ windowWidth: 'Window width', zoomIn: 'Zoom in', zoomOut: 'Zoom out',
45
+ },
46
+ 'ja-JP': {
47
+ fit: '画面に合わせる', loading: 'DICOM を読み込み中 ...', next: '次のフレーム', previous: '前のフレーム',
48
+ rotateLeft: '左に回転', rotateRight: '右に回転', windowCenter: 'ウィンドウ中心',
49
+ windowWidth: 'ウィンドウ幅', zoomIn: '拡大', zoomOut: '縮小',
50
+ },
51
+ 'zh-CN': {
52
+ fit: '适合视图', loading: '正在加载 DICOM ...', next: '下一帧', previous: '上一帧',
53
+ rotateLeft: '向左旋转', rotateRight: '向右旋转', windowCenter: '窗位',
54
+ windowWidth: '窗宽', zoomIn: '放大', zoomOut: '缩小',
55
+ },
56
+ };
57
+ const resolveCopy = (context) => {
58
+ var _a, _b, _c, _d;
59
+ const requested = (_a = context === null || context === void 0 ? void 0 : context.options) === null || _a === void 0 ? void 0 : _a.locale;
60
+ if (requested && requested !== 'auto' && requested in copyByLocale) {
61
+ return copyByLocale[requested];
62
+ }
63
+ const language = ((_d = (_c = (_b = context === null || context === void 0 ? void 0 : context.surface) === null || _b === void 0 ? void 0 : _b.container) === null || _c === void 0 ? void 0 : _c.ownerDocument.defaultView) === null || _d === void 0 ? void 0 : _d.navigator.language) ||
64
+ (typeof navigator !== 'undefined' ? navigator.language : 'en-US');
65
+ if (language.toLowerCase().startsWith('zh'))
66
+ return copyByLocale['zh-CN'];
67
+ if (language.toLowerCase().startsWith('ja'))
68
+ return copyByLocale['ja-JP'];
69
+ if (language.toLowerCase().startsWith('de'))
70
+ return copyByLocale['de-DE'];
71
+ return copyByLocale['en-US'];
72
+ };
73
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
74
+ const finitePositiveInteger = (value, label) => {
75
+ const number = typeof value === 'number' ? value : Number.parseInt(String(value || ''), 10);
76
+ if (!Number.isSafeInteger(number) || number < 1) {
77
+ throw new Error(`DICOM ${label} must be a positive integer.`);
78
+ }
79
+ return number;
80
+ };
81
+ const finiteNumber = (value, fallback) => {
82
+ const number = typeof value === 'number' ? value : Number.parseFloat(String(value || ''));
83
+ return Number.isFinite(number) ? number : fallback;
84
+ };
85
+ const resolveCodecWorkerCount = (requested) => {
86
+ const hardware = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency || 2 : 2;
87
+ const defaultCount = Math.min(2, Math.max(1, Math.floor(hardware / 2)));
88
+ if (requested === undefined)
89
+ return defaultCount;
90
+ return clamp(finitePositiveInteger(requested, 'codec worker count'), 1, MAX_CODEC_WORKERS);
91
+ };
92
+ const ensureDicomRuntime = (maxWebWorkers) => {
93
+ if (!isCornerstoneInitialized())
94
+ initCornerstone();
95
+ if (!dicomLoaderInitialized) {
96
+ const workerManager = getWebWorkerManager();
97
+ if (!workerManager.workerRegistry[CODEC_WORKER_NAME]) {
98
+ // Cornerstone's public init() purges the host's global image/metadata
99
+ // caches. Register only the local-file loader and worker non-destructively.
100
+ registerDefaultProviders();
101
+ registerImageLoader('dicomfile', wadouri.loadImageFromNaturalizedMetadata);
102
+ workerManager.registerWorker(CODEC_WORKER_NAME, () => new Worker(new URL('./decode-worker.js', import.meta.url), { type: 'module' }), { maxWorkerInstances: resolveCodecWorkerCount(maxWebWorkers) });
103
+ }
104
+ dicomLoaderInitialized = true;
105
+ }
106
+ };
107
+ const createElement = (documentRef, tag, className, text) => {
108
+ const element = documentRef.createElement(tag);
109
+ if (className)
110
+ element.className = className;
111
+ if (text !== undefined)
112
+ element.textContent = text;
113
+ return element;
114
+ };
115
+ const createButton = (documentRef, label, text) => {
116
+ const button = createElement(documentRef, 'button', undefined, text);
117
+ button.type = 'button';
118
+ button.title = label;
119
+ button.setAttribute('aria-label', label);
120
+ return button;
121
+ };
122
+ const createStyle = (documentRef) => {
123
+ const style = documentRef.createElement('style');
124
+ style.textContent = dicomStyle;
125
+ return style;
126
+ };
127
+ const parseFileManagerIndex = (baseImageId) => {
128
+ const match = /^dicomfile:(\d+)$/.exec(baseImageId);
129
+ return match ? Number.parseInt(match[1], 10) : -1;
130
+ };
131
+ const cleanupDicomResources = (baseImageId, imageIds, renderingEngine, viewportId) => {
132
+ if (renderingEngine && !renderingEngine.hasBeenDestroyed) {
133
+ try {
134
+ renderingEngine.disableElement(viewportId);
135
+ }
136
+ catch {
137
+ // The viewport may already be disabled by a host teardown.
138
+ }
139
+ renderingEngine.destroy();
140
+ }
141
+ for (const imageId of imageIds) {
142
+ try {
143
+ if (cache.getImageLoadObject(imageId) || cache.getImage(imageId)) {
144
+ cache.removeImageLoadObject(imageId, { force: true });
145
+ }
146
+ }
147
+ catch {
148
+ // A pending load may have removed the entry between the check and decache.
149
+ }
150
+ cornerstoneMetadata.clearQuery(MetadataEnums.MetadataModules.NATURALIZED, imageId);
151
+ }
152
+ cornerstoneMetadata.clearQuery(MetadataEnums.MetadataModules.NATURALIZED, baseImageId);
153
+ const fileIndex = parseFileManagerIndex(baseImageId);
154
+ if (fileIndex >= 0)
155
+ wadouri.fileManager.remove(fileIndex);
156
+ };
157
+ const makeImageIds = (baseImageId, frameCount) => {
158
+ return Array.from({ length: frameCount }, (_, index) => `${baseImageId}?frame=${index + 1}`);
159
+ };
160
+ const normalizeZoom = (value) => clamp(Number(value.toFixed(3)), MIN_ZOOM, MAX_ZOOM);
161
+ const safeErrorMessage = (error) => {
162
+ if (error instanceof Error && error.message)
163
+ return error.message;
164
+ return 'Unable to render this DICOM file.';
165
+ };
166
+ export default async function renderDicom(buffer, target, _type, context, options) {
167
+ var _a, _b, _c, _d, _e;
168
+ if ((_a = context === null || context === void 0 ? void 0 : context.signal) === null || _a === void 0 ? void 0 : _a.aborted) {
169
+ throw context.signal.reason || new DOMException('DICOM render aborted.', 'AbortError');
170
+ }
171
+ const inspected = inspectDicomPart10(buffer, options === null || options === void 0 ? void 0 : options.limits);
172
+ const documentRef = target.ownerDocument;
173
+ const copy = resolveCopy(context);
174
+ const root = createElement(documentRef, 'div', 'dicom-viewer');
175
+ Object.assign(root.dataset, {
176
+ status: 'loading', renderer: 'dicom', rows: String(inspected.rows), columns: String(inspected.columns),
177
+ frameCount: String(inspected.frameCount), currentFrame: '1', windowWidth: String(inspected.windowWidth),
178
+ windowCenter: String(inspected.windowCenter), panX: '0', panY: '0', rotation: '0',
179
+ });
180
+ const toolbar = createElement(documentRef, 'div', 'dicom-toolbar');
181
+ const navigation = createElement(documentRef, 'div', 'dicom-tool-group');
182
+ const previousButton = createButton(documentRef, copy.previous, '‹');
183
+ const frameMeter = createElement(documentRef, 'span', 'dicom-frame-meter', `1 / ${inspected.frameCount}`);
184
+ frameMeter.setAttribute('aria-live', 'polite');
185
+ const nextButton = createButton(documentRef, copy.next, '›');
186
+ navigation.append(previousButton, frameMeter, nextButton);
187
+ const zoomTools = createElement(documentRef, 'div', 'dicom-tool-group');
188
+ const zoomOutButton = createButton(documentRef, copy.zoomOut, '−');
189
+ const zoomMeter = createElement(documentRef, 'span', 'dicom-zoom-meter', '100%');
190
+ const zoomInButton = createButton(documentRef, copy.zoomIn, '+');
191
+ const fitButton = createButton(documentRef, copy.fit, 'Fit');
192
+ const rotateLeftButton = createButton(documentRef, copy.rotateLeft, '↶');
193
+ const rotateRightButton = createButton(documentRef, copy.rotateRight, '↷');
194
+ zoomTools.append(zoomOutButton, zoomMeter, zoomInButton, fitButton, rotateLeftButton, rotateRightButton);
195
+ const windowControls = createElement(documentRef, 'div', 'dicom-window-controls');
196
+ const widthLabel = createElement(documentRef, 'label', undefined, 'WW');
197
+ widthLabel.title = copy.windowWidth;
198
+ const widthInput = createElement(documentRef, 'input');
199
+ widthInput.type = 'number';
200
+ widthInput.min = '1';
201
+ widthInput.step = '1';
202
+ widthInput.value = String(Math.round(inspected.windowWidth));
203
+ widthInput.setAttribute('aria-label', copy.windowWidth);
204
+ widthLabel.append(widthInput);
205
+ const centerLabel = createElement(documentRef, 'label', undefined, 'WC');
206
+ centerLabel.title = copy.windowCenter;
207
+ const centerInput = createElement(documentRef, 'input');
208
+ centerInput.type = 'number';
209
+ centerInput.step = '1';
210
+ centerInput.value = String(Math.round(inspected.windowCenter));
211
+ centerInput.setAttribute('aria-label', copy.windowCenter);
212
+ centerLabel.append(centerInput);
213
+ windowControls.append(widthLabel, centerLabel);
214
+ toolbar.append(navigation, zoomTools, windowControls);
215
+ const body = createElement(documentRef, 'div', 'dicom-body');
216
+ const stage = createElement(documentRef, 'div', 'dicom-stage');
217
+ stage.tabIndex = 0;
218
+ stage.setAttribute('role', 'application');
219
+ stage.setAttribute('aria-label', 'DICOM image viewport');
220
+ const metadata = createElement(documentRef, 'div', 'dicom-meta', `${inspected.columns}×${inspected.rows} · ${inspected.frameCount} frame${inspected.frameCount === 1 ? '' : 's'} · ${inspected.modality || 'OT'} · ${inspected.photometricInterpretation || 'unknown'}`);
221
+ const state = createElement(documentRef, 'div', 'dicom-state', copy.loading);
222
+ state.setAttribute('role', 'status');
223
+ body.append(stage, metadata, state);
224
+ root.append(toolbar, body);
225
+ target.replaceChildren(createStyle(documentRef), root);
226
+ let baseImageId = '';
227
+ let imageIds = [];
228
+ let renderingEngine = null;
229
+ let viewport = null;
230
+ let destroyed = false;
231
+ let currentFrame = 0;
232
+ let frameQueue = Promise.resolve();
233
+ let currentWindowWidth = inspected.windowWidth;
234
+ let currentWindowCenter = inspected.windowCenter;
235
+ let currentRotation = 0;
236
+ let resizeObserver = null;
237
+ let pointerId = null;
238
+ let pointerStart = [0, 0];
239
+ let panStart = [0, 0];
240
+ const listeners = [];
241
+ const zoomEmitter = createFileViewerZoomChangeEmitter();
242
+ const viewStateEmitter = createFileViewerViewStateChangeEmitter();
243
+ const listen = (element, type, listener, listenerOptions) => {
244
+ element.addEventListener(type, listener, listenerOptions);
245
+ listeners.push(() => element.removeEventListener(type, listener, listenerOptions));
246
+ };
247
+ const getZoomState = () => {
248
+ const scale = normalizeZoom((viewport === null || viewport === void 0 ? void 0 : viewport.getZoom()) || 1);
249
+ return { scale, label: `${Math.round(scale * 100)}%`, canZoomIn: scale < MAX_ZOOM, canZoomOut: scale > MIN_ZOOM, canReset: Math.abs(scale - 1) > 0.001, minScale: MIN_ZOOM, maxScale: MAX_ZOOM };
250
+ };
251
+ const syncZoomUi = () => {
252
+ const zoom = getZoomState();
253
+ root.dataset.zoom = String(zoom.scale);
254
+ zoomMeter.textContent = zoom.label;
255
+ zoomInButton.disabled = !zoom.canZoomIn;
256
+ zoomOutButton.disabled = !zoom.canZoomOut;
257
+ zoomEmitter.emit();
258
+ };
259
+ const getViewState = () => {
260
+ const zoom = getZoomState();
261
+ const pan = (viewport === null || viewport === void 0 ? void 0 : viewport.getPan()) || [0, 0];
262
+ return { renderer: 'dicom', page: currentFrame + 1, pageCount: inspected.frameCount, scale: zoom.scale, zoom, rotation: currentRotation, extra: { pan: [Number(pan[0].toFixed(3)), Number(pan[1].toFixed(3))], windowCenter: currentWindowCenter, windowWidth: currentWindowWidth } };
263
+ };
264
+ const emitViewState = (action, source) => {
265
+ const next = getViewState();
266
+ viewStateEmitter.emit(createFileViewerViewStateChange(next, { action, source }));
267
+ return next;
268
+ };
269
+ const setZoom = (value, action = 'zoom-change', source = 'api') => {
270
+ const next = normalizeZoom(value);
271
+ viewport === null || viewport === void 0 ? void 0 : viewport.setZoom(next);
272
+ viewport === null || viewport === void 0 ? void 0 : viewport.render();
273
+ syncZoomUi();
274
+ emitViewState(action, source);
275
+ return getZoomState();
276
+ };
277
+ const setRotation = (value, action = 'rotation-change', source = 'api') => {
278
+ const next = ((Math.round(value / 90) * 90) % 360 + 360) % 360;
279
+ currentRotation = next;
280
+ viewport === null || viewport === void 0 ? void 0 : viewport.setRotation(next);
281
+ viewport === null || viewport === void 0 ? void 0 : viewport.render();
282
+ root.dataset.rotation = String(next);
283
+ return emitViewState(action, source);
284
+ };
285
+ const fitToView = (request, source = 'api') => {
286
+ if (!viewport)
287
+ return { applied: false, mode: (request === null || request === void 0 ? void 0 : request.mode) || 'contain', resize: (request === null || request === void 0 ? void 0 : request.resize) || 'initial', reason: 'not-ready', provider: 'zoom' };
288
+ viewport.resetCamera({ resetPan: true, resetZoom: true, resetToCenter: true });
289
+ viewport.setRotation(currentRotation);
290
+ viewport.render();
291
+ const pan = viewport.getPan();
292
+ root.dataset.panX = String(Number(pan[0].toFixed(3)));
293
+ root.dataset.panY = String(Number(pan[1].toFixed(3)));
294
+ syncZoomUi();
295
+ emitViewState('fit', source);
296
+ return { applied: true, mode: (request === null || request === void 0 ? void 0 : request.mode) || 'contain', resize: (request === null || request === void 0 ? void 0 : request.resize) || 'initial', scale: getZoomState().scale, source, provider: 'zoom', state: getViewState() };
297
+ };
298
+ const syncFrameUi = () => {
299
+ root.dataset.currentFrame = String(currentFrame + 1);
300
+ frameMeter.textContent = `${currentFrame + 1} / ${inspected.frameCount}`;
301
+ previousButton.disabled = currentFrame <= 0;
302
+ nextButton.disabled = currentFrame >= inspected.frameCount - 1;
303
+ };
304
+ const setFrame = (frame, source = 'user') => {
305
+ const requested = clamp(Math.round(frame), 0, inspected.frameCount - 1);
306
+ frameQueue = frameQueue.then(async () => {
307
+ if (destroyed || !viewport || requested === currentFrame)
308
+ return;
309
+ await viewport.setImageIdIndex(requested);
310
+ if (destroyed)
311
+ return;
312
+ currentFrame = requested;
313
+ syncFrameUi();
314
+ viewport.render();
315
+ emitViewState('page-change', source);
316
+ });
317
+ return frameQueue;
318
+ };
319
+ const applyWindow = () => {
320
+ if (!viewport)
321
+ return;
322
+ const width = Math.max(1, finiteNumber(widthInput.value, currentWindowWidth));
323
+ const center = finiteNumber(centerInput.value, currentWindowCenter);
324
+ currentWindowWidth = width;
325
+ currentWindowCenter = center;
326
+ widthInput.value = String(Math.round(width * 1000) / 1000);
327
+ centerInput.value = String(Math.round(center * 1000) / 1000);
328
+ root.dataset.windowWidth = String(width);
329
+ root.dataset.windowCenter = String(center);
330
+ viewport.setProperties({ voiRange: { lower: center - width / 2, upper: center + width / 2 } });
331
+ viewport.render();
332
+ emitViewState('window-change', 'user');
333
+ };
334
+ const cleanup = () => {
335
+ var _a;
336
+ if (destroyed)
337
+ return;
338
+ destroyed = true;
339
+ (_a = context === null || context === void 0 ? void 0 : context.registerThumbnailAdapter) === null || _a === void 0 ? void 0 : _a.call(context, null);
340
+ unregisterFileViewerViewStateProvider(root);
341
+ unregisterFileViewerZoomProvider(root);
342
+ resizeObserver === null || resizeObserver === void 0 ? void 0 : resizeObserver.disconnect();
343
+ resizeObserver = null;
344
+ listeners.splice(0).forEach(remove => remove());
345
+ cleanupDicomResources(baseImageId, imageIds, renderingEngine, viewport ? viewport.id : '');
346
+ viewport = null;
347
+ renderingEngine = null;
348
+ // Cornerstone owns the global codec worker pool. Do not terminate it here:
349
+ // the host application may have independent Cornerstone viewports.
350
+ target.replaceChildren();
351
+ };
352
+ const abort = () => cleanup();
353
+ if (context === null || context === void 0 ? void 0 : context.signal) {
354
+ context.signal.addEventListener('abort', abort, { once: true });
355
+ listeners.push(() => { var _a; return (_a = context.signal) === null || _a === void 0 ? void 0 : _a.removeEventListener('abort', abort); });
356
+ }
357
+ try {
358
+ ensureDicomRuntime(options === null || options === void 0 ? void 0 : options.maxWebWorkers);
359
+ const fileName = ((_b = context === null || context === void 0 ? void 0 : context.filename) === null || _b === void 0 ? void 0 : _b.trim()) || 'preview.dcm';
360
+ baseImageId = wadouri.fileManager.add(new File([buffer], fileName, { type: 'application/dicom' }));
361
+ imageIds = makeImageIds(baseImageId, inspected.frameCount);
362
+ root.dataset.dicomImageId = baseImageId;
363
+ const renderingEngineId = `file-viewer-dicom-engine-${Date.now()}-${++renderingEngineSerial}`;
364
+ root.dataset.renderingEngineId = renderingEngineId;
365
+ const viewportId = `${renderingEngineId}-stack`;
366
+ // A renderer instance owns one stack viewport, so a one-context tiled engine
367
+ // avoids allocating Cornerstone's multi-context pool for every File Viewer.
368
+ renderingEngine = new TiledRenderingEngine(renderingEngineId);
369
+ renderingEngine.enableElement({ element: stage, viewportId, type: Enums.ViewportType.STACK, defaultOptions: { background: [0.015, 0.025, 0.045] } });
370
+ viewport = renderingEngine.getViewport(viewportId);
371
+ await viewport.setStack(imageIds, 0);
372
+ if (destroyed || ((_c = context === null || context === void 0 ? void 0 : context.signal) === null || _c === void 0 ? void 0 : _c.aborted))
373
+ throw ((_d = context === null || context === void 0 ? void 0 : context.signal) === null || _d === void 0 ? void 0 : _d.reason) || new DOMException('DICOM render aborted.', 'AbortError');
374
+ viewport.setProperties({ voiRange: { lower: currentWindowCenter - currentWindowWidth / 2, upper: currentWindowCenter + currentWindowWidth / 2 } });
375
+ viewport.resetCamera({ resetPan: true, resetZoom: true, resetToCenter: true });
376
+ viewport.render();
377
+ registerFileViewerZoomProvider(root, {
378
+ zoomIn: () => setZoom(getZoomState().scale * 1.2, 'zoom-in', 'api'),
379
+ zoomOut: () => setZoom(getZoomState().scale / 1.2, 'zoom-out', 'api'),
380
+ resetZoom: () => { fitToView(undefined, 'api'); return getZoomState(); },
381
+ setZoom: value => setZoom(value, 'zoom-change', 'api'),
382
+ fit: request => fitToView(request, request.source), getState: getZoomState, subscribe: zoomEmitter.subscribe,
383
+ });
384
+ registerFileViewerViewStateProvider(root, {
385
+ getState: getViewState,
386
+ async applyState(nextState, applyOptions = {}) {
387
+ var _a, _b;
388
+ const source = applyOptions.source || 'api';
389
+ if (Number.isFinite(nextState.page))
390
+ await setFrame(Number(nextState.page) - 1, source);
391
+ const nextScale = Number((_a = nextState.scale) !== null && _a !== void 0 ? _a : (_b = nextState.zoom) === null || _b === void 0 ? void 0 : _b.scale);
392
+ if (Number.isFinite(nextScale))
393
+ setZoom(nextScale, 'zoom-change', source);
394
+ if (Number.isFinite(nextState.rotation))
395
+ setRotation(Number(nextState.rotation), 'rotation-change', source);
396
+ const extra = nextState.extra || {};
397
+ const nextWidth = finiteNumber(extra.windowWidth, Number.NaN);
398
+ const nextCenter = finiteNumber(extra.windowCenter, Number.NaN);
399
+ if (Number.isFinite(nextWidth) && Number.isFinite(nextCenter)) {
400
+ widthInput.value = String(nextWidth);
401
+ centerInput.value = String(nextCenter);
402
+ applyWindow();
403
+ }
404
+ const nextPan = Array.isArray(extra.pan) ? extra.pan : [];
405
+ if (nextPan.length >= 2 && Number.isFinite(nextPan[0]) && Number.isFinite(nextPan[1])) {
406
+ viewport === null || viewport === void 0 ? void 0 : viewport.setPan([Number(nextPan[0]), Number(nextPan[1])]);
407
+ viewport === null || viewport === void 0 ? void 0 : viewport.render();
408
+ root.dataset.panX = String(Number(nextPan[0]));
409
+ root.dataset.panY = String(Number(nextPan[1]));
410
+ }
411
+ const action = applyOptions.action || 'restore';
412
+ return applyOptions.notify === false ? getViewState() : emitViewState(action, source);
413
+ },
414
+ fit: request => fitToView(request, request.source), subscribe: viewStateEmitter.subscribe,
415
+ });
416
+ listen(previousButton, 'click', () => void setFrame(currentFrame - 1));
417
+ listen(nextButton, 'click', () => void setFrame(currentFrame + 1));
418
+ listen(zoomOutButton, 'click', () => setZoom(getZoomState().scale / 1.2, 'zoom-out', 'user'));
419
+ listen(zoomInButton, 'click', () => setZoom(getZoomState().scale * 1.2, 'zoom-in', 'user'));
420
+ listen(fitButton, 'click', () => fitToView(undefined, 'user'));
421
+ listen(rotateLeftButton, 'click', () => setRotation(currentRotation - 90, 'rotate-left', 'user'));
422
+ listen(rotateRightButton, 'click', () => setRotation(currentRotation + 90, 'rotate-right', 'user'));
423
+ listen(widthInput, 'change', applyWindow);
424
+ listen(centerInput, 'change', applyWindow);
425
+ listen(stage, 'wheel', event => {
426
+ event.preventDefault();
427
+ if (event.ctrlKey || event.metaKey || inspected.frameCount === 1) {
428
+ setZoom(getZoomState().scale * (event.deltaY < 0 ? 1.12 : 1 / 1.12), 'zoom-change', 'user');
429
+ }
430
+ else
431
+ void setFrame(currentFrame + (event.deltaY > 0 ? 1 : -1));
432
+ }, { passive: false });
433
+ listen(stage, 'keydown', event => {
434
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowUp' || event.key === 'PageUp') {
435
+ event.preventDefault();
436
+ void setFrame(currentFrame - 1);
437
+ }
438
+ else if (event.key === 'ArrowRight' || event.key === 'ArrowDown' || event.key === 'PageDown') {
439
+ event.preventDefault();
440
+ void setFrame(currentFrame + 1);
441
+ }
442
+ else if (event.key === '+' || event.key === '=') {
443
+ event.preventDefault();
444
+ setZoom(getZoomState().scale * 1.2, 'zoom-in', 'user');
445
+ }
446
+ else if (event.key === '-') {
447
+ event.preventDefault();
448
+ setZoom(getZoomState().scale / 1.2, 'zoom-out', 'user');
449
+ }
450
+ else if (event.key === '0' || event.key === 'Home') {
451
+ event.preventDefault();
452
+ fitToView(undefined, 'user');
453
+ }
454
+ else if (event.key === '[' || (event.key.toLowerCase() === 'r' && event.shiftKey)) {
455
+ event.preventDefault();
456
+ setRotation(currentRotation - 90, 'rotate-left', 'user');
457
+ }
458
+ else if (event.key === ']' || event.key.toLowerCase() === 'r') {
459
+ event.preventDefault();
460
+ setRotation(currentRotation + 90, 'rotate-right', 'user');
461
+ }
462
+ });
463
+ listen(stage, 'pointerdown', event => {
464
+ if (event.button !== 0 || !viewport)
465
+ return;
466
+ pointerId = event.pointerId;
467
+ pointerStart = [event.clientX, event.clientY];
468
+ const pan = viewport.getPan();
469
+ panStart = [pan[0], pan[1]];
470
+ stage.setPointerCapture(event.pointerId);
471
+ stage.classList.add('is-panning');
472
+ stage.focus({ preventScroll: true });
473
+ });
474
+ listen(stage, 'pointermove', event => {
475
+ if (pointerId !== event.pointerId || !viewport)
476
+ return;
477
+ const nextPan = [panStart[0] + event.clientX - pointerStart[0], panStart[1] + event.clientY - pointerStart[1]];
478
+ viewport.setPan(nextPan);
479
+ viewport.render();
480
+ root.dataset.panX = String(Number(nextPan[0].toFixed(3)));
481
+ root.dataset.panY = String(Number(nextPan[1].toFixed(3)));
482
+ });
483
+ const endPointer = (event) => {
484
+ if (pointerId !== event.pointerId)
485
+ return;
486
+ if (stage.hasPointerCapture(event.pointerId))
487
+ stage.releasePointerCapture(event.pointerId);
488
+ pointerId = null;
489
+ stage.classList.remove('is-panning');
490
+ emitViewState('pan-change', 'user');
491
+ };
492
+ listen(stage, 'pointerup', endPointer);
493
+ listen(stage, 'pointercancel', endPointer);
494
+ const ResizeObserverConstructor = (_e = documentRef.defaultView) === null || _e === void 0 ? void 0 : _e.ResizeObserver;
495
+ if (ResizeObserverConstructor) {
496
+ resizeObserver = new ResizeObserverConstructor(() => {
497
+ if (!destroyed && renderingEngine && !renderingEngine.hasBeenDestroyed)
498
+ renderingEngine.resize(true, true);
499
+ });
500
+ resizeObserver.observe(body);
501
+ }
502
+ previousButton.disabled = true;
503
+ nextButton.disabled = inspected.frameCount <= 1;
504
+ root.dataset.status = 'ready';
505
+ state.hidden = true;
506
+ syncFrameUi();
507
+ syncZoomUi();
508
+ emitViewState('init', 'viewer');
509
+ }
510
+ catch (error) {
511
+ if (!destroyed) {
512
+ root.dataset.status = 'error';
513
+ state.hidden = false;
514
+ state.classList.add('error');
515
+ state.textContent = safeErrorMessage(error);
516
+ }
517
+ cleanup();
518
+ // An abort can destroy the viewport while its initial worker request is
519
+ // settling. Purge the owner-scoped cache a second time after setStack has
520
+ // rejected/resolved so late metadata cannot survive the failed session.
521
+ cleanupDicomResources(baseImageId, imageIds, null, '');
522
+ throw error;
523
+ }
524
+ return { $el: root, destroy: cleanup };
525
+ }
@@ -0,0 +1,21 @@
1
+ import type { FileRenderHandler, FileViewerRenderedInstance, FileViewerRendererPlugin, RendererDefinition } from '@file-viewer/core';
2
+ export interface FileViewerDicomLimits {
3
+ /** Maximum encoded Part 10 file size. Defaults to 64 MiB. */
4
+ maxSourceBytes?: number;
5
+ /** Maximum number of frames exposed by one file. Defaults to 256. */
6
+ maxFrames?: number;
7
+ /** Maximum decoded samples in one frame. Defaults to 16 million. */
8
+ maxFramePixels?: number;
9
+ /** Maximum decoded samples addressable by one file. Defaults to 48 million. */
10
+ maxTotalPixels?: number;
11
+ }
12
+ export interface FileViewerDicomRendererOptions {
13
+ limits?: FileViewerDicomLimits;
14
+ /** Number of codec workers initialized after a DICOM file is selected. Defaults to at most 2. */
15
+ maxWebWorkers?: number;
16
+ }
17
+ export declare const dicomRendererDefinition: RendererDefinition;
18
+ export declare const renderFileViewerDicom: FileRenderHandler<FileViewerRenderedInstance, HTMLDivElement>;
19
+ export declare const createDicomRenderer: (options?: FileViewerDicomRendererOptions) => FileViewerRendererPlugin<FileRenderHandler<FileViewerRenderedInstance, HTMLDivElement>>;
20
+ export declare const dicomRenderer: FileViewerRendererPlugin<FileRenderHandler<FileViewerRenderedInstance, HTMLDivElement>>;
21
+ export default dicomRenderer;
package/dist/index.js ADDED
@@ -0,0 +1,34 @@
1
+ export const dicomRendererDefinition = {
2
+ id: 'dicom',
3
+ label: 'DICOM medical image',
4
+ category: 'image',
5
+ extensions: ['dcm', 'dicom'],
6
+ async: true,
7
+ supportLevel: 'basic',
8
+ status: 'experimental',
9
+ packageName: '@file-viewer/renderer-dicom',
10
+ presets: [],
11
+ knownLimits: [
12
+ 'This MVP is tested with Implicit/Explicit VR Little Endian, JPEG Lossless Process 14 SV1, JPEG-LS Lossless, and JPEG 2000 Lossless Part 10 files; other transfer syntaxes and DICOMweb are not included.',
13
+ 'The MVP renders one single-frame or multi-frame local file as a stack; series assembly, PACS/DICOMweb, MPR, and diagnostic use are not included.',
14
+ ],
15
+ capabilities: {
16
+ download: true,
17
+ print: false,
18
+ exportHtml: false,
19
+ zoom: true,
20
+ },
21
+ };
22
+ const createHandler = (options) => (buffer, target, type, context) => import('./dicom.js').then(({ default: renderDicom }) => (renderDicom(buffer, target, type, context, options)));
23
+ export const renderFileViewerDicom = createHandler();
24
+ export const createDicomRenderer = (options) => ({
25
+ id: 'file-viewer-renderer-dicom',
26
+ label: 'Flyfish File Viewer opt-in DICOM renderer',
27
+ definitions: [dicomRendererDefinition],
28
+ handlers: [{
29
+ rendererId: dicomRendererDefinition.id,
30
+ handler: options ? createHandler(options) : renderFileViewerDicom,
31
+ }],
32
+ });
33
+ export const dicomRenderer = createDicomRenderer();
34
+ export default dicomRenderer;
@@ -0,0 +1,17 @@
1
+ import type { FileViewerDicomLimits } from './index.js';
2
+ export declare const DEFAULT_DICOM_LIMITS: Readonly<Required<FileViewerDicomLimits>>;
3
+ /** Transfer syntaxes accepted by this tested MVP (intentionally narrower than the loader). */
4
+ export declare const SUPPORTED_DICOM_TRANSFER_SYNTAXES: Set<string>;
5
+ export interface InspectedDicomPart10 {
6
+ bitsAllocated: number;
7
+ columns: number;
8
+ frameCount: number;
9
+ modality: string;
10
+ photometricInterpretation: string;
11
+ rows: number;
12
+ samplesPerPixel: number;
13
+ transferSyntax: string;
14
+ windowCenter: number;
15
+ windowWidth: number;
16
+ }
17
+ export declare const inspectDicomPart10: (buffer: ArrayBuffer, limitsInput?: FileViewerDicomLimits) => InspectedDicomPart10;