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.
- package/LICENSE +21 -0
- package/README.md +933 -0
- package/dist/module.d.mts +22 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +168 -0
- package/dist/runtime/annotation-json.d.ts +76 -0
- package/dist/runtime/annotation-json.js +176 -0
- package/dist/runtime/components/CornerstoneViewport.d.vue.ts +57 -0
- package/dist/runtime/components/CornerstoneViewport.vue +227 -0
- package/dist/runtime/components/CornerstoneViewport.vue.d.ts +57 -0
- package/dist/runtime/composables/useAnnotationReport.d.ts +28 -0
- package/dist/runtime/composables/useAnnotationReport.js +66 -0
- package/dist/runtime/composables/useCinePlayer.d.ts +52 -0
- package/dist/runtime/composables/useCinePlayer.js +95 -0
- package/dist/runtime/composables/useCornerstone.d.ts +17 -0
- package/dist/runtime/composables/useCornerstone.js +30 -0
- package/dist/runtime/composables/useCornerstoneI18n.d.ts +24 -0
- package/dist/runtime/composables/useCornerstoneI18n.js +32 -0
- package/dist/runtime/composables/useCornerstoneTools.d.ts +21 -0
- package/dist/runtime/composables/useCornerstoneTools.js +105 -0
- package/dist/runtime/composables/useDicomAnnotations.d.ts +76 -0
- package/dist/runtime/composables/useDicomAnnotations.js +220 -0
- package/dist/runtime/composables/useDicomFiles.d.ts +87 -0
- package/dist/runtime/composables/useDicomFiles.js +234 -0
- package/dist/runtime/composables/useDicomGuard.d.ts +24 -0
- package/dist/runtime/composables/useDicomGuard.js +30 -0
- package/dist/runtime/composables/useDicomStudy.d.ts +122 -0
- package/dist/runtime/composables/useDicomStudy.js +222 -0
- package/dist/runtime/composables/useImagePrefetch.d.ts +78 -0
- package/dist/runtime/composables/useImagePrefetch.js +119 -0
- package/dist/runtime/composables/useMeasurements.d.ts +51 -0
- package/dist/runtime/composables/useMeasurements.js +138 -0
- package/dist/runtime/composables/useRenderingEngine.d.ts +6 -0
- package/dist/runtime/composables/useRenderingEngine.js +46 -0
- package/dist/runtime/composables/useStackCine.d.ts +50 -0
- package/dist/runtime/composables/useStackCine.js +62 -0
- package/dist/runtime/composables/useViewerShortcuts.d.ts +94 -0
- package/dist/runtime/composables/useViewerShortcuts.js +115 -0
- package/dist/runtime/cornerstone.d.ts +21 -0
- package/dist/runtime/cornerstone.js +100 -0
- package/dist/runtime/dicom-instances.d.ts +32 -0
- package/dist/runtime/dicom-instances.js +15 -0
- package/dist/runtime/dicom-zip.d.ts +95 -0
- package/dist/runtime/dicom-zip.js +149 -0
- package/dist/runtime/i18n/detect.d.ts +37 -0
- package/dist/runtime/i18n/detect.js +37 -0
- package/dist/runtime/i18n/index.d.ts +61 -0
- package/dist/runtime/i18n/index.js +145 -0
- package/dist/runtime/i18n/messages.d.ts +122 -0
- package/dist/runtime/i18n/messages.js +147 -0
- package/dist/runtime/plugin.client.d.ts +13 -0
- package/dist/runtime/plugin.client.js +31 -0
- package/dist/runtime/plugin.i18n.d.ts +12 -0
- package/dist/runtime/plugin.i18n.js +15 -0
- package/dist/runtime/types.d.ts +150 -0
- package/dist/runtime/types.js +10 -0
- package/dist/types.d.mts +29 -0
- package/package.json +76 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { onMounted, onUnmounted } from "vue";
|
|
2
|
+
export const DEFAULT_TOOL_SHORTCUTS = [
|
|
3
|
+
{ className: "WindowLevelTool", shortcut: "w" },
|
|
4
|
+
{ className: "PanTool", shortcut: "p" },
|
|
5
|
+
{ className: "ZoomTool", shortcut: "z" },
|
|
6
|
+
{ className: "LengthTool", shortcut: "m" },
|
|
7
|
+
{ className: "RectangleROITool", shortcut: "b" },
|
|
8
|
+
{ className: "EllipticalROITool", shortcut: "e" },
|
|
9
|
+
{ className: "ProbeTool", shortcut: "t" }
|
|
10
|
+
];
|
|
11
|
+
const OWNS_KEYBOARD = [
|
|
12
|
+
"input",
|
|
13
|
+
"textarea",
|
|
14
|
+
"select",
|
|
15
|
+
'[role="combobox"]',
|
|
16
|
+
'[role="listbox"]',
|
|
17
|
+
'[role="option"]',
|
|
18
|
+
'[role="searchbox"]',
|
|
19
|
+
'[role="spinbutton"]',
|
|
20
|
+
'[role="textbox"]',
|
|
21
|
+
'[role="menu"]',
|
|
22
|
+
'[role="menuitem"]'
|
|
23
|
+
].join(", ");
|
|
24
|
+
function ownsKeyboard(target) {
|
|
25
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
26
|
+
if (target.isContentEditable) return true;
|
|
27
|
+
return target.closest(OWNS_KEYBOARD) !== null;
|
|
28
|
+
}
|
|
29
|
+
const CORNERSTONE_VIEWPORT = "[data-viewport-uid][data-rendering-engine-uid]";
|
|
30
|
+
function isCornerstoneViewport(target) {
|
|
31
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
32
|
+
return target.closest(CORNERSTONE_VIEWPORT) !== null;
|
|
33
|
+
}
|
|
34
|
+
function isInDialog(target) {
|
|
35
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
36
|
+
return target.closest('[role="dialog"], [role="alertdialog"]') !== null;
|
|
37
|
+
}
|
|
38
|
+
function isActivatable(target) {
|
|
39
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
40
|
+
return target.closest('button, a, [role="button"], [role="option"]') !== null;
|
|
41
|
+
}
|
|
42
|
+
function codeForLetter(letter) {
|
|
43
|
+
return `Key${letter.toUpperCase()}`;
|
|
44
|
+
}
|
|
45
|
+
export function useViewerShortcuts(handlers, options = {}) {
|
|
46
|
+
const tools = options.tools ?? DEFAULT_TOOL_SHORTCUTS;
|
|
47
|
+
function onKeydown(event) {
|
|
48
|
+
if (event.defaultPrevented && !isCornerstoneViewport(event.target)) return;
|
|
49
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
50
|
+
if (ownsKeyboard(event.target)) return;
|
|
51
|
+
if (event.key === "?" || event.shiftKey && event.code === "Slash") {
|
|
52
|
+
handlers.toggleHelp();
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (isInDialog(event.target)) return;
|
|
57
|
+
if (!handlers.isEnabled()) return;
|
|
58
|
+
switch (event.code) {
|
|
59
|
+
case "ArrowDown":
|
|
60
|
+
handlers.step(1);
|
|
61
|
+
break;
|
|
62
|
+
case "ArrowUp":
|
|
63
|
+
handlers.step(-1);
|
|
64
|
+
break;
|
|
65
|
+
case "PageDown":
|
|
66
|
+
handlers.stepSeries(1);
|
|
67
|
+
break;
|
|
68
|
+
case "PageUp":
|
|
69
|
+
handlers.stepSeries(-1);
|
|
70
|
+
break;
|
|
71
|
+
case "Home":
|
|
72
|
+
handlers.first();
|
|
73
|
+
break;
|
|
74
|
+
case "End":
|
|
75
|
+
handlers.last();
|
|
76
|
+
break;
|
|
77
|
+
case "Space":
|
|
78
|
+
if (isActivatable(event.target)) return;
|
|
79
|
+
handlers.togglePlay();
|
|
80
|
+
break;
|
|
81
|
+
// `C` for cine, kept alongside Space for the same reason a media player
|
|
82
|
+
// has both a spacebar and a labelled button.
|
|
83
|
+
case "KeyC":
|
|
84
|
+
handlers.togglePlay();
|
|
85
|
+
break;
|
|
86
|
+
case "KeyR":
|
|
87
|
+
handlers.resetViewport();
|
|
88
|
+
break;
|
|
89
|
+
// Delete is the key for this everywhere, and Backspace is the one a Mac
|
|
90
|
+
// keyboard without a Delete key actually has. Backspace is only "go
|
|
91
|
+
// back" outside a text field in old browsers, and nothing here runs
|
|
92
|
+
// inside one — the widgets that own the keyboard have already been let
|
|
93
|
+
// through above — so taking it and marking it handled is safe.
|
|
94
|
+
case "Delete":
|
|
95
|
+
case "Backspace":
|
|
96
|
+
if (!handlers.deleteMeasurement) return;
|
|
97
|
+
handlers.deleteMeasurement();
|
|
98
|
+
break;
|
|
99
|
+
default: {
|
|
100
|
+
const tool = tools.find((entry) => codeForLetter(entry.shortcut) === event.code);
|
|
101
|
+
if (!tool) return;
|
|
102
|
+
handlers.setTool(tool.className);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
}
|
|
107
|
+
onMounted(() => window.addEventListener("keydown", onKeydown));
|
|
108
|
+
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
|
109
|
+
}
|
|
110
|
+
export function releaseFocus(event) {
|
|
111
|
+
if (event.detail === 0) return;
|
|
112
|
+
const target = event.target;
|
|
113
|
+
if (!(target instanceof HTMLElement)) return;
|
|
114
|
+
target.closest("button")?.blur();
|
|
115
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CornerstoneLibs, CornerstoneModuleOptions, ResolvedCornerstoneOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Install the options resolved at build time. Called by the client plugin
|
|
4
|
+
* before any component can mount, so `ensureCornerstone()` never has to reach
|
|
5
|
+
* for a Nuxt context of its own.
|
|
6
|
+
*/
|
|
7
|
+
export declare function configureCornerstone(options: CornerstoneModuleOptions | undefined): void;
|
|
8
|
+
/** Merge extra options in before init. Throws once init has started. */
|
|
9
|
+
export declare function setCornerstoneOptions(options: CornerstoneModuleOptions): void;
|
|
10
|
+
export declare function getCornerstoneOptions(): ResolvedCornerstoneOptions;
|
|
11
|
+
/**
|
|
12
|
+
* Initialise Cornerstone3D once, and hand back the three entry points.
|
|
13
|
+
*
|
|
14
|
+
* Init order is not a style choice: `dicomImageLoaderInit()` calls
|
|
15
|
+
* `getWebWorkerManager()` from core, and core's `init()` is what creates that
|
|
16
|
+
* manager. Core first, loader second, tools last.
|
|
17
|
+
*/
|
|
18
|
+
export declare function ensureCornerstone(options?: CornerstoneModuleOptions): Promise<CornerstoneLibs>;
|
|
19
|
+
/** The libraries if init has completed, else `null`. Never triggers a load. */
|
|
20
|
+
export declare function getLoadedCornerstone(): CornerstoneLibs | null;
|
|
21
|
+
export declare function isCornerstoneInitialised(): boolean;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { configureI18n, t } from "./i18n/index.js";
|
|
2
|
+
import { DEFAULT_TOOLS } from "./types.js";
|
|
3
|
+
const hot = import.meta.hot;
|
|
4
|
+
const state = hot ? hot.data.cornerstone ??= {} : {};
|
|
5
|
+
state.registeredTools ??= /* @__PURE__ */ new Set();
|
|
6
|
+
const DEFAULT_OPTIONS = {
|
|
7
|
+
autoInit: true,
|
|
8
|
+
core: {},
|
|
9
|
+
dicomImageLoader: {},
|
|
10
|
+
tools: { enabled: true, register: [...DEFAULT_TOOLS] },
|
|
11
|
+
i18n: {
|
|
12
|
+
locale: "en",
|
|
13
|
+
fallbackLocale: "en",
|
|
14
|
+
messages: {},
|
|
15
|
+
numberingSystem: "auto",
|
|
16
|
+
detect: false
|
|
17
|
+
},
|
|
18
|
+
viteCommonjs: true,
|
|
19
|
+
prefix: "Cornerstone",
|
|
20
|
+
renderingEngineId: "nuxt-cornerstone",
|
|
21
|
+
toolGroupId: "nuxt-cornerstone-tools"
|
|
22
|
+
};
|
|
23
|
+
function isPlainObject(value) {
|
|
24
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
function merge(base, override) {
|
|
27
|
+
if (!isPlainObject(base) || !isPlainObject(override)) {
|
|
28
|
+
return override === void 0 ? base : override;
|
|
29
|
+
}
|
|
30
|
+
const out = { ...base };
|
|
31
|
+
for (const [key, value] of Object.entries(override)) {
|
|
32
|
+
if (value === void 0) continue;
|
|
33
|
+
out[key] = isPlainObject(out[key]) && isPlainObject(value) ? merge(out[key], value) : value;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
export function configureCornerstone(options) {
|
|
38
|
+
state.options = merge(state.options ?? DEFAULT_OPTIONS, options);
|
|
39
|
+
configureI18n(state.options.i18n);
|
|
40
|
+
}
|
|
41
|
+
export function setCornerstoneOptions(options) {
|
|
42
|
+
if (state.libsPromise) {
|
|
43
|
+
throw new Error(`[nuxt-cornerstone] ${t("error.optionsLocked")}`);
|
|
44
|
+
}
|
|
45
|
+
configureCornerstone(options);
|
|
46
|
+
}
|
|
47
|
+
export function getCornerstoneOptions() {
|
|
48
|
+
return state.options ?? DEFAULT_OPTIONS;
|
|
49
|
+
}
|
|
50
|
+
export function ensureCornerstone(options) {
|
|
51
|
+
if (import.meta.server) {
|
|
52
|
+
return Promise.reject(
|
|
53
|
+
new Error(`[nuxt-cornerstone] ${t("error.ssr")}`)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (options) setCornerstoneOptions(options);
|
|
57
|
+
state.libsPromise ??= load();
|
|
58
|
+
return state.libsPromise;
|
|
59
|
+
}
|
|
60
|
+
async function load() {
|
|
61
|
+
const options = getCornerstoneOptions();
|
|
62
|
+
const [core, dicomImageLoader, tools] = await Promise.all([
|
|
63
|
+
import("@cornerstonejs/core"),
|
|
64
|
+
import("@cornerstonejs/dicom-image-loader"),
|
|
65
|
+
import("@cornerstonejs/tools")
|
|
66
|
+
]);
|
|
67
|
+
core.init(options.core);
|
|
68
|
+
dicomImageLoader.init(options.dicomImageLoader);
|
|
69
|
+
if (options.tools.enabled) {
|
|
70
|
+
tools.init();
|
|
71
|
+
if (options.tools.register !== false) {
|
|
72
|
+
registerTools(tools, options.tools.register);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const libs = { core, tools, dicomImageLoader };
|
|
76
|
+
state.libs = libs;
|
|
77
|
+
return libs;
|
|
78
|
+
}
|
|
79
|
+
function registerTools(tools, names) {
|
|
80
|
+
const registered = state.registeredTools;
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
if (registered.has(name)) continue;
|
|
83
|
+
const ToolClass = tools[name];
|
|
84
|
+
if (typeof ToolClass !== "function") {
|
|
85
|
+
console.warn(`[nuxt-cornerstone] ${t("warn.unknownTool", { tool: name })}`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
tools.addTool(ToolClass);
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
registered.add(name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function getLoadedCornerstone() {
|
|
96
|
+
return state.libs ?? null;
|
|
97
|
+
}
|
|
98
|
+
export function isCornerstoneInitialised() {
|
|
99
|
+
return Boolean(state.libs);
|
|
100
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a SOPInstanceUID can be found in this session.
|
|
3
|
+
*
|
|
4
|
+
* Annotations produced somewhere else — by a reporting system, or by a model
|
|
5
|
+
* running server-side — identify the slice they belong to by its
|
|
6
|
+
* SOPInstanceUID (0008,0018), because that is the only identifier that
|
|
7
|
+
* survives leaving the viewer. An imageId does not: `dicomfile:` ids are
|
|
8
|
+
* handed out by the loader as files are registered, so the same study opened
|
|
9
|
+
* twice has a different set of them.
|
|
10
|
+
*
|
|
11
|
+
* `useDicomFiles()` fills this in as it reads headers, and
|
|
12
|
+
* `useDicomAnnotations()` reads it to place incoming boxes. It lives here
|
|
13
|
+
* rather than inside either composable so that neither has to import the
|
|
14
|
+
* other.
|
|
15
|
+
*
|
|
16
|
+
* Nothing writes to it on the server: both writers go through
|
|
17
|
+
* `ensureCornerstone()`, which rejects during SSR. So the module-scoped map
|
|
18
|
+
* that would otherwise be shared between in-flight requests only ever holds
|
|
19
|
+
* one browser session's files.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Remember where a slice lives.
|
|
23
|
+
*
|
|
24
|
+
* A later registration wins. Reopening a study registers the same UIDs against
|
|
25
|
+
* fresh imageIds, and the fresh ones are the ones that still resolve.
|
|
26
|
+
*/
|
|
27
|
+
export declare function registerInstance(sopInstanceUid: string, imageId: string): void;
|
|
28
|
+
/** The imageId for a SOPInstanceUID, or `null` if that slice is not loaded. */
|
|
29
|
+
export declare function imageIdForSopInstanceUid(sopInstanceUid: string): string | null;
|
|
30
|
+
export declare function clearInstanceIndex(): void;
|
|
31
|
+
/** How many slices can currently be found by UID. */
|
|
32
|
+
export declare function instanceIndexSize(): number;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const hot = import.meta.hot;
|
|
2
|
+
const state = hot ? hot.data.cornerstoneInstances ??= {} : {};
|
|
3
|
+
state.imageIds ??= /* @__PURE__ */ new Map();
|
|
4
|
+
export function registerInstance(sopInstanceUid, imageId) {
|
|
5
|
+
state.imageIds.set(sopInstanceUid, imageId);
|
|
6
|
+
}
|
|
7
|
+
export function imageIdForSopInstanceUid(sopInstanceUid) {
|
|
8
|
+
return state.imageIds.get(sopInstanceUid) ?? null;
|
|
9
|
+
}
|
|
10
|
+
export function clearInstanceIndex() {
|
|
11
|
+
state.imageIds.clear();
|
|
12
|
+
}
|
|
13
|
+
export function instanceIndexSize() {
|
|
14
|
+
return state.imageIds.size;
|
|
15
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export { formatBytes } from './i18n/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* ZIP extraction for DICOM archives.
|
|
4
|
+
*
|
|
5
|
+
* `fflate` is imported dynamically: an app that never opens a ZIP should not
|
|
6
|
+
* pay for a decompressor, and its async `unzip` inflates on a worker pool so a
|
|
7
|
+
* 500-slice study does not block the frame the user is scrolling.
|
|
8
|
+
*/
|
|
9
|
+
/** A file pulled out of the archive, kept with the path it had inside it. */
|
|
10
|
+
export interface ZipEntry {
|
|
11
|
+
/** Full path inside the archive, e.g. `STUDY/SER00002/IM000001`. */
|
|
12
|
+
path: string;
|
|
13
|
+
/**
|
|
14
|
+
* The backing buffer is pinned to `ArrayBuffer` rather than left as
|
|
15
|
+
* `ArrayBufferLike`, because `BlobPart` rejects a possibly-shared buffer and
|
|
16
|
+
* these bytes go straight into a `File`. fflate never allocates on a
|
|
17
|
+
* `SharedArrayBuffer`, so narrowing it here is sound.
|
|
18
|
+
*/
|
|
19
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
20
|
+
}
|
|
21
|
+
export type SkipReason =
|
|
22
|
+
/** Archive metadata: `__MACOSX/`, dotfiles, `DICOMDIR`. */
|
|
23
|
+
'metadata'
|
|
24
|
+
/** Ruled out by extension before inflating — .pdf, .jpg, .txt and friends. */
|
|
25
|
+
| 'not-dicom-extension'
|
|
26
|
+
/** Inflated, but neither the `DICM` magic nor a parseable dataset. */
|
|
27
|
+
| 'not-dicom'
|
|
28
|
+
/** Zero bytes. */
|
|
29
|
+
| 'empty';
|
|
30
|
+
export interface SkippedEntry {
|
|
31
|
+
path: string;
|
|
32
|
+
reason: SkipReason;
|
|
33
|
+
}
|
|
34
|
+
export interface UnzipDicomResult {
|
|
35
|
+
entries: ZipEntry[];
|
|
36
|
+
skipped: SkippedEntry[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Default ceiling on total *uncompressed* bytes. A ZIP advertises the
|
|
40
|
+
* uncompressed size of every member in its central directory, so this is
|
|
41
|
+
* checked before anything is inflated — a 100 KB archive claiming 40 GB is
|
|
42
|
+
* rejected without allocating for it.
|
|
43
|
+
*/
|
|
44
|
+
export declare const DEFAULT_MAX_BYTES: number;
|
|
45
|
+
export interface UnzipDicomOptions {
|
|
46
|
+
/** Ceiling on total uncompressed bytes. Default: 2 GiB. */
|
|
47
|
+
maxBytes?: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Rule a file out by its name alone, and say why, or `null` to keep it.
|
|
51
|
+
*
|
|
52
|
+
* Exported because the same question is asked of files that never came from an
|
|
53
|
+
* archive — a folder picked in a file dialog carries the same `.DS_Store` and
|
|
54
|
+
* report PDFs a burned CD does — and one definition of "not DICOM" is better
|
|
55
|
+
* than two that drift apart.
|
|
56
|
+
*/
|
|
57
|
+
export declare function nonDicomNameReason(path: string): 'metadata' | 'not-dicom-extension' | null;
|
|
58
|
+
/** Bytes needed before {@link hasDicmMagic} can answer. */
|
|
59
|
+
export declare const DICM_MAGIC_BYTES: number;
|
|
60
|
+
/** Part 10 files carry `DICM` at byte 128, right after the preamble. */
|
|
61
|
+
export declare function hasDicmMagic(bytes: Uint8Array): boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Enough bytes for {@link isDicomContent} to decide: the Part 10 preamble plus
|
|
64
|
+
* room for the first few elements of a dataset that has no preamble.
|
|
65
|
+
*/
|
|
66
|
+
export declare const CONTENT_PROBE_BYTES: number;
|
|
67
|
+
/**
|
|
68
|
+
* Does this look like DICOM from its content alone?
|
|
69
|
+
*
|
|
70
|
+
* An extension proves nothing in either direction — plenty of DICOM files are
|
|
71
|
+
* named `IM000001`, and anything at all can be renamed to `.dcm` — so the
|
|
72
|
+
* answer has to come from the bytes.
|
|
73
|
+
*
|
|
74
|
+
* Part 10 files say so outright with their magic. A dataset stored without a
|
|
75
|
+
* preamble has nothing to declare, so its structure is read instead: the first
|
|
76
|
+
* element must open a group a dataset may legitimately open with, and the
|
|
77
|
+
* elements after it must parse and ascend. A PNG fails at the first tag, whose
|
|
78
|
+
* group reads as 0x5089.
|
|
79
|
+
*
|
|
80
|
+
* This is a positive test. Anything that cannot show one of those two things
|
|
81
|
+
* is rejected, which is the opposite of assuming a file is DICOM because
|
|
82
|
+
* nothing proved otherwise.
|
|
83
|
+
*/
|
|
84
|
+
export declare function isDicomContent(bytes: Uint8Array): Promise<boolean>;
|
|
85
|
+
export type DicomParser = typeof import('dicom-parser');
|
|
86
|
+
/**
|
|
87
|
+
* Inflate a ZIP and hand back the members that look like DICOM.
|
|
88
|
+
*
|
|
89
|
+
* Filtering happens in two passes. The cheap one runs inside fflate's `filter`
|
|
90
|
+
* hook, which sees each member's name and uncompressed size *before* it is
|
|
91
|
+
* inflated, so housekeeping files and obvious non-DICOM never cost anything.
|
|
92
|
+
* The second pass looks at the actual bytes, because a file's name tells you
|
|
93
|
+
* very little about whether it is DICOM.
|
|
94
|
+
*/
|
|
95
|
+
export declare function unzipDicom(data: Uint8Array, options?: UnzipDicomOptions): Promise<UnzipDicomResult>;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { formatBytes, t } from "./i18n/index.js";
|
|
2
|
+
export { formatBytes } from "./i18n/index.js";
|
|
3
|
+
export const DEFAULT_MAX_BYTES = 2 * 1024 ** 3;
|
|
4
|
+
function isMetadataPath(path) {
|
|
5
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
6
|
+
return path.startsWith("__MACOSX/") || path.includes("/__MACOSX/") || name.startsWith(".") || name === "DICOMDIR" || name === "Thumbs.db";
|
|
7
|
+
}
|
|
8
|
+
const NON_DICOM_EXTENSION = /\.(?:txt|pdf|jpe?g|png|gif|bmp|tiff?|svg|xml|html?|json|csv|tsv|md|rtf|docx?|xlsx?|pptx?|zip|gz|tgz|bz2|xz|rar|7z|exe|dll|so|dylib|bat|sh|ini|cfg|log|db|sqlite|mp4|avi|mov|wav|mp3)$/i;
|
|
9
|
+
export function nonDicomNameReason(path) {
|
|
10
|
+
if (isMetadataPath(path)) return "metadata";
|
|
11
|
+
if (NON_DICOM_EXTENSION.test(path)) return "not-dicom-extension";
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const DICM_MAGIC = [68, 73, 67, 77];
|
|
15
|
+
const DICM_MAGIC_OFFSET = 128;
|
|
16
|
+
export const DICM_MAGIC_BYTES = DICM_MAGIC_OFFSET + DICM_MAGIC.length;
|
|
17
|
+
export function hasDicmMagic(bytes) {
|
|
18
|
+
if (bytes.length < DICM_MAGIC_OFFSET + DICM_MAGIC.length) return false;
|
|
19
|
+
return DICM_MAGIC.every((byte, i) => bytes[DICM_MAGIC_OFFSET + i] === byte);
|
|
20
|
+
}
|
|
21
|
+
export const CONTENT_PROBE_BYTES = 16 * 1024;
|
|
22
|
+
const FOREIGN_SIGNATURES = [
|
|
23
|
+
[137, 80, 78, 71],
|
|
24
|
+
// PNG
|
|
25
|
+
[255, 216, 255],
|
|
26
|
+
// JPEG
|
|
27
|
+
[71, 73, 70, 56],
|
|
28
|
+
// GIF8
|
|
29
|
+
[66, 77],
|
|
30
|
+
// BMP
|
|
31
|
+
[37, 80, 68, 70],
|
|
32
|
+
// %PDF
|
|
33
|
+
[80, 75, 3, 4],
|
|
34
|
+
// ZIP
|
|
35
|
+
[31, 139],
|
|
36
|
+
// gzip
|
|
37
|
+
[82, 97, 114, 33],
|
|
38
|
+
// Rar!
|
|
39
|
+
[55, 122, 188, 175],
|
|
40
|
+
// 7z
|
|
41
|
+
[73, 73, 42, 0],
|
|
42
|
+
// TIFF little-endian
|
|
43
|
+
[77, 77, 0, 42],
|
|
44
|
+
// TIFF big-endian
|
|
45
|
+
[82, 73, 70, 70],
|
|
46
|
+
// RIFF (wav, avi, webp)
|
|
47
|
+
[79, 103, 103, 83],
|
|
48
|
+
// OggS
|
|
49
|
+
[127, 69, 76, 70],
|
|
50
|
+
// ELF
|
|
51
|
+
[77, 90],
|
|
52
|
+
// DOS/PE executable
|
|
53
|
+
[73, 68, 51],
|
|
54
|
+
// ID3 (mp3)
|
|
55
|
+
[60, 63, 120, 109],
|
|
56
|
+
// <?xm
|
|
57
|
+
[60, 33, 68, 79]
|
|
58
|
+
// <!DO
|
|
59
|
+
];
|
|
60
|
+
function hasForeignSignature(bytes) {
|
|
61
|
+
return FOREIGN_SIGNATURES.some(
|
|
62
|
+
(signature) => signature.every((byte, i) => bytes[i] === byte)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const OPENING_GROUPS = /* @__PURE__ */ new Set([2, 8]);
|
|
66
|
+
const PROBE_ELEMENTS = 4;
|
|
67
|
+
const readU16 = (bytes, at, littleEndian) => littleEndian ? bytes[at] | bytes[at + 1] << 8 : bytes[at] << 8 | bytes[at + 1];
|
|
68
|
+
export async function isDicomContent(bytes) {
|
|
69
|
+
if (hasDicmMagic(bytes)) return true;
|
|
70
|
+
if (bytes.length < 8) return false;
|
|
71
|
+
if (hasForeignSignature(bytes)) return false;
|
|
72
|
+
if (OPENING_GROUPS.has(readU16(bytes, 0, false))) return true;
|
|
73
|
+
if (!OPENING_GROUPS.has(readU16(bytes, 0, true))) return false;
|
|
74
|
+
const dicomParser = await loadDicomParser();
|
|
75
|
+
return walksAsDataset(dicomParser, bytes, false) || walksAsDataset(dicomParser, bytes, true);
|
|
76
|
+
}
|
|
77
|
+
function walksAsDataset(dicomParser, bytes, explicitVr) {
|
|
78
|
+
try {
|
|
79
|
+
const stream = new dicomParser.ByteStream(dicomParser.littleEndianByteArrayParser, bytes, 0);
|
|
80
|
+
let previousTag = "";
|
|
81
|
+
for (let read = 0; read < PROBE_ELEMENTS; read++) {
|
|
82
|
+
if (stream.position + 8 > bytes.length) return read > 0;
|
|
83
|
+
const element = explicitVr ? dicomParser.readDicomElementExplicit(stream) : dicomParser.readDicomElementImplicit(stream);
|
|
84
|
+
if (!/^x[0-9a-f]{8}$/.test(element.tag)) return false;
|
|
85
|
+
if (element.tag <= previousTag) return false;
|
|
86
|
+
previousTag = element.tag;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
let dicomParserPromise = null;
|
|
94
|
+
function loadDicomParser() {
|
|
95
|
+
dicomParserPromise ??= import("dicom-parser").then((mod) => {
|
|
96
|
+
const candidate = mod;
|
|
97
|
+
return candidate.default ?? mod;
|
|
98
|
+
});
|
|
99
|
+
return dicomParserPromise;
|
|
100
|
+
}
|
|
101
|
+
export async function unzipDicom(data, options = {}) {
|
|
102
|
+
const { maxBytes = DEFAULT_MAX_BYTES } = options;
|
|
103
|
+
const { unzip } = await import("fflate");
|
|
104
|
+
const skipped = [];
|
|
105
|
+
let claimedBytes = 0;
|
|
106
|
+
const filter = (file) => {
|
|
107
|
+
if (file.name.endsWith("/")) return false;
|
|
108
|
+
const nameReason = nonDicomNameReason(file.name);
|
|
109
|
+
if (nameReason) {
|
|
110
|
+
skipped.push({ path: file.name, reason: nameReason });
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (file.originalSize === 0) {
|
|
114
|
+
skipped.push({ path: file.name, reason: "empty" });
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
claimedBytes += file.originalSize;
|
|
118
|
+
if (claimedBytes > maxBytes) {
|
|
119
|
+
throw new Error(t("zip.tooLarge", { limit: formatBytes(maxBytes) }));
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
};
|
|
123
|
+
const unzipped = await new Promise((resolve, reject) => {
|
|
124
|
+
unzip(data, { filter }, (err, result) => {
|
|
125
|
+
if (err) reject(asZipError(err));
|
|
126
|
+
else resolve(result);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
const entries = [];
|
|
130
|
+
for (const [path, bytes] of Object.entries(unzipped)) {
|
|
131
|
+
if (bytes.length === 0) {
|
|
132
|
+
skipped.push({ path, reason: "empty" });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!await isDicomContent(bytes)) {
|
|
136
|
+
skipped.push({ path, reason: "not-dicom" });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
entries.push({ path, bytes });
|
|
140
|
+
}
|
|
141
|
+
return { entries, skipped };
|
|
142
|
+
}
|
|
143
|
+
function asZipError(error) {
|
|
144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
145
|
+
if (/invalid zip|no central directory|end of central/i.test(message)) {
|
|
146
|
+
return new Error(t("zip.notReadable"));
|
|
147
|
+
}
|
|
148
|
+
return new Error(t("zip.readFailed", { message }));
|
|
149
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CornerstoneI18nOptions, LocaleSource } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reading the locale from the application that installed the module.
|
|
4
|
+
*
|
|
5
|
+
* Kept out of the plugin so it depends on nothing Nuxt-specific — the plugin
|
|
6
|
+
* hands it `nuxtApp` and it duck-types its way from there.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ALL_SOURCES: LocaleSource[];
|
|
9
|
+
export declare function sourcesOf(detect: NonNullable<CornerstoneI18nOptions['detect']>): LocaleSource[];
|
|
10
|
+
/**
|
|
11
|
+
* Adopt a locale only if there is a catalogue for it.
|
|
12
|
+
*
|
|
13
|
+
* An app running in a language nobody has translated keeps the configured
|
|
14
|
+
* locale, rather than flipping text direction under the viewer to go on showing
|
|
15
|
+
* English anyway.
|
|
16
|
+
*/
|
|
17
|
+
export declare function adoptLocale(candidate: unknown): boolean;
|
|
18
|
+
/** Try each source in order; returns the one that answered, or `null`. */
|
|
19
|
+
export declare function followHostLocale(nuxtApp: unknown, sources: LocaleSource[]): LocaleSource | null;
|
|
20
|
+
/**
|
|
21
|
+
* `@nuxtjs/i18n` and vue-i18n both put the active locale on `nuxtApp.$i18n`.
|
|
22
|
+
* It is duck-typed rather than imported, so the module gains no dependency and
|
|
23
|
+
* simply declines when neither is installed.
|
|
24
|
+
*
|
|
25
|
+
* `toValue` inside the getter covers both shapes: a `Ref` in Composition mode,
|
|
26
|
+
* a reactive string property in legacy mode.
|
|
27
|
+
*/
|
|
28
|
+
export declare function followHostI18n(nuxtApp: unknown): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The `lang` attribute, watched so a switch at runtime is picked up. Nearly
|
|
31
|
+
* every i18n library sets it, which makes this the generic path.
|
|
32
|
+
*
|
|
33
|
+
* Do not pair this source with setting `<html lang>` *from* this module's
|
|
34
|
+
* locale — that is a cycle. It settles, because adopting the locale that is
|
|
35
|
+
* already active changes nothing, but the app then has no source of truth.
|
|
36
|
+
*/
|
|
37
|
+
export declare function followHtmlLang(): boolean;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { toValue, watch } from "vue";
|
|
2
|
+
import { hasCornerstoneCatalog, setCornerstoneLocale } from "./index.js";
|
|
3
|
+
export const ALL_SOURCES = ["i18n", "html", "navigator"];
|
|
4
|
+
export function sourcesOf(detect) {
|
|
5
|
+
return Array.isArray(detect) ? detect : detect ? ALL_SOURCES : [];
|
|
6
|
+
}
|
|
7
|
+
export function adoptLocale(candidate) {
|
|
8
|
+
if (typeof candidate !== "string" || !candidate) return false;
|
|
9
|
+
if (!hasCornerstoneCatalog(candidate)) return false;
|
|
10
|
+
setCornerstoneLocale(candidate);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
export function followHostLocale(nuxtApp, sources) {
|
|
14
|
+
for (const source of sources) {
|
|
15
|
+
if (source === "i18n" && followHostI18n(nuxtApp)) return source;
|
|
16
|
+
if (source === "html" && followHtmlLang()) return source;
|
|
17
|
+
if (source === "navigator" && adoptLocale(navigator.language)) return source;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
export function followHostI18n(nuxtApp) {
|
|
22
|
+
const host = nuxtApp?.$i18n;
|
|
23
|
+
if (!host || host.locale === void 0) return false;
|
|
24
|
+
const read = () => toValue(host.locale);
|
|
25
|
+
if (!adoptLocale(read())) return false;
|
|
26
|
+
watch(read, adoptLocale);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
export function followHtmlLang() {
|
|
30
|
+
const root = document.documentElement;
|
|
31
|
+
if (!adoptLocale(root.lang)) return false;
|
|
32
|
+
new MutationObserver(() => adoptLocale(root.lang)).observe(root, {
|
|
33
|
+
attributes: true,
|
|
34
|
+
attributeFilter: ["lang"]
|
|
35
|
+
});
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { MessageCatalog, MessageKey, MessageParams } from './messages.js';
|
|
2
|
+
import type { CornerstoneI18nOptions } from '../types.js';
|
|
3
|
+
export type { CornerstoneMessageKey, MessageCatalog, MessageKey, MessageParams, MessageValue, PluralCategory, } from './messages.js';
|
|
4
|
+
export { BUILTIN_MESSAGES, en, fa } from './messages.js';
|
|
5
|
+
/**
|
|
6
|
+
* A translator supplied by the host app, to route this module's strings
|
|
7
|
+
* through whatever i18n it already runs. Return `undefined` for any key it does
|
|
8
|
+
* not know and the built-in catalogue answers instead, so an app can override
|
|
9
|
+
* three strings without adopting the whole set.
|
|
10
|
+
*/
|
|
11
|
+
export type CornerstoneTranslator = (key: MessageKey, params: MessageParams | undefined, locale: string) => string | undefined;
|
|
12
|
+
export declare const DEFAULT_LOCALE = "en";
|
|
13
|
+
/**
|
|
14
|
+
* Apply the build-time `cornerstone.i18n` options.
|
|
15
|
+
*
|
|
16
|
+
* Called from `configureCornerstone()`, so it runs both for options that came
|
|
17
|
+
* through `runtimeConfig` and for options handed to `ensureCornerstone()` by an
|
|
18
|
+
* app's own plugin.
|
|
19
|
+
*/
|
|
20
|
+
export declare function configureI18n(options: CornerstoneI18nOptions | false | undefined): void;
|
|
21
|
+
export declare function getCornerstoneLocale(): string;
|
|
22
|
+
/**
|
|
23
|
+
* Switch locale at runtime. A no-op when `cornerstone.i18n` is `false` — an app
|
|
24
|
+
* that turned the catalogue off is driving the strings from its own i18n, and
|
|
25
|
+
* silently tracking a second locale here would only be confusing.
|
|
26
|
+
*/
|
|
27
|
+
export declare function setCornerstoneLocale(locale: string): void;
|
|
28
|
+
/** Every locale with a catalogue, built-in or registered through options. */
|
|
29
|
+
export declare function getCornerstoneLocales(): string[];
|
|
30
|
+
/**
|
|
31
|
+
* Whether there is a catalogue for a locale, matching `fa-IR` against `fa`.
|
|
32
|
+
* Locale detection uses this to ignore languages nobody has translated.
|
|
33
|
+
*/
|
|
34
|
+
export declare function hasCornerstoneCatalog(locale: string): boolean;
|
|
35
|
+
/** Route this module's strings through the host app's i18n. `null` unsets it. */
|
|
36
|
+
export declare function setCornerstoneTranslator(translator: CornerstoneTranslator | null): void;
|
|
37
|
+
/** Register or extend a catalogue at runtime, for locales not in the config. */
|
|
38
|
+
export declare function addCornerstoneMessages(locale: string, catalog: MessageCatalog): void;
|
|
39
|
+
/** Whether a locale is written right-to-left. */
|
|
40
|
+
export declare function isRtlLocale(locale?: string): boolean;
|
|
41
|
+
/** `'rtl'` or `'ltr'`, ready for a `dir` attribute. */
|
|
42
|
+
export declare function dirForLocale(locale?: string): 'ltr' | 'rtl';
|
|
43
|
+
/**
|
|
44
|
+
* Translate a key.
|
|
45
|
+
*
|
|
46
|
+
* Resolution order is translator, active locale, fallback locale, English, and
|
|
47
|
+
* finally the key itself — a missing string shows up as `series.unnamed`
|
|
48
|
+
* rather than as an empty label.
|
|
49
|
+
*/
|
|
50
|
+
export declare function t(key: MessageKey, params?: MessageParams): string;
|
|
51
|
+
/** Format a number in the active locale. */
|
|
52
|
+
export declare function n(value: number, options?: Intl.NumberFormatOptions): string;
|
|
53
|
+
/**
|
|
54
|
+
* Human-readable byte size in the active locale — `2 GB`, or «۲ گیگابایت».
|
|
55
|
+
*
|
|
56
|
+
* The number is formatted by `Intl` (Persian-Indic digits, Persian decimal
|
|
57
|
+
* separator) but the unit comes from the catalogue. `Intl`'s own `style: 'unit'`
|
|
58
|
+
* is not usable here: CLDR's short English name for `byte` is "byte", so a
|
|
59
|
+
* 900-byte archive would read "900 byte".
|
|
60
|
+
*/
|
|
61
|
+
export declare function formatBytes(bytes: number): string;
|