dicom-synth 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/esm/collection/writer.js +61 -13
- package/dist/esm/describe/describe.js +16 -1
- package/dist/esm/index.js +77 -14
- package/dist/esm/schema/validate.js +16 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -287,6 +287,33 @@ import type { StudySpec, SeriesSpec, SeriesEntrySpec } from 'dicom-synth'
|
|
|
287
287
|
|
|
288
288
|
`GeneratedFile.relativePath` carries the layout-aware path for in-process consumers; `filename` is always the basename.
|
|
289
289
|
|
|
290
|
+
### Path quirks (`pathQuirks`)
|
|
291
|
+
|
|
292
|
+
Decorate written paths with edge-case names to exercise consumer file handling (e.g. the Chrome File System Access API). Quirks apply to the relative paths the layout already produces — DICOM byte content is unchanged.
|
|
293
|
+
|
|
294
|
+
```json
|
|
295
|
+
{
|
|
296
|
+
"studies": [{ "series": [{ "entries": [{ "type": "valid-image", "count": 3 }] }] }],
|
|
297
|
+
"layout": "hierarchical",
|
|
298
|
+
"pathQuirks": ["trailing-dot", "unicode"]
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
| Quirk | Effect |
|
|
303
|
+
|---|---|
|
|
304
|
+
| `trailing-dot` | Appends `.` to each directory component (`study-001./series-001./…`); on flat output, to the filename |
|
|
305
|
+
| `unicode` | Inserts a non-ASCII character into directory and file names |
|
|
306
|
+
| `deep-nesting` | Injects extra nested directory levels before the file |
|
|
307
|
+
| `long-name` | Pads the filename stem and the deepest directory component toward the 255-byte limit |
|
|
308
|
+
|
|
309
|
+
Decorations are stable per path component, so a series' files still share one directory. Quirks compose; the result is deterministic and independent of the order they're listed in.
|
|
310
|
+
|
|
311
|
+
> **Cross-platform:** these names are writable on POSIX (Linux/macOS, where CI runs) but Windows rewrites or rejects several (trailing dots, reserved names, long paths). The intent is to *feed* a downstream consumer (e.g. a browser), not to round-trip on Windows.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
import type { PathQuirk } from 'dicom-synth'
|
|
315
|
+
```
|
|
316
|
+
|
|
290
317
|
### Tag overrides (`tags`)
|
|
291
318
|
|
|
292
319
|
Available on all image types.
|
|
@@ -419,6 +419,48 @@ async function generateFile(spec, options) {
|
|
|
419
419
|
index
|
|
420
420
|
};
|
|
421
421
|
}
|
|
422
|
+
function hashString(s) {
|
|
423
|
+
let h = 2166136261;
|
|
424
|
+
for (let i = 0; i < s.length; i++) {
|
|
425
|
+
h ^= s.charCodeAt(i);
|
|
426
|
+
h = Math.imul(h, 16777619);
|
|
427
|
+
}
|
|
428
|
+
return h >>> 0;
|
|
429
|
+
}
|
|
430
|
+
var UNICODE_TOKENS = ["\xE9", "\xFC", "\xF1", "\u65E5", "\u2122", "\u03A9"];
|
|
431
|
+
var NEST_DEPTH = 8;
|
|
432
|
+
var LONG_NAME_TARGET = 200;
|
|
433
|
+
function applyPathQuirks(relativePath, quirks) {
|
|
434
|
+
const segments = relativePath.split("/");
|
|
435
|
+
let dirs = segments.slice(0, -1);
|
|
436
|
+
const leaf = segments[segments.length - 1];
|
|
437
|
+
const dot = leaf.lastIndexOf(".");
|
|
438
|
+
let stem = dot > 0 ? leaf.slice(0, dot) : leaf;
|
|
439
|
+
const ext = dot > 0 ? leaf.slice(dot) : "";
|
|
440
|
+
const unicode = (s) => `${s}${UNICODE_TOKENS[hashString(s) % UNICODE_TOKENS.length]}`;
|
|
441
|
+
const has = (q) => quirks.includes(q);
|
|
442
|
+
if (has("deep-nesting")) {
|
|
443
|
+
dirs = [...dirs, ...Array.from({ length: NEST_DEPTH }, (_, i) => `d${i}`)];
|
|
444
|
+
}
|
|
445
|
+
if (has("unicode")) {
|
|
446
|
+
dirs = dirs.map(unicode);
|
|
447
|
+
stem = unicode(stem);
|
|
448
|
+
}
|
|
449
|
+
if (has("long-name")) {
|
|
450
|
+
const pad = (s) => s.length < LONG_NAME_TARGET ? s + "x".repeat(LONG_NAME_TARGET - s.length) : s;
|
|
451
|
+
stem = pad(stem);
|
|
452
|
+
if (dirs.length > 0) {
|
|
453
|
+
const last = dirs.length - 1;
|
|
454
|
+
dirs = dirs.map((d, i) => i === last ? pad(d) : d);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
let newLeaf = stem + ext;
|
|
458
|
+
if (has("trailing-dot")) {
|
|
459
|
+
if (dirs.length > 0) dirs = dirs.map((d) => `${d}.`);
|
|
460
|
+
else newLeaf += ".";
|
|
461
|
+
}
|
|
462
|
+
return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
|
|
463
|
+
}
|
|
422
464
|
function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
423
465
|
const pad3 = (n) => String(n).padStart(3, "0");
|
|
424
466
|
const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
|
|
@@ -433,15 +475,19 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
433
475
|
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
434
476
|
const padWidth = String(totalFiles).length;
|
|
435
477
|
const hierarchical = spec.layout === "hierarchical";
|
|
478
|
+
const quirks = spec.pathQuirks ?? [];
|
|
479
|
+
const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
|
|
436
480
|
let globalIndex = 0;
|
|
437
481
|
for (const entry of flatEntries) {
|
|
438
482
|
const { count = 1, ...fileSpec } = entry;
|
|
439
483
|
for (let i = 0; i < count; i++) {
|
|
440
|
-
yield
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
484
|
+
yield decorate(
|
|
485
|
+
await generateFile(fileSpec, {
|
|
486
|
+
index: globalIndex,
|
|
487
|
+
seed: spec.seed,
|
|
488
|
+
padWidth
|
|
489
|
+
})
|
|
490
|
+
);
|
|
445
491
|
globalIndex++;
|
|
446
492
|
}
|
|
447
493
|
}
|
|
@@ -473,14 +519,16 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
473
519
|
uid: { study: studyUid, series: seriesUid }
|
|
474
520
|
}
|
|
475
521
|
);
|
|
476
|
-
yield
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
522
|
+
yield decorate(
|
|
523
|
+
hierarchical ? {
|
|
524
|
+
...file,
|
|
525
|
+
...hierarchicalName(
|
|
526
|
+
studyOrdinal,
|
|
527
|
+
seriesIndex,
|
|
528
|
+
instanceNumber
|
|
529
|
+
)
|
|
530
|
+
} : file
|
|
531
|
+
);
|
|
484
532
|
globalIndex++;
|
|
485
533
|
}
|
|
486
534
|
}
|
|
@@ -40,6 +40,12 @@ var VALID_LAYOUTS = {
|
|
|
40
40
|
flat: true,
|
|
41
41
|
hierarchical: true
|
|
42
42
|
};
|
|
43
|
+
var VALID_PATH_QUIRKS = {
|
|
44
|
+
"trailing-dot": true,
|
|
45
|
+
unicode: true,
|
|
46
|
+
"deep-nesting": true,
|
|
47
|
+
"long-name": true
|
|
48
|
+
};
|
|
43
49
|
var VALID_TRANSFER_SYNTAXES = {
|
|
44
50
|
"explicit-vr-little-endian": true,
|
|
45
51
|
"implicit-vr-little-endian": true
|
|
@@ -226,11 +232,20 @@ function validateDatasetSpec(raw) {
|
|
|
226
232
|
if ("layout" in r) {
|
|
227
233
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
228
234
|
}
|
|
235
|
+
if ("pathQuirks" in r) {
|
|
236
|
+
if (!Array.isArray(r.pathQuirks)) {
|
|
237
|
+
throw new Error("DatasetSpec.pathQuirks: must be an array");
|
|
238
|
+
}
|
|
239
|
+
for (const [i, q] of r.pathQuirks.entries()) {
|
|
240
|
+
validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
229
243
|
return {
|
|
230
244
|
...entries.length > 0 ? { entries } : {},
|
|
231
245
|
...studies.length > 0 ? { studies } : {},
|
|
232
246
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
233
|
-
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
247
|
+
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
248
|
+
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
234
249
|
};
|
|
235
250
|
}
|
|
236
251
|
|
package/dist/esm/index.js
CHANGED
|
@@ -63,6 +63,12 @@ var VALID_LAYOUTS = {
|
|
|
63
63
|
flat: true,
|
|
64
64
|
hierarchical: true
|
|
65
65
|
};
|
|
66
|
+
var VALID_PATH_QUIRKS = {
|
|
67
|
+
"trailing-dot": true,
|
|
68
|
+
unicode: true,
|
|
69
|
+
"deep-nesting": true,
|
|
70
|
+
"long-name": true
|
|
71
|
+
};
|
|
66
72
|
var VALID_TRANSFER_SYNTAXES = {
|
|
67
73
|
"explicit-vr-little-endian": true,
|
|
68
74
|
"implicit-vr-little-endian": true
|
|
@@ -249,11 +255,20 @@ function validateDatasetSpec(raw) {
|
|
|
249
255
|
if ("layout" in r) {
|
|
250
256
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
251
257
|
}
|
|
258
|
+
if ("pathQuirks" in r) {
|
|
259
|
+
if (!Array.isArray(r.pathQuirks)) {
|
|
260
|
+
throw new Error("DatasetSpec.pathQuirks: must be an array");
|
|
261
|
+
}
|
|
262
|
+
for (const [i, q] of r.pathQuirks.entries()) {
|
|
263
|
+
validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
252
266
|
return {
|
|
253
267
|
...entries.length > 0 ? { entries } : {},
|
|
254
268
|
...studies.length > 0 ? { studies } : {},
|
|
255
269
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
256
|
-
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
270
|
+
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
271
|
+
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
257
272
|
};
|
|
258
273
|
}
|
|
259
274
|
function validateRange(value, path) {
|
|
@@ -720,6 +735,48 @@ async function generateFile(spec, options) {
|
|
|
720
735
|
index
|
|
721
736
|
};
|
|
722
737
|
}
|
|
738
|
+
function hashString(s) {
|
|
739
|
+
let h = 2166136261;
|
|
740
|
+
for (let i = 0; i < s.length; i++) {
|
|
741
|
+
h ^= s.charCodeAt(i);
|
|
742
|
+
h = Math.imul(h, 16777619);
|
|
743
|
+
}
|
|
744
|
+
return h >>> 0;
|
|
745
|
+
}
|
|
746
|
+
var UNICODE_TOKENS = ["\xE9", "\xFC", "\xF1", "\u65E5", "\u2122", "\u03A9"];
|
|
747
|
+
var NEST_DEPTH = 8;
|
|
748
|
+
var LONG_NAME_TARGET = 200;
|
|
749
|
+
function applyPathQuirks(relativePath, quirks) {
|
|
750
|
+
const segments = relativePath.split("/");
|
|
751
|
+
let dirs = segments.slice(0, -1);
|
|
752
|
+
const leaf = segments[segments.length - 1];
|
|
753
|
+
const dot = leaf.lastIndexOf(".");
|
|
754
|
+
let stem = dot > 0 ? leaf.slice(0, dot) : leaf;
|
|
755
|
+
const ext = dot > 0 ? leaf.slice(dot) : "";
|
|
756
|
+
const unicode = (s) => `${s}${UNICODE_TOKENS[hashString(s) % UNICODE_TOKENS.length]}`;
|
|
757
|
+
const has = (q) => quirks.includes(q);
|
|
758
|
+
if (has("deep-nesting")) {
|
|
759
|
+
dirs = [...dirs, ...Array.from({ length: NEST_DEPTH }, (_, i) => `d${i}`)];
|
|
760
|
+
}
|
|
761
|
+
if (has("unicode")) {
|
|
762
|
+
dirs = dirs.map(unicode);
|
|
763
|
+
stem = unicode(stem);
|
|
764
|
+
}
|
|
765
|
+
if (has("long-name")) {
|
|
766
|
+
const pad = (s) => s.length < LONG_NAME_TARGET ? s + "x".repeat(LONG_NAME_TARGET - s.length) : s;
|
|
767
|
+
stem = pad(stem);
|
|
768
|
+
if (dirs.length > 0) {
|
|
769
|
+
const last = dirs.length - 1;
|
|
770
|
+
dirs = dirs.map((d, i) => i === last ? pad(d) : d);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
let newLeaf = stem + ext;
|
|
774
|
+
if (has("trailing-dot")) {
|
|
775
|
+
if (dirs.length > 0) dirs = dirs.map((d) => `${d}.`);
|
|
776
|
+
else newLeaf += ".";
|
|
777
|
+
}
|
|
778
|
+
return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
|
|
779
|
+
}
|
|
723
780
|
function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
724
781
|
const pad3 = (n) => String(n).padStart(3, "0");
|
|
725
782
|
const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
|
|
@@ -734,15 +791,19 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
734
791
|
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
735
792
|
const padWidth = String(totalFiles).length;
|
|
736
793
|
const hierarchical = spec.layout === "hierarchical";
|
|
794
|
+
const quirks = spec.pathQuirks ?? [];
|
|
795
|
+
const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
|
|
737
796
|
let globalIndex = 0;
|
|
738
797
|
for (const entry of flatEntries) {
|
|
739
798
|
const { count = 1, ...fileSpec } = entry;
|
|
740
799
|
for (let i = 0; i < count; i++) {
|
|
741
|
-
yield
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
800
|
+
yield decorate(
|
|
801
|
+
await generateFile(fileSpec, {
|
|
802
|
+
index: globalIndex,
|
|
803
|
+
seed: spec.seed,
|
|
804
|
+
padWidth
|
|
805
|
+
})
|
|
806
|
+
);
|
|
746
807
|
globalIndex++;
|
|
747
808
|
}
|
|
748
809
|
}
|
|
@@ -774,14 +835,16 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
774
835
|
uid: { study: studyUid, series: seriesUid }
|
|
775
836
|
}
|
|
776
837
|
);
|
|
777
|
-
yield
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
838
|
+
yield decorate(
|
|
839
|
+
hierarchical ? {
|
|
840
|
+
...file,
|
|
841
|
+
...hierarchicalName(
|
|
842
|
+
studyOrdinal,
|
|
843
|
+
seriesIndex,
|
|
844
|
+
instanceNumber
|
|
845
|
+
)
|
|
846
|
+
} : file
|
|
847
|
+
);
|
|
785
848
|
globalIndex++;
|
|
786
849
|
}
|
|
787
850
|
}
|
|
@@ -17,6 +17,12 @@ var VALID_LAYOUTS = {
|
|
|
17
17
|
flat: true,
|
|
18
18
|
hierarchical: true
|
|
19
19
|
};
|
|
20
|
+
var VALID_PATH_QUIRKS = {
|
|
21
|
+
"trailing-dot": true,
|
|
22
|
+
unicode: true,
|
|
23
|
+
"deep-nesting": true,
|
|
24
|
+
"long-name": true
|
|
25
|
+
};
|
|
20
26
|
var VALID_TRANSFER_SYNTAXES = {
|
|
21
27
|
"explicit-vr-little-endian": true,
|
|
22
28
|
"implicit-vr-little-endian": true
|
|
@@ -203,11 +209,20 @@ function validateDatasetSpec(raw) {
|
|
|
203
209
|
if ("layout" in r) {
|
|
204
210
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
205
211
|
}
|
|
212
|
+
if ("pathQuirks" in r) {
|
|
213
|
+
if (!Array.isArray(r.pathQuirks)) {
|
|
214
|
+
throw new Error("DatasetSpec.pathQuirks: must be an array");
|
|
215
|
+
}
|
|
216
|
+
for (const [i, q] of r.pathQuirks.entries()) {
|
|
217
|
+
validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
206
220
|
return {
|
|
207
221
|
...entries.length > 0 ? { entries } : {},
|
|
208
222
|
...studies.length > 0 ? { studies } : {},
|
|
209
223
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
210
|
-
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
224
|
+
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
225
|
+
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
211
226
|
};
|
|
212
227
|
}
|
|
213
228
|
function validateRange(value, path) {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -3,5 +3,5 @@ export { type DescribeOptions, type DescribeResult, describeDirectory, } from '.
|
|
|
3
3
|
export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
|
|
4
4
|
export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
|
|
5
5
|
export { resolveParametricSpec } from './schema/parametric.js';
|
|
6
|
-
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
6
|
+
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
7
7
|
export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
|
|
@@ -55,11 +55,13 @@ export type StudySpec = {
|
|
|
55
55
|
count?: number;
|
|
56
56
|
};
|
|
57
57
|
export type DatasetLayout = 'flat' | 'hierarchical';
|
|
58
|
+
export type PathQuirk = 'trailing-dot' | 'unicode' | 'deep-nesting' | 'long-name';
|
|
58
59
|
export type DatasetSpec = {
|
|
59
60
|
entries?: EntrySpec[];
|
|
60
61
|
studies?: StudySpec[];
|
|
61
62
|
seed?: number;
|
|
62
63
|
layout?: DatasetLayout;
|
|
64
|
+
pathQuirks?: PathQuirk[];
|
|
63
65
|
};
|
|
64
66
|
export type Range = number | {
|
|
65
67
|
min: number;
|