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 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 generateFile(fileSpec, {
441
- index: globalIndex,
442
- seed: spec.seed,
443
- padWidth
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 hierarchical ? {
477
- ...file,
478
- ...hierarchicalName(
479
- studyOrdinal,
480
- seriesIndex,
481
- instanceNumber
482
- )
483
- } : file;
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 generateFile(fileSpec, {
742
- index: globalIndex,
743
- seed: spec.seed,
744
- padWidth
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 hierarchical ? {
778
- ...file,
779
- ...hierarchicalName(
780
- studyOrdinal,
781
- seriesIndex,
782
- instanceNumber
783
- )
784
- } : file;
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) {
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",