nuxt-cornerstone 1.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +933 -0
  3. package/dist/module.d.mts +22 -0
  4. package/dist/module.json +12 -0
  5. package/dist/module.mjs +168 -0
  6. package/dist/runtime/annotation-json.d.ts +76 -0
  7. package/dist/runtime/annotation-json.js +176 -0
  8. package/dist/runtime/components/CornerstoneViewport.d.vue.ts +57 -0
  9. package/dist/runtime/components/CornerstoneViewport.vue +227 -0
  10. package/dist/runtime/components/CornerstoneViewport.vue.d.ts +57 -0
  11. package/dist/runtime/composables/useAnnotationReport.d.ts +28 -0
  12. package/dist/runtime/composables/useAnnotationReport.js +66 -0
  13. package/dist/runtime/composables/useCinePlayer.d.ts +52 -0
  14. package/dist/runtime/composables/useCinePlayer.js +95 -0
  15. package/dist/runtime/composables/useCornerstone.d.ts +17 -0
  16. package/dist/runtime/composables/useCornerstone.js +30 -0
  17. package/dist/runtime/composables/useCornerstoneI18n.d.ts +24 -0
  18. package/dist/runtime/composables/useCornerstoneI18n.js +32 -0
  19. package/dist/runtime/composables/useCornerstoneTools.d.ts +21 -0
  20. package/dist/runtime/composables/useCornerstoneTools.js +105 -0
  21. package/dist/runtime/composables/useDicomAnnotations.d.ts +76 -0
  22. package/dist/runtime/composables/useDicomAnnotations.js +220 -0
  23. package/dist/runtime/composables/useDicomFiles.d.ts +87 -0
  24. package/dist/runtime/composables/useDicomFiles.js +234 -0
  25. package/dist/runtime/composables/useDicomGuard.d.ts +24 -0
  26. package/dist/runtime/composables/useDicomGuard.js +30 -0
  27. package/dist/runtime/composables/useDicomStudy.d.ts +122 -0
  28. package/dist/runtime/composables/useDicomStudy.js +222 -0
  29. package/dist/runtime/composables/useImagePrefetch.d.ts +78 -0
  30. package/dist/runtime/composables/useImagePrefetch.js +119 -0
  31. package/dist/runtime/composables/useMeasurements.d.ts +51 -0
  32. package/dist/runtime/composables/useMeasurements.js +138 -0
  33. package/dist/runtime/composables/useRenderingEngine.d.ts +6 -0
  34. package/dist/runtime/composables/useRenderingEngine.js +46 -0
  35. package/dist/runtime/composables/useStackCine.d.ts +50 -0
  36. package/dist/runtime/composables/useStackCine.js +62 -0
  37. package/dist/runtime/composables/useViewerShortcuts.d.ts +94 -0
  38. package/dist/runtime/composables/useViewerShortcuts.js +115 -0
  39. package/dist/runtime/cornerstone.d.ts +21 -0
  40. package/dist/runtime/cornerstone.js +100 -0
  41. package/dist/runtime/dicom-instances.d.ts +32 -0
  42. package/dist/runtime/dicom-instances.js +15 -0
  43. package/dist/runtime/dicom-zip.d.ts +95 -0
  44. package/dist/runtime/dicom-zip.js +149 -0
  45. package/dist/runtime/i18n/detect.d.ts +37 -0
  46. package/dist/runtime/i18n/detect.js +37 -0
  47. package/dist/runtime/i18n/index.d.ts +61 -0
  48. package/dist/runtime/i18n/index.js +145 -0
  49. package/dist/runtime/i18n/messages.d.ts +122 -0
  50. package/dist/runtime/i18n/messages.js +147 -0
  51. package/dist/runtime/plugin.client.d.ts +13 -0
  52. package/dist/runtime/plugin.client.js +31 -0
  53. package/dist/runtime/plugin.i18n.d.ts +12 -0
  54. package/dist/runtime/plugin.i18n.js +15 -0
  55. package/dist/runtime/types.d.ts +150 -0
  56. package/dist/runtime/types.js +10 -0
  57. package/dist/types.d.mts +29 -0
  58. package/package.json +76 -0
package/README.md ADDED
@@ -0,0 +1,933 @@
1
+ # nuxt-cornerstone
2
+
3
+ [Cornerstone3D](https://www.cornerstonejs.org/) for Nuxt 4 — a DICOM stack viewport, tool groups
4
+ and loaders, with the build configuration Cornerstone needs already done.
5
+
6
+ Cornerstone3D cannot simply be imported into a Nuxt app. It needs Vite configured a particular way
7
+ (module workers, dependency prebundling held back from the WASM codecs, CommonJS interop), and it is
8
+ strictly browser-only, so SSR must never touch it. A plugin file cannot change Vite's configuration
9
+ for the app that installs it — a module can, which is what this is.
10
+
11
+ Built and verified against **Cornerstone3D 5.10.7**, **Nuxt 4.5.2**, **Vite 8.3.0**.
12
+
13
+ ## Install
14
+
15
+ The `@cornerstonejs/*` packages are peer dependencies, so your app owns exactly one copy of each.
16
+ Two copies of `@cornerstonejs/core` in a dependency tree means two image caches and two event
17
+ targets, and it fails in ways that are hard to trace.
18
+
19
+ ```bash
20
+ pnpm add nuxt-cornerstone
21
+ pnpm add @cornerstonejs/core @cornerstonejs/tools @cornerstonejs/dicom-image-loader \
22
+ @cornerstonejs/metadata @cornerstonejs/utils dicom-parser
23
+ ```
24
+
25
+ `@cornerstonejs/metadata` and `@cornerstonejs/utils` were split out of `core` in Cornerstone3D 5 and
26
+ are exact-pinned peers of it. Nothing in your code imports them directly, but the install is broken
27
+ without them. All `@cornerstonejs` packages must be on the same version.
28
+
29
+ The module checks for all six at startup and fails with the install command if any is missing.
30
+
31
+ ### Styling
32
+
33
+ Nothing to configure. `<CornerstoneViewport>` brings the handful of rules it needs to function —
34
+ a size of its own, `overflow: hidden`, `touch-action: none`, and `display: block` on the canvas
35
+ Cornerstone appends to it — in its own `<style>` block, which your build compiles like any other
36
+ component's. There is no Tailwind requirement and no source-scanning line to add.
37
+
38
+ Those rules are written as `:where(.nuxt-cornerstone-viewport)`, which contributes no specificity,
39
+ so anything you write beats them without `!important`:
40
+
41
+ ```vue
42
+ <CornerstoneViewport :image-ids="imageIds" class="h-[600px] rounded-lg" />
43
+ ```
44
+
45
+ The element must end up with a height from somewhere. It defaults to `100%`, which means a parent
46
+ with a height of its own; a viewport that reports no error and draws nothing is almost always an
47
+ ancestor that collapsed to zero.
48
+
49
+ ```ts
50
+ // nuxt.config.ts
51
+ export default defineNuxtConfig({
52
+ modules: ['nuxt-cornerstone'],
53
+ })
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ ```vue
59
+ <script setup lang="ts">
60
+ const { addFiles } = useDicomFiles()
61
+ const tools = useCornerstoneTools()
62
+
63
+ const imageIds = ref<string[]>([])
64
+ const imageIndex = ref(0)
65
+
66
+ async function open(files: FileList | null) {
67
+ if (files) imageIds.value = await addFiles(files)
68
+ }
69
+ </script>
70
+
71
+ <template>
72
+ <input type="file" multiple @change="open(($event.target as HTMLInputElement).files)">
73
+ <button @click="tools.setActive('LengthTool')">Measure</button>
74
+
75
+ <div style="height: 600px">
76
+ <CornerstoneViewport
77
+ :image-ids="imageIds"
78
+ :image-index="imageIndex"
79
+ @image-index-change="imageIndex = $event"
80
+ />
81
+ </div>
82
+ </template>
83
+ ```
84
+
85
+ Out of the box: left-drag windows/levels, right-drag zooms, middle-drag pans, the wheel scrolls the
86
+ stack.
87
+
88
+ ## `<CornerstoneViewport>`
89
+
90
+ A stack viewport. Registered as a client-only component, so you never need `<ClientOnly>` around it.
91
+
92
+ | Prop | Type | Default | |
93
+ | --- | --- | --- | --- |
94
+ | `imageIds` | `string[]` | required | `wadouri:` / `wadors:` / `dicomfile:` ids, in display order |
95
+ | `imageIndex` | `number` | `0` | index into `imageIds` |
96
+ | `viewportId` | `string` | generated | |
97
+ | `renderingEngineId` | `string` | module option | viewports sharing an id share one engine |
98
+ | `toolGroupId` | `string` | module option | |
99
+ | `background` | `[number, number, number]` | `[0, 0, 0]` | canvas background, RGB in 0..1 |
100
+ | `defaultTool` | `string \| false` | `'WindowLevelTool'` | bound to left-drag on mount |
101
+
102
+ Events: `ready(viewport)`, `imageRendered`, `imageIndexChange(index)`, `error(error)`.
103
+
104
+ Exposed: `viewport`, `viewportId`, `renderingEngineId`, `status`, `error`, `setImageIndex(index)`,
105
+ `resetCamera()`, `getRenderingEngine()`.
106
+
107
+ The default slot receives `{ status, error, viewport }` for overlays.
108
+
109
+ `imageIndex` changes collapse rather than queue. `setImageIdIndex` resolves only once the slice has
110
+ been loaded and drawn, which can take longer than the gap between requests — cine playback asks for
111
+ a frame every 30 ms or so, and scrubbing fires as fast as the pointer moves. Only one change is
112
+ ever in flight; whatever arrives while it runs replaces the previous waiting one, so the viewport
113
+ follows the newest index asked for rather than the last load to finish.
114
+
115
+ The element must have a size — give it a height. It waits for a non-zero box before enabling the
116
+ viewport, because Cornerstone sizes its canvas from the element and a zero-sized element produces a
117
+ camera that never recovers. A viewport that starts inside a collapsed panel therefore comes up when
118
+ the panel opens, not before. Resizes are followed with a `ResizeObserver`, keeping the user's
119
+ pan/zoom.
120
+
121
+ ## Composables
122
+
123
+ All are auto-imported.
124
+
125
+ **`useCornerstone()`** → `{ libs, ready, error, pending, ensure, options }`. Calling it starts
126
+ initialisation; `ensure()` resolves with `{ core, tools, dicomImageLoader }`. Initialisation is
127
+ shared, so calling this from ten components still initialises once.
128
+
129
+ **`useDicomFiles()`** → `{ addFiles, addZip, toImageId, indexUrls, purge, imageIdForSopInstanceUid }`.
130
+ `addFiles(files)` registers local `File`s and returns `dicomfile:` imageIds sorted by
131
+ **InstanceNumber** — read from tag (0020,0013) with `dicom-parser`, stopping at that tag, and
132
+ falling back to a numeric-aware filename sort for files that do not carry it. Pass
133
+ `{ sort: 'name' }` or `{ sort: false }` to change that. `toImageId(url)` builds a `wadouri:` id for
134
+ a Part 10 file served over HTTP. `addZip(file)` unpacks a ZIP archive — see below.
135
+ `indexUrls(urls)` reads the headers of files served that way so imported annotations can find them,
136
+ which `toImageId()` alone does not do. `imageIdForSopInstanceUid(uid)` answers where a slice ended
137
+ up, which is how annotations from elsewhere find their image — see
138
+ [Imported annotations](#imported-annotations).
139
+
140
+ **`useCornerstoneI18n()`** → `{ locale, setLocale, availableLocales, dir, isRtl, t, n, formatBytes,
141
+ addMessages, setTranslator }`. Locale and translation for the strings this module produces — see
142
+ [Internationalisation](#internationalisation).
143
+
144
+ ### ZIP archives
145
+
146
+ `addZip()` takes a `File`, `Blob`, `ArrayBuffer` or `Uint8Array` and returns the images inside it,
147
+ **split into series**:
148
+
149
+ ```vue
150
+ <script setup lang="ts">
151
+ import type { DicomSeries } from 'nuxt-cornerstone'
152
+
153
+ const { addZip } = useDicomFiles()
154
+ const series = shallowRef<DicomSeries[]>([])
155
+ const imageIds = ref<string[]>([])
156
+
157
+ async function open(file: File) {
158
+ const result = await addZip(file, {
159
+ onProgress: ({ phase, done, total }) => console.log(phase, done, '/', total),
160
+ })
161
+ series.value = result.series
162
+ imageIds.value = result.series[0]?.imageIds ?? []
163
+ }
164
+ </script>
165
+ ```
166
+
167
+ Each `DicomSeries` carries `seriesInstanceUid`, `seriesNumber`, `description`, `modality`, a
168
+ ready-made `label` and its own sorted `imageIds`. Series come back ordered by SeriesNumber. The
169
+ result also has a flat `imageIds` across every series, and `skipped`, listing the archive members
170
+ that were not loaded and why.
171
+
172
+ Splitting by SeriesInstanceUID (0020,000E) is the default because a study ZIP normally holds
173
+ several series, and stacking a sagittal T1 on top of an axial T2 is not a stack. Files whose header
174
+ does not give a SeriesInstanceUID fall back to grouping by their folder inside the archive, which is
175
+ how burned CDs lay series out anyway. Pass `{ groupBy: false }` for a single group holding
176
+ everything.
177
+
178
+ Archive members are filtered twice. Before anything is inflated, entries are dropped by name and
179
+ declared size — directories, `__MACOSX/`, dotfiles, `DICOMDIR`, `Thumbs.db`, and extensions that are
180
+ never DICOM (`.pdf`, `.jpg`, `.txt` and friends). There is no allowlist in the other direction,
181
+ because plenty of DICOM files are named `IM000001` or `I10`.
182
+
183
+ What survives is then judged on its bytes, and has to prove itself. A Part 10 file says so with the
184
+ `DICM` magic at offset 128. A dataset stored without a preamble has nothing to declare, so its
185
+ structure is read instead: the first element must open a group a dataset may legitimately open with
186
+ (file meta, or the identifying module), and the elements after it must parse and ascend. Anything
187
+ that can show neither is skipped as `not-dicom`.
188
+
189
+ A name is not evidence in either direction — a PNG renamed to `.dcm` is still a PNG, and it is
190
+ rejected here on its first tag, whose group reads as `0x5089`.
191
+
192
+ | Option | Default | |
193
+ | --- | --- | --- |
194
+ | `groupBy` | `'series'` | `false` returns one group |
195
+ | `sort` | `'instanceNumber'` | as `addFiles`; applies within each series |
196
+ | `maxBytes` | `2 GiB` | ceiling on total *uncompressed* bytes |
197
+ | `onProgress` | — | `{ phase, done, total }` |
198
+
199
+ `maxBytes` is checked against the sizes the archive's central directory declares, so a 100 KB file
200
+ claiming to expand to 40 GB is rejected without allocating for it.
201
+
202
+ `onProgress` reports three phases. `'reading'` and `'extracting'` have nothing to count — fflate
203
+ reports nothing until inflation finishes — so show an indeterminate bar for those; `'indexing'`
204
+ counts DICOM headers and fills in `done`/`total`. Indexing yields to the event loop every 32 files,
205
+ so a large study does not freeze the page while it is read.
206
+
207
+ Decompression uses [`fflate`](https://github.com/101arrowz/fflate), imported dynamically: an app
208
+ that never opens an archive never loads it.
209
+
210
+ **`useCornerstoneTools(toolGroupId?)`** → `{ ensureGroup, addViewport, removeViewport, setActive,
211
+ setPassive, setEnabled, setDisabled, getActiveTool, destroy }`. Takes either a class name
212
+ (`'LengthTool'`) or a tool name (`'Length'`). `setActive` sets the tool that held the left-drag
213
+ binding to *passive* rather than disabled, so annotations it drew stay visible and selectable.
214
+
215
+ **`useRenderingEngine()`** → `{ acquire, release, get }`. Engines are shared per id and reference
216
+ counted; the last viewport to leave destroys the engine. `core.init()` allocates a pool of WebGL
217
+ contexts (7 by default) and each engine takes one, so four viewports should share one engine rather
218
+ than create four.
219
+
220
+ **`useImagePrefetch()`** → `{ prepare, cancel, reset, isPrepared, loaded, failed, total, pending,
221
+ progress, complete, cacheFull }`. Decodes a stack into Cornerstone's image cache ahead of time.
222
+
223
+ **`useCinePlayer(index, frameCount, options?)`** → `{ playing, frameRate, loop, bounce, direction,
224
+ canPlay, play, pause, toggle, setFrameRate, setDirection }`. Plays a stack as a film by advancing
225
+ the index ref you give it. Both are covered under [Cine playback](#cine-playback).
226
+
227
+ **`useDicomAnnotations(viewport)`** → `{ addBoxes, addJson, clear, setVisible, drawn, pending,
228
+ visible }`. Draws boxes that were produced somewhere else — see below.
229
+
230
+ ### Cine playback
231
+
232
+ A stack viewport loads each slice at the moment it is shown. That is right for scrolling and wrong
233
+ for anything that moves on its own: at 15 frames a second the decoder never keeps up, and playback
234
+ stutters through whatever happens to be cached. So the two halves go together — prepare the stack,
235
+ then play it.
236
+
237
+ ```vue
238
+ <script setup lang="ts">
239
+ const imageIds = ref<string[]>([])
240
+ const imageIndex = ref(0)
241
+
242
+ const prefetch = useImagePrefetch()
243
+ const cine = useCinePlayer(imageIndex, () => imageIds.value.length, { frameRate: 15 })
244
+
245
+ // Prepare as soon as a stack has loaded, rather than making the user ask:
246
+ // by the time they reach for play, the film is already in the cache. A new
247
+ // stack cancels the run the old one started.
248
+ watch(imageIds, (ids) => {
249
+ cine.pause()
250
+ prefetch.reset()
251
+ if (ids.length > 1) prefetch.prepare(ids, { order: 'forward' })
252
+ })
253
+ </script>
254
+
255
+ <template>
256
+ <CornerstoneViewport
257
+ :image-ids="imageIds"
258
+ :image-index="imageIndex"
259
+ @image-index-change="imageIndex = $event"
260
+ />
261
+ <button @click="cine.toggle()">{{ cine.playing.value ? 'Pause' : 'Play' }}</button>
262
+ <progress :value="prefetch.progress.value" />
263
+ </template>
264
+ ```
265
+
266
+ `prepare(imageIds, options)` resolves once every image has been through the loader, and reports
267
+ `{ loaded, failed, total, cancelled, cacheFull }`. Follow it live on the refs instead — `progress`
268
+ is 0..1, and `pending` is true while it runs. Images already in the cache are counted and skipped,
269
+ so preparing again after adding slices only fetches the new ones.
270
+
271
+ | Option | Default | |
272
+ | --- | --- | --- |
273
+ | `from` | `0` | index to start from — pass the slice on screen |
274
+ | `order` | `'outward'` | `'outward'` fans out either side of `from`; `'forward'` runs to the end and wraps |
275
+ | `concurrency` | `4` | images decoded at once |
276
+ | `priority` | `0` | passed to Cornerstone's loader; lower runs sooner |
277
+ | `requestType` | `'prefetch'` | the request class the pool serves after anything the user waits on |
278
+ | `onProgress` | — | `{ loaded, failed, total }` after each image settles |
279
+
280
+ One instance runs one prepare at a time: a second call cancels the first, which is what you want
281
+ when the user switches series mid-load. `cancel()` also abandons the requests in flight, and the
282
+ composable calls it for you when its scope is disposed. Counters keep their values so a
283
+ half-prepared stack can still say so; `reset()` clears them.
284
+
285
+ A slice that will not decode is counted in `failed` and the rest continue. If the image cache fills
286
+ up, the run stops rather than thrashing — each new slice would only evict one just decoded — and
287
+ `cacheFull` says so. Raise the ceiling with `cache.setMaxCacheSize()` if a whole study has to fit.
288
+
289
+ `useCinePlayer()` owns no images and no viewport. It advances the index ref, and whatever is bound
290
+ to that index follows, so a scrubber, the keyboard and the player all move the same value. Frames
291
+ are paced with `requestAnimationFrame` measured against the clock, so the rate does not drift and a
292
+ backgrounded tab stops advancing instead of spending the battery. A frame that arrives late is
293
+ shown late and the ones behind it are dropped, rather than the stack sprinting to catch up.
294
+
295
+ | Option | Default | |
296
+ | --- | --- | --- |
297
+ | `frameRate` | `15` | clamped to 1..60; `frameRate` is writable, so a control can `v-model` it |
298
+ | `loop` | `true` | `false` stops at the end |
299
+ | `bounce` | `false` | reverse at each end instead of jumping — what a cardiac cine wants |
300
+ | `direction` | `1` | `-1` plays towards the first image |
301
+
302
+ `canPlay` is false for a stack of one, because a single image is a picture rather than a film. Draw
303
+ the transport from it rather than only disabling by it — a play button on a series of one has
304
+ nothing to do in any state, so leaving it out says so more clearly than greying it out. The player
305
+ pauses itself when the stack is replaced or emptied.
306
+
307
+ ### Imported annotations
308
+
309
+ Boxes from a reporting service or a detection model are drawn onto the stack with
310
+ `useDicomAnnotations()`. It takes the viewport — a ref, a getter, or the object itself — and boxes
311
+ in **image pixel coordinates**, each naming the slice it belongs to by **SOPInstanceUID**:
312
+
313
+ ```vue
314
+ <script setup lang="ts">
315
+ import type { Types as CoreTypes } from '@cornerstonejs/core'
316
+
317
+ const viewport = shallowRef<CoreTypes.IStackViewport | null>(null)
318
+ const { addZip } = useDicomFiles()
319
+ const { addBoxes, setVisible, visible } = useDicomAnnotations(viewport)
320
+
321
+ async function open(archive: Blob, report: Finding[]) {
322
+ await addZip(archive)
323
+
324
+ const { drawn, deferred, unresolved } = await addBoxes(
325
+ report.map(finding => ({
326
+ sopInstanceUid: finding.sopInstanceUid,
327
+ box: { x1: finding.left, y1: finding.top, x2: finding.right, y2: finding.bottom },
328
+ label: `${finding.name} ${Math.round(finding.confidence * 100)}%`,
329
+ uid: finding.id,
330
+ })),
331
+ { color: 'rgb(251, 191, 36)' },
332
+ )
333
+
334
+ if (unresolved.length) console.warn(`${unresolved.length} boxes name slices that are not loaded`)
335
+ }
336
+ </script>
337
+ ```
338
+
339
+ **SOPInstanceUID (0008,0018) is how a box finds its slice**, because it is the only identifier that
340
+ survives leaving the viewer: `dicomfile:` imageIds are handed out by the loader as files are
341
+ registered, so the same study opened twice has a different set of them. `addFiles()` and `addZip()`
342
+ record the UID of every file whose header they read, which is every file on the default settings —
343
+ `sort: false` and `sort: 'name'` skip header reading, and a file that was not indexed cannot be
344
+ found by UID. Boxes naming a slice that is not loaded come back in `unresolved` instead of throwing,
345
+ because a study and a report disagreeing about which slices exist is a normal thing to show the
346
+ user rather than an error.
347
+
348
+ **Boxes are placed lazily.** Pixel coordinates become world coordinates through the slice's own
349
+ image plane, and the metadata provider only holds that once the slice has been loaded. A box whose
350
+ slice is already loaded is drawn immediately and counted in `drawn`; the rest are held and counted
351
+ in `deferred`, then drawn the first time their slice is shown. Loading 500 slices up front to place
352
+ boxes the user may never scroll to would cost more than it saves.
353
+
354
+ **They are read-only.** Boxes are locked, so they cannot be dragged, resized or deleted, and they
355
+ are drawn with a RectangleROI instance of their own — registered as `DicomBoxOverlay` — rather than
356
+ with RectangleROI itself. That keeps the user's own measurements separately styled and separately
357
+ selectable, and lets an imported box show the label it arrived with instead of the area and mean a
358
+ measurement shows. Pass `{ locked: false }` if they should be editable.
359
+
360
+ `setVisible(false)` hides the boxes without discarding them; `clear()` removes every box this
361
+ composable drew and leaves the user's own measurements alone. Boxes are keyed to the imageIds that
362
+ were loaded when they were added, so clear them when the stack changes.
363
+
364
+ | Option | Default | |
365
+ | --- | --- | --- |
366
+ | `color` | Cornerstone's locked colour | any CSS colour; applies to imported boxes only |
367
+ | `locked` | `true` | `false` makes them editable |
368
+
369
+ ### Annotation files
370
+
371
+ `addJson(file)` is the same thing starting from a JSON file rather than from objects you have
372
+ already built. Open the DICOM images first — the boxes are matched to the slices that are loaded —
373
+ then hand it whatever the user picked, dropped or you fetched: a `File`, a `Blob`, the JSON text, or
374
+ an object that is already parsed.
375
+
376
+ ```vue
377
+ <script setup lang="ts">
378
+ const viewport = shallowRef<CoreTypes.IStackViewport | null>(null)
379
+ const { addJson } = useDicomAnnotations(viewport)
380
+
381
+ async function openReport(file: File) {
382
+ const { drawn, deferred, report } = await addJson(file, { color: 'rgb(251, 191, 36)' })
383
+
384
+ if (drawn + deferred === 0 && report.boxes.length) {
385
+ // Parsed fine, matched nothing: normally a report for a different series.
386
+ console.warn(`report belongs to series ${report.seriesInstanceUid}`)
387
+ }
388
+ }
389
+ </script>
390
+ ```
391
+
392
+ Two layouts are read. The first is this module's own — a list of boxes, either as the whole document
393
+ or under `boxes` / `annotations`:
394
+
395
+ ```json
396
+ [{ "sopInstanceUid": "1.2.3", "box": { "x1": 10, "y1": 20, "x2": 80, "y2": 90 }, "label": "Nodule 1" }]
397
+ ```
398
+
399
+ The second is a detector's output: findings, each holding the slices it was seen on, optionally
400
+ wrapped in `predictions`.
401
+
402
+ ```json
403
+ { "predictions": { "findings": [
404
+ { "confidence": 0.87, "label": 1, "slice_findings": [
405
+ { "sop_instance_uid": "1.2.3",
406
+ "bounding_box": { "upper_left_x": 10, "upper_left_y": 20,
407
+ "lower_right_x": 80, "lower_right_y": 90 } }
408
+ ] }
409
+ ] } }
410
+ ```
411
+
412
+ Field names are read in both `camelCase` and `snake_case`, and a box may be written as two opposite
413
+ corners, as `xMin`/`xMax`, as an origin with `width` and `height`, or as a bare `[x1, y1, x2, y2]`.
414
+ A finding's own `bounding_box` is deliberately ignored: it is in the volume the model ran on, not in
415
+ the pixel space of the images on screen, so only `slice_findings` is read. Anything else throws with
416
+ a message naming what was expected; individual rows that cannot be read are counted in
417
+ `report.malformed` rather than discarding the rest of the file.
418
+
419
+ Boxes read from the detector shape are captioned with the finding's name — or its class — and its
420
+ confidence, and are given a uid of `finding-<n>-<sopInstanceUid>`, so opening the same report twice
421
+ replaces its boxes instead of stacking a second set on the first. Pass `label` to write your own
422
+ caption, and `minConfidence` to drop findings below a threshold.
423
+
424
+ | Option | Default | |
425
+ | --- | --- | --- |
426
+ | `minConfidence` | `0` | 0..1; detector shape only |
427
+ | `label` | name + confidence | `(finding) => string` |
428
+
429
+ `addJson` also takes `color` and `locked`, which it passes to `addBoxes`. It returns what `addBoxes`
430
+ returns, plus `report`: `{ boxes, format, findings, filtered, malformed, seriesInstanceUid }`. For
431
+ files served over HTTP, call `indexUrls()` before drawing — `toImageId()` builds an imageId without
432
+ reading the file, so nothing would know its SOPInstanceUID.
433
+
434
+ ## Building a viewer
435
+
436
+ The composables above are the primitives. Assembling them into something a
437
+ radiologist can use — open a study, switch series, scrub, play, report what
438
+ went wrong — is the same work in every application, so it is here too, in a
439
+ second tier of composables that produce **state and translated captions but no
440
+ markup**. You bring the buttons.
441
+
442
+ ```vue
443
+ <script setup lang="ts">
444
+ import type { Types as CoreTypes } from '@cornerstonejs/core'
445
+
446
+ const study = useDicomStudy()
447
+ const viewport = shallowRef<CoreTypes.IStackViewport | null>(null)
448
+ const cine = useStackCine(study.imageIds, study.imageIndex)
449
+ const report = useAnnotationReport(viewport)
450
+ const tools = useCornerstoneTools()
451
+
452
+ useViewerShortcuts({
453
+ isEnabled: () => study.imageIds.value.length > 0,
454
+ step: study.step,
455
+ first: () => (study.imageIndex.value = 0),
456
+ last: () => (study.imageIndex.value = study.maxIndex.value),
457
+ stepSeries: study.stepSeries,
458
+ setTool: tools.setActive,
459
+ resetViewport: () => viewport.value?.resetCamera(),
460
+ togglePlay: cine.toggle,
461
+ toggleHelp: () => {},
462
+ })
463
+ </script>
464
+
465
+ <template>
466
+ <input type="file" multiple @change="study.openAny(Array.from($event.target.files ?? []))">
467
+ <p v-if="study.problemText.value">{{ study.problemText }}</p>
468
+
469
+ <div style="height: 600px">
470
+ <CornerstoneViewport
471
+ :image-ids="study.imageIds.value"
472
+ :image-index="study.imageIndex.value"
473
+ @ready="viewport = $event"
474
+ @image-index-change="study.imageIndex.value = $event"
475
+ />
476
+ </div>
477
+
478
+ <button :disabled="cine.buffering.value" @click="cine.toggle">Play</button>
479
+ <small>{{ study.sourceLabel }} · {{ cine.statusText }}</small>
480
+ </template>
481
+ ```
482
+
483
+ **`useDicomStudy()`** → `{ ready, imageIds, imageIndex, maxIndex, series, activeSeriesUid, source,
484
+ sourceLabel, busy, progress, progressLabel, progressValue, problemText, rejectedText, openAny,
485
+ openFiles, openZip, openUrls, selectSeries, step, stepSeries, clear, setProblem }`.
486
+
487
+ Everything about the stack on screen. `openAny(files)` is the one way in for a picked or dropped
488
+ selection: an archive is unpacked and anything else is vetted file by file, on content rather than
489
+ on the name, so a PNG renamed `.dcm` is turned away here with its name attached instead of failing
490
+ later at render with an error that points at the viewport. `openUrls(urls)` is the path for a study
491
+ served by your own backend; it reads each file's headers afterwards so an annotation report can find
492
+ the slices. The `*Label` and `*Text` members are computed, already translated, and re-render on a
493
+ locale switch — they hold data rather than a finished string for exactly that reason.
494
+
495
+ **`useStackCine(imageIds, imageIndex)`** → `{ playing, canPlay, frameRate, loop, preparing,
496
+ prepared, buffering, percent, statusText, play, pause, toggle, prepare }`.
497
+
498
+ [`useCinePlayer()`](#cine-playback) and `useImagePrefetch()` wired together, which is what a
499
+ transport actually needs: a stack viewport loads each slice as it shows it, so playing a stack
500
+ nobody has prepared runs at the speed of the decoder rather than at the frame rate that was asked
501
+ for. A stack prepares itself as soon as it loads, and `buffering` holds play until
502
+ `PLAY_READY_RATIO` — half — of it is decoded. Half is a comfortable head start, because the film and
503
+ the prefetch both run from the first image.
504
+
505
+ **`useAnnotationReport(viewport)`** → `{ open, toggle, reset, busy, loaded, visible, summaryText,
506
+ failure, drawn, pending }`.
507
+
508
+ [`useDicomAnnotations()`](#imported-annotations) as a user action: which file is open, whether its
509
+ boxes are showing, and what to say afterwards. The case worth having a sentence for is a file that
510
+ parsed perfectly and placed nothing, which almost always means the report belongs to another series.
511
+ Call `reset()` when the stack changes — boxes are keyed to the imageIds that were loaded when they
512
+ were drawn.
513
+
514
+ **`useMeasurements(viewport)`** → `{ list, refresh, remove, deleteSelected, deleteOnSlice,
515
+ deleteAll, total, onSlice, selected }`.
516
+
517
+ Deleting the measurements the reader drew. Cornerstone gives every annotation tool a way to draw and
518
+ no way to un-draw, so a stray Length or a mis-clicked Probe stays on the slice for as long as the
519
+ study is open. This is the other half: the counts a toolbar needs to decide what to offer, and the
520
+ three removals worth offering.
521
+
522
+ ```vue
523
+ <script setup lang="ts">
524
+ const viewport = shallowRef<CoreTypes.IStackViewport | null>(null)
525
+ const measurements = useMeasurements(viewport)
526
+ </script>
527
+
528
+ <template>
529
+ <!-- Nothing drawn yet, nothing to offer. -->
530
+ <template v-if="measurements.total.value">
531
+ <button @click="measurements.deleteOnSlice()">
532
+ Clear this image ({{ measurements.onSlice.value }})
533
+ </button>
534
+ <button @click="measurements.deleteAll()">
535
+ Clear all ({{ measurements.total.value }})
536
+ </button>
537
+ </template>
538
+ </template>
539
+ ```
540
+
541
+ Only the reader's own work is in scope. Boxes drawn by [`useDicomAnnotations()`](#imported-annotations)
542
+ are excluded by tool name and anything locked is excluded outright, so a delete button cannot erase
543
+ an imported report — `useAnnotationReport().reset()` is what takes those away. Pass `{ keep: [...] }`
544
+ to exclude tools of your own as well.
545
+
546
+ Scope is the stack on screen, not the annotation state manager, which outlives a study: a
547
+ measurement drawn on a series that has since been closed is neither counted nor deleted. The counts
548
+ follow the annotation state — they move when the user draws, deletes, selects or scrolls — so a
549
+ toolbar can read them directly rather than recomputing them.
550
+
551
+ **`useViewerShortcuts(handlers, { tools })`** and **`releaseFocus(event)`**.
552
+
553
+ The [OHIF](https://ohif.org) keymap: arrows and Home/End through the stack, PageUp/PageDown through
554
+ the series, `W`/`P`/`Z` and friends for tools, `R` to reset, Space or `C` for cine, `?` for help.
555
+ Delete and Backspace remove the selected measurements when you supply `deleteMeasurement` — both
556
+ keys, because the one a reader reaches for is whichever their keyboard has; leave the handler out
557
+ and the keys stay with the browser.
558
+ Bindings match `event.code`, the physical key, not `event.key`: on a Persian layout the W key
559
+ reports `'ش'` and on a Russian one `'ц'`, so a `key`-based binding silently stops working for
560
+ anyone not on QWERTY. Pass `tools` so your toolbar and the keymap read from one table and the key a
561
+ button advertises is the key that works; the default is `DEFAULT_TOOL_SHORTCUTS`. Widgets that read
562
+ the keyboard themselves — anything with a text, combobox, listbox or menu role, and anything inside
563
+ an open dialog — are left alone. `releaseFocus` goes on a toolbar's root: a button clicked with the
564
+ mouse keeps focus and then swallows the spacebar, so picking a tool and pressing play appears to do
565
+ nothing.
566
+
567
+ **`guardDicomFiles(files)`** → `{ accepted, rejected }`. The content check `openFiles()` runs, on
568
+ its own, for an application that sorts its own selection.
569
+
570
+ ## Options
571
+
572
+ ```ts
573
+ export default defineNuxtConfig({
574
+ modules: ['nuxt-cornerstone'],
575
+ cornerstone: {
576
+ autoInit: true,
577
+ core: {}, // -> coreInit(config)
578
+ dicomImageLoader: {}, // -> dicomImageLoaderInit(options)
579
+ tools: { enabled: true, register: [/* class names */] },
580
+ i18n: { // see Internationalisation
581
+ locale: 'en',
582
+ fallbackLocale: 'en',
583
+ messages: {},
584
+ numberingSystem: 'auto',
585
+ detect: false, // or true to follow the host app's locale
586
+ },
587
+ viteCommonjs: true,
588
+ prefix: 'Cornerstone',
589
+ renderingEngineId: 'nuxt-cornerstone',
590
+ toolGroupId: 'nuxt-cornerstone-tools',
591
+ },
592
+ })
593
+ ```
594
+
595
+ Registered tools by default: `WindowLevelTool`, `PanTool`, `ZoomTool`, `StackScrollTool`,
596
+ `LengthTool`, `RectangleROITool`, `EllipticalROITool`, `ProbeTool`. Add any other export of
597
+ `@cornerstonejs/tools` to `tools.register`.
598
+
599
+ ### Callbacks
600
+
601
+ Options reach the browser through `runtimeConfig`, which is serialized into the page payload, so
602
+ functions cannot travel that way — `dicomImageLoader.beforeSend`, `core.peerImport` and friends. The
603
+ module warns at build time and drops them. Pass them at runtime instead:
604
+
605
+ ```ts
606
+ // nuxt.config.ts → cornerstone: { autoInit: false }
607
+
608
+ // app/plugins/cornerstone.client.ts
609
+ export default defineNuxtPlugin(() => {
610
+ ensureCornerstone({
611
+ dicomImageLoader: {
612
+ beforeSend: xhr => ({ Authorization: `Bearer ${useAuth().token}` }),
613
+ },
614
+ })
615
+ })
616
+ ```
617
+
618
+ ## Internationalisation
619
+
620
+ The module ships `en` and `fa` (Farsi) and depends on no i18n library, because a viewer component
621
+ should not force one on the app that installs it. Both catalogues are bundled — they are a few
622
+ hundred bytes each, and the strings are needed synchronously, on paths like `asZipError()` that have
623
+ to return an `Error` rather than a promise of one.
624
+
625
+ ```ts
626
+ // nuxt.config.ts
627
+ export default defineNuxtConfig({
628
+ cornerstone: {
629
+ i18n: {
630
+ locale: 'fa',
631
+ fallbackLocale: 'en',
632
+ // Merged over the built-ins. Override a string, add a locale, or
633
+ // register keys of your own — `t()` resolves anything in here.
634
+ messages: {
635
+ fa: { 'series.unnamed': 'سری ناشناس' },
636
+ },
637
+ // 'auto' (default) gives Farsi its Persian-Indic digits: ۱۲ تصویر.
638
+ // 'latn' pins every locale to 0-9, for users who cross-reference slice
639
+ // numbers against another system.
640
+ numberingSystem: 'auto',
641
+ },
642
+ },
643
+ })
644
+ ```
645
+
646
+ ```vue
647
+ <script setup lang="ts">
648
+ const { t, n, locale, dir, isRtl, availableLocales } = useCornerstoneI18n()
649
+ </script>
650
+
651
+ <template>
652
+ <!-- `locale` is writable, and every viewer in the app follows it. -->
653
+ <select v-model="locale">
654
+ <option v-for="code in availableLocales" :key="code" :value="code">{{ code }}</option>
655
+ </select>
656
+ <p>{{ t('series.unnamed') }} — {{ n(1234) }}</p>
657
+ </template>
658
+ ```
659
+
660
+ Set `<html lang dir>` from the same source, so the chrome flips with the locale:
661
+
662
+ ```ts
663
+ // app.vue
664
+ const { locale, dir } = useCornerstoneI18n()
665
+ useHead({ htmlAttrs: { lang: locale, dir } }) // pass the refs, not `.value`
666
+ ```
667
+
668
+ ### Following the host application's locale
669
+
670
+ By default the module uses the locale you configure and nothing else. Set `detect` and it reads the
671
+ locale from the app that installed it instead:
672
+
673
+ ```ts
674
+ // nuxt.config.ts
675
+ cornerstone: {
676
+ i18n: { detect: true }, // or pick the sources: ['i18n', 'html']
677
+ }
678
+ ```
679
+
680
+ `true` tries each source in order and stops at the first that answers:
681
+
682
+ | Source | Reads | Follows changes |
683
+ | --- | --- | --- |
684
+ | `'i18n'` | `nuxtApp.$i18n.locale` — `@nuxtjs/i18n` and vue-i18n, in either Composition or legacy mode | yes, reactively |
685
+ | `'html'` | the `lang` attribute on `<html>`, which nearly every i18n library sets | yes, via `MutationObserver` |
686
+ | `'navigator'` | the browser's preferred language | no, read once at startup |
687
+
688
+ Nothing is imported to do this — `$i18n` is duck-typed, so the module gains no dependency and simply
689
+ declines when no i18n library is installed.
690
+
691
+ **Only a locale the module has a catalogue for is adopted.** An app running in a language nobody has
692
+ translated keeps the configured locale, rather than flipping text direction under the viewer to go
693
+ on showing English anyway. A host that *starts* in such a language is never hooked up at all, so it
694
+ cannot take the module over later either.
695
+
696
+ Two things worth knowing:
697
+
698
+ - **Detection is client-side.** The locale is module-scoped state, which on the server is shared by
699
+ every in-flight request — following a per-request locale there would let one request's language
700
+ leak into another's. During SSR the configured locale is used, so set `cornerstone.i18n.locale` to
701
+ whatever your app's default is and detection takes over after hydration. If you need a genuinely
702
+ per-request locale, use `i18n: false` and the translator below: your own i18n has request scope
703
+ and this does not.
704
+ - **Do not use the `'html'` source if you set `<html lang>` *from* this module's locale**, as the
705
+ playground does. It is a cycle. It settles, because adopting the locale that is already active
706
+ changes nothing, but you then have no source of truth.
707
+
708
+ ### What gets translated
709
+
710
+ Everything the module produces itself: the errors it throws, the series labels
711
+ `useDicomFiles()` builds — `Series 3 (CT, 12 images)` becomes `سری ۳ (CT، ۱۲ تصویر)` — and every
712
+ caption the [viewer composables](#building-a-viewer) return, under the `study.*`, `cine.*` and
713
+ `report.*` keys. Identifiers stay in English inside every translation, because
714
+ `cornerstone.autoInit` and `ensureCornerstone()` are things you have to type or search for.
715
+
716
+ Those captions are computed from state rather than stored once, so they are rewritten on a locale
717
+ switch. That is the one place the rule below does not apply.
718
+
719
+ An error is translated when it is **thrown**, not when it is displayed. Switching locale does not
720
+ rewrite an error already sitting in a `ref`.
721
+
722
+ ### RTL and the viewport
723
+
724
+ `<CornerstoneViewport>` sets `dir="ltr"` on itself and you should leave it there. In an RTL app the
725
+ surrounding chrome flips, but a DICOM image must not: left and right are facts about the patient,
726
+ and mirroring one turns a left-sided finding into a right-sided one. It is a default rather than a
727
+ hard-coded value, so an app with its own reason to change it still can by passing `dir`.
728
+
729
+ ### Using your own i18n instead
730
+
731
+ If the app already runs vue-i18n, `@nuxtjs/i18n` or anything else, hand it these strings:
732
+
733
+ ```ts
734
+ // nuxt.config.ts → cornerstone: { i18n: false }
735
+
736
+ // app/plugins/cornerstone-i18n.client.ts
737
+ export default defineNuxtPlugin(() => {
738
+ const { t } = useI18n()
739
+ const { setTranslator } = useCornerstoneI18n()
740
+ // Return `undefined` for a key you do not own and the built-in English
741
+ // answers instead, so you can take over three strings or all of them.
742
+ setTranslator((key, params) => (te(key) ? t(key, params) : undefined))
743
+ })
744
+ ```
745
+
746
+ `i18n: false` keeps the module on English and makes `setLocale()` a no-op, so there is no second
747
+ locale quietly tracking alongside yours.
748
+
749
+ The locale is module-scoped state, shared by every request on the server. That is correct for a
750
+ locale fixed at build time and wrong for a per-request one, so `setLocale()` is a client-side call.
751
+ For per-request locales, keep `i18n: false` and let your own i18n — which has request scope — feed
752
+ the translator.
753
+
754
+ ### Adding a locale
755
+
756
+ Nothing in the module is specific to `en` and `fa`. Plural forms come from `Intl.PluralRules`, so a
757
+ catalogue supplies whichever CLDR categories its language uses and missing ones fall back to
758
+ `other`; text direction is derived from the language subtag.
759
+
760
+ ```ts
761
+ messages: {
762
+ ar: {
763
+ 'series.unnamed': 'سلسلة بدون اسم',
764
+ 'series.images': { zero: 'لا صور', one: 'صورة واحدة', two: 'صورتان', few: '{count} صور', other: '{count} صورة' },
765
+ },
766
+ }
767
+ ```
768
+
769
+ ## What the module does to Vite
770
+
771
+ On the client build only:
772
+
773
+ - **`optimizeDeps.exclude: ['@cornerstonejs/dicom-image-loader']`.** Each decoder locates its binary
774
+ with a bare `@cornerstonejs/codec-*` specifier inside `new URL(..., import.meta.url)`. Rollup
775
+ resolves that and emits the binary; esbuild does not, and dev prebundling is esbuild. Left in, the
776
+ request goes to a path that does not exist, the SPA fallback answers with `index.html`, and you
777
+ get `CompileError: WebAssembly.instantiate(): expected magic word 00 61 73 6d, found 3c 21 64 6f`
778
+ — `3c 21 64 6f` is `<!do`.
779
+ - **`optimizeDeps.include`** for `dicom-parser` (CommonJS), `fflate`, `@cornerstonejs/core`,
780
+ `@cornerstonejs/tools`, `@cornerstonejs/metadata` and `@cornerstonejs/utils`. Two reasons. Vite
781
+ would discover core and tools on the first dynamic import anyway, but discovering them *while the
782
+ browser is importing them* re-bundles, changes the dep hash and invalidates the in-flight URLs, so
783
+ the first load fails with "error loading dynamically imported module" and only recovers through a
784
+ full reload. And `metadata`/`utils` are singletons holding the metadata provider registry: reached
785
+ only transitively, the optimizer inlines one copy into core's chunk while the excluded image loader
786
+ resolves a second raw copy, the two stop sharing a registry, and every `wadouri` load fails with
787
+ `no pixel data in NATURALIZED`. Naming them makes them shared chunks. `fflate` is here for the
788
+ first reason too: it is imported dynamically the first time someone opens a ZIP, which is a click
789
+ rather than a page load, so discovering it only then would reload the page out from under the
790
+ archive the user just picked.
791
+ - **`worker.format: 'es'`.** The loader spawns
792
+ `new Worker(new URL('./decodeImageFrameWorker.js', import.meta.url), { type: 'module' })`.
793
+ - **`assetsInclude: ['**/*.wasm']`.**
794
+ - **`@originjs/vite-plugin-commonjs`** (`viteCommonjs: true`, on by default). The four Emscripten
795
+ codec bundles are UMD/CommonJS, and they are served raw because the loader that imports them is
796
+ excluded from prebundling. Without this, initialisation fails with
797
+ `doesn't provide an export named: 'default'`. Turn it off only if you supply your own interop.
798
+
799
+ It also adds the browser-only packages to `nitro.externals.external` so the server bundle cannot
800
+ inline them.
801
+
802
+ ## Troubleshooting
803
+
804
+ **Deflated Explicit VR Little Endian (1.2.840.10008.1.2.1.99) fails to load** with `no pixel data in
805
+ NATURALIZED`. This is upstream: Cornerstone3D 5's default metadata path naturalises Part 10 with
806
+ dcmjs's `AsyncDicomReader`, which does not inflate the deflated dataset — it reads no elements past
807
+ the file meta group. The legacy path parses through `dicom-parser` with a pako inflater and handles
808
+ it:
809
+
810
+ ```ts
811
+ cornerstone: { dicomImageLoader: { useLegacyMetadataProvider: true } }
812
+ ```
813
+
814
+ That path is deprecated upstream, so treat it as a workaround for this transfer syntax rather than a
815
+ default.
816
+
817
+ **`Module "fs"/"path" has been externalized for browser compatibility`** during build, pointing at
818
+ the codec packages. Harmless — the Emscripten glue only reaches for them under Node. The equivalent
819
+ webpack fix is `config.resolve.fallback = { fs: false }`.
820
+
821
+ **Serving under a subpath, or from a CDN.** Point the loader at the binaries yourself:
822
+
823
+ ```ts
824
+ cornerstone: {
825
+ dicomImageLoader: { wasmBasePath: '/assets/cs-wasm/' },
826
+ }
827
+ ```
828
+
829
+ One root for every codec, containing `charlswasm_decode.wasm`, `libjpegturbowasm_decode.wasm`,
830
+ `openjpegwasm_decode.wasm` and `openjphjs.wasm`, copied from the `dist` directory of each
831
+ `@cornerstonejs/codec-*` package.
832
+
833
+ **`No known conditions for "./types" specifier in "@cornerstonejs/core"`** at build time. Something
834
+ is importing `@cornerstonejs/core/types` as a *value*; that subpath only has a `types` condition. Use
835
+ `import type`.
836
+
837
+ **Tool names look minified** (`LengthTool` registered as `FE`). Set `build.minify: false`, or
838
+ register tools by their `toolName` string.
839
+
840
+ ## Scope
841
+
842
+ This release covers 2D stack viewing: initialisation, `<CornerstoneViewport>`, tool groups, the
843
+ [viewer composables](#building-a-viewer) that assemble them into a working viewer,
844
+ annotations — drawn by the user, or imported as read-only boxes — and the `wadouri:` /
845
+ `dicomfile:` loading paths. Imported annotations are boxes only, and are read-only: nothing writes
846
+ measurements back out, and DICOM SR and Presentation State are not read. Volume and MPR viewports, segmentation,
847
+ `@cornerstonejs/polymorphic-segmentation` and `@cornerstonejs/labelmap-interpolation` are not wired
848
+ up yet — the last two each need their own `optimizeDeps.exclude` entry and a `peerImport` callback.
849
+
850
+ No chrome ships either, and that is deliberate: a header, a series list and a transport are a
851
+ handful of buttons over the composables above, and every application wants them to look like itself.
852
+ Making a component library a peer dependency of a DICOM viewport would be a large tax on anyone who
853
+ only wants the viewport. The [playground](#playground) is the reference implementation, written to
854
+ be read and copied.
855
+
856
+ ## Playground
857
+
858
+ ```bash
859
+ pnpm install
860
+ pnpm dev:prepare
861
+ pnpm samples # downloads sample DICOM into playground/public/samples/
862
+ pnpm dev # http://localhost:3000 — or /?samples to load the stack immediately
863
+ ```
864
+
865
+ The playground UI is built from PrimeVue components with Tailwind for layout. The module itself
866
+ ships no UI dependency and no framework requirement — `<CornerstoneViewport>` carries only the
867
+ rules it needs to function, and has a default slot for everything else, so the consuming
868
+ application supplies the look. The playground's own stylesheet names no source inside the package,
869
+ which is the point: what works here works in an app that has never heard of Tailwind.
870
+
871
+ What is left in `playground/app/` is therefore chrome and nothing else: the header, the series
872
+ list, the tool rail, the scrubber and the shortcuts dialog, plus the table of tools that the rail
873
+ and the keymap share. Everything underneath them — opening a study, switching series, playback,
874
+ prefetch, the annotation report, the keyboard — is [`useDicomStudy()` and its
875
+ neighbours](#building-a-viewer), and is imported from the module exactly as it would be in your own
876
+ application. Copying a component out of `playground/app/components/` into your project is meant to
877
+ work: it is ordinary PrimeVue markup over composables you already have.
878
+
879
+ The header carries an **English / فارسی** switch. It flips `<html lang dir>`, loads a Persian face,
880
+ and renders counts in Persian-Indic digits, while the viewport stays left-to-right. The demo's own
881
+ strings live in `playground/i18n/messages.ts` and are registered through
882
+ `cornerstone.i18n.messages`, which is the point of it: the catalogue takes arbitrary keys, so an app
883
+ can translate its chrome from one source without a second i18n library.
884
+
885
+ **Open annotations…** appears once there are images on screen. It reads a JSON report and draws it
886
+ over the stack, and the button beside it then shows and hides what it drew. A report can also be
887
+ dropped on the stage, on its own or alongside the study it belongs to, in which case it is drawn as
888
+ soon as the images finish loading. Boxes are discarded whenever the stack changes, since they are
889
+ keyed to the imageIds that were loaded when they were drawn.
890
+
891
+ The rail grows a **delete** section as soon as the reader has drawn something. **Click a
892
+ measurement to select it** — any tool selects, so this works without leaving window/level, and
893
+ shift-click adds to the selection — and the first button deletes what is selected, as Delete and
894
+ Backspace also do. Below it, an eraser clears the measurements on the image on screen and a trash
895
+ button clears the series, the latter asking first because it can take away work that is not in view.
896
+ Each button appears only when it has something to do: the eraser stays away while every measurement
897
+ is on the slice anyway, since it would then be the trash button under another icon, and while
898
+ nothing is selected the first slot holds the hint that says a measurement can be clicked at all.
899
+ Imported boxes are not measurements: none of this touches a report, which is shown and hidden from
900
+ the header and discarded with the stack.
901
+
902
+ The footer is the transport. **Play** — or the spacebar, or `C` — runs the stack as a film at the
903
+ rate chosen beside it, looping unless the loop button is turned off, and moving the scrubber or the
904
+ arrow keys takes over from it. Space is the one place this demo parts company with the OHIF keymap,
905
+ which resets the viewport with it; Space means play/pause everywhere else a person has used a media
906
+ player, so the reset moves to `R`.
907
+
908
+ Shortcuts are matched on the physical key rather than the character it types, so they work the same
909
+ on a Persian, Russian or French layout — `W` is the key marked W, whatever it produces. They stay
910
+ out of the way of anything that reads the keyboard itself: a text field, the frame-rate select, the
911
+ series list, the scrubber's own arrow handling, and an open dialog.
912
+
913
+ A stack **prepares itself** as soon as it has loaded — decoding every image into Cornerstone's
914
+ cache, in playback order, so that playback and scrolling never wait for the loader — and the footer
915
+ shows how far that has got. Play waits for half of it: until then the button holds a spinner rather
916
+ than a play icon, and the keys do nothing. Half a stack is already a comfortable head start, because
917
+ the film and the prefetch both run from the first image, and the rest arrives while that half plays. A prepare that stops early — cancelled by another series, or cut short by a full
918
+ cache — releases play whatever it managed, so the button never spins on a run that is not coming
919
+ back. The **Prepare** button is the way back when a run did
920
+ not finish, because another series interrupted it or the image cache cut it short, and it
921
+ disappears once there is nothing left to fetch. None of this appears for a series of a single
922
+ image, which has nothing to play and nothing to fetch ahead of.
923
+
924
+ The samples are the MIT-licensed test images from the Cornerstone3D repository: one CT slice in eight
925
+ transfer syntaxes. Loading them as a single stack exercises every decoder — pako, RLE,
926
+ jpeg-lossless-decoder-js, and the libjpeg-turbo, charls and openjpeg WASM codecs — so it doubles as a
927
+ check that the worker and WASM wiring is intact.
928
+
929
+ ## Licence
930
+
931
+ MIT. Cornerstone3D is MIT, maintained by the Open Health Imaging Foundation.
932
+
933
+ **Not a medical device.** Nothing here is cleared for clinical use.