@cosyte/dicom 0.0.19 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -15,11 +15,10 @@ import { Buffer } from 'node:buffer';
15
15
  declare const VERSION: string;
16
16
 
17
17
  /**
18
- * Type definitions for the Phase 1 public Dictionary namespace.
18
+ * Type definitions for the public Dictionary namespace.
19
19
  *
20
- * Frozen surface. Phase 2 (parser), Phase 3 (dataset/VR), and Phase 7
21
- * (validate) all depend on this
22
- * shape - schema-breaking changes here cascade through the codebase.
20
+ * Frozen surface. The parser, the VR decoders and `validate()` all depend on
21
+ * this shape - schema-breaking changes here cascade through the codebase.
23
22
  *
24
23
  * @module
25
24
  */
@@ -98,19 +97,17 @@ interface UidEntry {
98
97
  }
99
98
 
100
99
  /**
101
- * Public Dictionary namespace for `@cosyte/dicom` - Phase 1 deliverable.
102
- *
103
- * D-10:
100
+ * Public Dictionary namespace for `@cosyte/dicom`.
104
101
  *
105
102
  * - {@link lookup} - accepts either an 8-char hex tag (`"00100010"`) or a
106
103
  * keyword (`"PatientName"`) and returns the typed {@link DictionaryEntry}
107
104
  * or `undefined`.
108
105
  * - {@link byKeyword} - keyword-only lookup; for cases where the caller has
109
106
  * already validated the input shape and wants a narrower call.
110
- * - {@link uid} - UID lookup (DICT-06).
107
+ * - {@link uid} - UID lookup.
111
108
  *
112
- * No function throws on miss (D-10: "Returns the typed entry or `undefined`.
113
- * No throws on miss."). Returned entries are deeply frozen - mutation attempts
109
+ * No function throws on miss: each returns the typed entry or `undefined`.
110
+ * Returned entries are deeply frozen - mutation attempts
114
111
  * throw `TypeError` in strict mode (which all of `@cosyte/dicom`'s emitted code
115
112
  * runs under, given `"use strict"` is implicit for ES modules).
116
113
  *
@@ -140,8 +137,8 @@ interface UidEntry {
140
137
  * @example
141
138
  * import { Dictionary } from "@cosyte/dicom";
142
139
  * const a = Dictionary.lookup("00100010"); // by tag
143
- * const b = Dictionary.lookup("PatientName"); // by keyword (DICT-04)
144
- * const c = Dictionary.lookup("not-real"); // undefined (D-10 no-throw)
140
+ * const b = Dictionary.lookup("PatientName"); // by keyword
141
+ * const c = Dictionary.lookup("not-real"); // undefined, never a throw
145
142
  */
146
143
  declare function lookup(tagOrKeyword: string): DictionaryEntry | undefined;
147
144
  /**
@@ -162,8 +159,8 @@ declare function byKeyword(keyword: string): DictionaryEntry | undefined;
162
159
  * Covers PS3.6 Annex A whole: transfer syntaxes, SOP and Meta SOP Classes,
163
160
  * well-known SOP Instances and Frames of Reference, coding schemes and the
164
161
  * rest, current and retired alike. Returns `undefined` for unknown UIDs or
165
- * malformed input. Used by `parseDicom()` (Phase 2) to render human-readable
166
- * Transfer Syntax names (DICT-06).
162
+ * malformed input. Used by `parseDicom()` to render human-readable
163
+ * Transfer Syntax names.
167
164
  *
168
165
  * @example
169
166
  * import { Dictionary } from "@cosyte/dicom";
@@ -187,10 +184,10 @@ declare namespace index {
187
184
  /**
188
185
  * Fatal error taxonomy for the `@cosyte/dicom` parser pipeline.
189
186
  *
190
- * Phase 2 core-parser context:
191
- * - D-09 - `FATAL_CODES` is a frozen `as const` registry with EXACTLY
192
- * four codes; anything less severe MUST be a Tier-2 warning.
193
- * - D-10 - `DicomParseError` carries `code`, `byteOffset`, `offsetFrame`,
187
+ * Two rules hold the taxonomy in place:
188
+ * - `FATAL_CODES` is a frozen `as const` registry with EXACTLY four codes;
189
+ * anything less severe MUST be a Tier-2 warning.
190
+ * - `DicomParseError` carries `code`, `byteOffset`, `offsetFrame`,
194
191
  * `snippet` (up to 16 source bytes, space-separated lowercase hex), and
195
192
  * an optional `contextPath`. The thrown `Error.message` is formatted
196
193
  * `[CODE] msg (offset=N frame=F)` with `… in path/segments` appended when
@@ -260,8 +257,7 @@ type FatalCode = (typeof FATAL_CODES)[keyof typeof FATAL_CODES];
260
257
  * RESIDUALS` closed it, nothing on the thrown error said which. A consumer
261
258
  * cutting `input.subarray(err.byteOffset, err.byteOffset + 16)` to see what
262
259
  * upset the parser was, inside a Sequence Item, cutting an unrelated element -
263
- * the exact defect the `{ strict: true }` snippet itself was fixed for in
264
- * `#80`.
260
+ * the exact defect the `{ strict: true }` snippet itself was once fixed for.
265
261
  *
266
262
  * **A frame NAME is published; a frame ORIGIN is not, and that asymmetry is
267
263
  * deliberate.** The name is drawn from the closed set below, which the parser
@@ -331,7 +327,7 @@ type OffsetFrame = (typeof OFFSET_FRAMES)[keyof typeof OFFSET_FRAMES];
331
327
  * Thrown by `parseDicom` when the input violates one of the four
332
328
  * unrecoverable Tier-3 structural rules - or, under `{ strict: true }`,
333
329
  * when any Tier-2 warning is escalated through the single `emit`
334
- * chokepoint (D-35). Carries byte-offset positional context plus a short
330
+ * chokepoint. Carries byte-offset positional context plus a short
335
331
  * source snippet so consumers can log actionable errors.
336
332
  *
337
333
  * Message format: `[CODE] msg (offset=N frame=F)`, with `… in a/b/c`
@@ -353,13 +349,14 @@ type OffsetFrame = (typeof OFFSET_FRAMES)[keyof typeof OFFSET_FRAMES];
353
349
  * **`snippet` is cut in the frame `offsetFrame` names, on every fatal but one.**
354
350
  * The exception is `UNSUPPORTED_TRANSFER_SYNTAX`, whose snippet slot carries
355
351
  * PS3.6's own NAME for the unsupported UID when the registry publishes one
356
- * (`"RLE Lossless"`), and 16 raw bytes only when it does not. That is
352
+ * (`"SMPTE ST 2110-20 Uncompressed Progressive Active Video"`), and 16 raw
353
+ * bytes only when it does not. That is
357
354
  * deliberate and predates the frame; it is named here because a universal about
358
- * `snippet` written without it is false on the code a compressed object reaches
359
- * first. Everywhere else the two agree, so a consumer that only wants the bytes
360
- * at the offset already has them. **The frame is what a consumer needs before
361
- * indexing anything of its OWN by `byteOffset`**, which is the case no field on
362
- * this class used to cover.
355
+ * `snippet` written without it is false on the code an object under a refused
356
+ * Transfer Syntax reaches first. Everywhere else the two agree, so a consumer
357
+ * that only wants the bytes at the offset already has them. **The frame is what
358
+ * a consumer needs before indexing anything of its OWN by `byteOffset`**, which
359
+ * is the case no field on this class used to cover.
363
360
  *
364
361
  * @example
365
362
  * ```ts
@@ -388,7 +385,7 @@ declare class DicomParseError extends Error {
388
385
  readonly contextPath: readonly string[] | undefined;
389
386
  /**
390
387
  * Construct a new `DicomParseError`. All fields except `contextPath` are
391
- * required so every thrower populates positional context per `TOL-02` - and
388
+ * required so every thrower populates positional context - and
392
389
  * `offsetFrame` is required for the same reason `byteOffset` is, because an
393
390
  * offset whose frame is optional is an offset whose frame is usually
394
391
  * missing.
@@ -401,11 +398,14 @@ declare class DicomParseError extends Error {
401
398
  /**
402
399
  * Shared parser-pipeline types for `@cosyte/dicom`.
403
400
  *
404
- * Phase 2 core-parser context:
405
- * - D-02 - `ParseOptions` shape (Phase 2 only; no `profile` field).
406
- * - D-03 - `OnWarningCallback` ordering contract (invoked AFTER push to `ctx.warnings`).
407
- * - D-07 - `DicomPosition` shape (`byteOffset`, optional `fileMeta` / `deflated` / `contextPath`).
408
- * - D-45 - `ParseContext.profile?: unknown` is reserved for Phase 6; Phase 2 never sets it.
401
+ * The contracts that live here:
402
+ * - `ParseOptions`, the shape a caller passes to `parseDicom`.
403
+ * - `OnWarningCallback`, whose ordering contract is that it is invoked
404
+ * AFTER the push to `ctx.warnings`.
405
+ * - `DicomPosition` (`byteOffset`, optional `fileMeta` / `deflated` /
406
+ * `contextPath`).
407
+ * - `ParseContext`, the parser's internal state, including the active
408
+ * source/vendor `Profile`.
409
409
  *
410
410
  * Public types (exported via `src/index.ts`): `DicomPosition`, `ParseOptions`, `OnWarningCallback`.
411
411
  * Internal type (NOT exported from `src/index.ts`): `ParseContext`.
@@ -435,7 +435,7 @@ interface PrivateTagDefinition {
435
435
  readonly name: string;
436
436
  }
437
437
  /**
438
- * A source/vendor tolerance preset (Phase 6). A `Profile` bundles three
438
+ * A source/vendor tolerance preset. A `Profile` bundles three
439
439
  * things that only ever **tighten or annotate** a parse - never loosen it
440
440
  * past the Postel's-Law default:
441
441
  *
@@ -478,7 +478,7 @@ interface Profile {
478
478
  * Positional context for a `DicomParseWarning` or `DicomParseError`.
479
479
  *
480
480
  * **🛑 `byteOffset` IS NOT ALWAYS RELATIVE TO THE SOURCE BUFFER, AND THIS
481
- * JSDOC SAID IT WAS.** For the Deflated Explicit VR LE transfer syntax (D-27),
481
+ * JSDOC SAID IT WAS.** For the Deflated Explicit VR LE transfer syntax,
482
482
  * `deflated: true` says the offset indexes the inflated dataset buffer rather
483
483
  * than the on-disk source. That flag is the only frame this type carries, and
484
484
  * it is not the only frame this parser has: a defined-length Sequence Item is
@@ -514,7 +514,7 @@ interface DicomPosition {
514
514
  /**
515
515
  * Synchronous callback invoked once per Tier-2 warning emitted during parse.
516
516
  *
517
- * Per `02-CONTEXT.md` D-03, the callback fires AFTER the warning has been
517
+ * The callback fires AFTER the warning has been
518
518
  * pushed to `ctx.warnings`; if the callback throws, the parser silently
519
519
  * swallows the exception and continues (mirrors `@cosyte/hl7` sibling).
520
520
  *
@@ -532,8 +532,7 @@ type OnWarningCallback = (warning: DicomParseWarning) => void;
532
532
  /**
533
533
  * Options accepted by `parseDicom`.
534
534
  *
535
- * Per `02-CONTEXT.md` D-02 - Phase 2 form only. No `profile` field; Phase 6
536
- * adds it. With `exactOptionalPropertyTypes: true`, callers omit unset keys
535
+ * With `exactOptionalPropertyTypes: true`, callers omit unset keys
537
536
  * rather than passing `undefined` for any field below.
538
537
  *
539
538
  * @example
@@ -579,7 +578,7 @@ interface ParseOptions {
579
578
  * treatment is in the package's troubleshooting docs. The `DicomParseError`
580
579
  * this option raises in its place is a different and larger surface: it also
581
580
  * carries `snippet`,
582
- * **16 raw bytes, unredacted** (D-10), read at the warning's own `byteOffset`.
581
+ * **16 raw bytes, unredacted**, read at the warning's own `byteOffset`.
583
582
  * A message-only PHI review of the lenient path therefore does not transfer to
584
583
  * the strict one. Log `err.code`, `err.byteOffset`, `err.offsetFrame` and
585
584
  * `err.message`; treat `err.snippet` as PHI.
@@ -623,7 +622,7 @@ interface ParseOptions {
623
622
  /**
624
623
  * Synchronous callback invoked once per Tier-2 warning, after the warning
625
624
  * has been pushed to `Dataset.warnings`. Throwing handlers are silently
626
- * swallowed (parser-state safety per D-03).
625
+ * swallowed, for parser-state safety.
627
626
  *
628
627
  * Omit to skip the callback entirely.
629
628
  */
@@ -634,11 +633,11 @@ interface ParseOptions {
634
633
  * default), `Element.rawBytes` is `Buffer.subarray(slice)` - a zero-copy
635
634
  * view that pins the source ArrayBuffer until every Element is GC'd.
636
635
  *
637
- * Per D-16 / MODEL-03. Omit to use the default.
636
+ * Omit to use the default.
638
637
  */
639
638
  readonly copyValues?: boolean;
640
639
  /**
641
- * Source/vendor tolerance preset (Phase 6, D-45). Applies the profile's
640
+ * Source/vendor tolerance preset. Applies the profile's
642
641
  * `escalations` / `suppressions` to warning emission and its
643
642
  * `privateDictionary` to Implicit-VR resolution of private data elements.
644
643
  * A profile only tightens or annotates - it never makes the default
@@ -655,13 +654,12 @@ interface ParseOptions {
655
654
  * Tier-2 warning registry and factories for the `@cosyte/dicom` parser
656
655
  * pipeline.
657
656
  *
658
- * Phase 2 core-parser context:
659
- * - D-08 - `WARNING_CODES` is a frozen `as const` registry with every code
660
- * in the TOL-03 catalog (≥25 entries). Phase 2 actively emits 13;
661
- * 7 VR-decode-time codes are reserved for Phase 3; 2 charset codes are
662
- * reserved for Phase 4; 2 codes are reserved for Phase 6 / Phase 7.
663
- * - D-12 - Exactly one named factory per actively-emitted code; each
664
- * factory carries its own JSDoc + `@example` and returns a typed
657
+ * Two rules govern it:
658
+ * - `WARNING_CODES` is a frozen `as const` registry carrying every code
659
+ * this package defines, including the ones no stage emits yet, so the
660
+ * schema is stable for consumers.
661
+ * - Exactly one named factory per actively-emitted code; each factory
662
+ * carries its own JSDoc + `@example` and returns a typed
665
663
  * `DicomParseWarning`.
666
664
  *
667
665
  * Consumers compare `warning.code === WARNING_CODES.<CODE>` to narrow and
@@ -677,9 +675,8 @@ interface ParseOptions {
677
675
  * The registry is frozen via `as const` so TypeScript infers the exact
678
676
  * string-literal union for `WarningCode` - there is zero runtime cost and
679
677
  * no magic-string comparisons for consumers. Reserved-but-not-emitted
680
- * codes carry inline comments documenting which phase activates them
681
- * (Phase 2 declares the union so the schema is stable for downstream
682
- * phases per D-08, D-42, D-43).
678
+ * codes carry inline comments documenting which stage activates them; the
679
+ * union is declared whole so the schema is stable for consumers.
683
680
  *
684
681
  * @example
685
682
  * ```ts
@@ -691,6 +688,10 @@ interface ParseOptions {
691
688
  * ```
692
689
  */
693
690
  declare const WARNING_CODES: {
691
+ readonly DICOM_DIRECTORY_OFFSET_DEFLATED: "DICOM_DIRECTORY_OFFSET_DEFLATED";
692
+ readonly DICOM_DIRECTORY_OFFSET_MALFORMED: "DICOM_DIRECTORY_OFFSET_MALFORMED";
693
+ readonly DICOM_DIRECTORY_OFFSET_UNRESOLVED: "DICOM_DIRECTORY_OFFSET_UNRESOLVED";
694
+ readonly DICOM_DIRECTORY_RECORD_REVISITED: "DICOM_DIRECTORY_RECORD_REVISITED";
694
695
  readonly DICOM_DUPLICATE_FILE_META_ELEMENT: "DICOM_DUPLICATE_FILE_META_ELEMENT";
695
696
  readonly DICOM_DUPLICATE_TAG_IN_DATA_SET: "DICOM_DUPLICATE_TAG_IN_DATA_SET";
696
697
  readonly DICOM_EMPTY_ITEM_IN_SEQUENCE: "DICOM_EMPTY_ITEM_IN_SEQUENCE";
@@ -702,6 +703,7 @@ declare const WARNING_CODES: {
702
703
  readonly DICOM_MISSING_PREAMBLE: "DICOM_MISSING_PREAMBLE";
703
704
  readonly DICOM_NONZERO_RESERVED_BYTES: "DICOM_NONZERO_RESERVED_BYTES";
704
705
  readonly DICOM_ODD_LENGTH_VALUE_PADDED: "DICOM_ODD_LENGTH_VALUE_PADDED";
706
+ readonly DICOM_PIXEL_DATA_FRAGMENTS_NOT_DELIMITED: "DICOM_PIXEL_DATA_FRAGMENTS_NOT_DELIMITED";
705
707
  readonly DICOM_PIXEL_DATA_LENGTH_MISMATCH: "DICOM_PIXEL_DATA_LENGTH_MISMATCH";
706
708
  readonly DICOM_PRIVATE_TAG_NO_CREATOR: "DICOM_PRIVATE_TAG_NO_CREATOR";
707
709
  readonly DICOM_SQ_NOT_DESCENDED: "DICOM_SQ_NOT_DESCENDED";
@@ -716,15 +718,27 @@ declare const WARNING_CODES: {
716
718
  readonly DICOM_TRAILING_NULL_IN_TEXT_VR: "DICOM_TRAILING_NULL_IN_TEXT_VR";
717
719
  readonly DICOM_UI_TRAILING_SPACE: "DICOM_UI_TRAILING_SPACE";
718
720
  readonly DICOM_CHARSET_AMBIGUOUS_SEPARATOR: "DICOM_CHARSET_AMBIGUOUS_SEPARATOR";
721
+ readonly DICOM_CHARSET_BYTES_UNDECODABLE: "DICOM_CHARSET_BYTES_UNDECODABLE";
722
+ readonly DICOM_CHARSET_ESCAPE_UNDECLARED: "DICOM_CHARSET_ESCAPE_UNDECLARED";
723
+ readonly DICOM_CHARSET_EXTENSION_NOT_RESET: "DICOM_CHARSET_EXTENSION_NOT_RESET";
719
724
  readonly DICOM_UNSUPPORTED_CHARSET: "DICOM_UNSUPPORTED_CHARSET";
720
725
  readonly DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED: "DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED";
726
+ readonly DICOM_DEIDENT_DATES_NOT_TRANSFORMED: "DICOM_DEIDENT_DATES_NOT_TRANSFORMED";
727
+ readonly DICOM_DEIDENT_DICOMDIR_FILE_SET_NOT_DISCHARGED: "DICOM_DEIDENT_DICOMDIR_FILE_SET_NOT_DISCHARGED";
721
728
  readonly DICOM_DEIDENT_EMBEDDED_ATTRIBUTE_REMOVED: "DICOM_DEIDENT_EMBEDDED_ATTRIBUTE_REMOVED";
729
+ readonly DICOM_DEIDENT_FILE_META_REPLACED: "DICOM_DEIDENT_FILE_META_REPLACED";
730
+ readonly DICOM_DEIDENT_GROUP_0004_REMOVED: "DICOM_DEIDENT_GROUP_0004_REMOVED";
731
+ readonly DICOM_DEIDENT_METHOD_CODES_PRIOR_REPLACED: "DICOM_DEIDENT_METHOD_CODES_PRIOR_REPLACED";
732
+ readonly DICOM_DEIDENT_METHOD_CODES_PRIOR_RETAINED: "DICOM_DEIDENT_METHOD_CODES_PRIOR_RETAINED";
722
733
  readonly DICOM_DEIDENT_METHOD_NOT_ADDED: "DICOM_DEIDENT_METHOD_NOT_ADDED";
723
734
  readonly DICOM_DEIDENT_METHOD_NOT_LO: "DICOM_DEIDENT_METHOD_NOT_LO";
724
735
  readonly DICOM_DEIDENT_METHOD_PRIOR_RETAINED: "DICOM_DEIDENT_METHOD_PRIOR_RETAINED";
725
736
  readonly DICOM_DEIDENT_METHOD_VALUE_OVER_LENGTH: "DICOM_DEIDENT_METHOD_VALUE_OVER_LENGTH";
737
+ readonly DICOM_DEIDENT_PRIVATE_CARRIER_NOT_AUDITABLE: "DICOM_DEIDENT_PRIVATE_CARRIER_NOT_AUDITABLE";
738
+ readonly DICOM_DEIDENT_PRIVATE_DECLARATION_NOT_RESOLVED: "DICOM_DEIDENT_PRIVATE_DECLARATION_NOT_RESOLVED";
726
739
  readonly DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE: "DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE";
727
740
  readonly DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE: "DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE";
741
+ readonly DICOM_DEIDENT_UNREGISTERED_ELEMENT_REMOVED: "DICOM_DEIDENT_UNREGISTERED_ELEMENT_REMOVED";
728
742
  readonly DICOM_PRIVATE_CREATOR_UNKNOWN: "DICOM_PRIVATE_CREATOR_UNKNOWN";
729
743
  };
730
744
  /**
@@ -750,7 +764,7 @@ type WarningCode = (typeof WARNING_CODES)[keyof typeof WARNING_CODES];
750
764
  * subclass) so they can be safely accumulated into `Dataset.warnings` and
751
765
  * passed to `onWarning` callbacks.
752
766
  *
753
- * Per D-07 there is intentionally NO `snippet` field on warnings:
767
+ * There is intentionally NO `snippet` field on warnings:
754
768
  * real-world files routinely produce 50+ warnings and a per-warning
755
769
  * snippet would balloon retained memory. Snippets appear only on
756
770
  * `DicomParseError` (the strict-mode escalation path).
@@ -772,43 +786,66 @@ interface DicomParseWarning {
772
786
  }
773
787
 
774
788
  /**
775
- * Phase 2 structural `Item` - a single `(FFFE,E000)`-delimited Item
789
+ * The structural `Item` - a single `(FFFE,E000)`-delimited Item
776
790
  * inside a Sequence, carrying a nested `Dataset`.
777
791
  *
778
- * Per `02-CONTEXT.md` D-04: structural surface only; navigation methods
779
- * land in Phase 3 (via `Dataset` extension per D-42).
792
+ * Structural surface only; navigation comes from the `Dataset` superclass.
780
793
  *
781
794
  * @module
782
795
  */
783
796
 
784
797
  /**
785
798
  * Initialiser shape for an `Item` - extends `DatasetInit` with `index`,
786
- * the 0-based position of the item inside its parent `Sequence`.
799
+ * the 0-based position of the item inside its parent `Sequence`, and the
800
+ * optional `fileOffset` the parser records (see {@link Item.fileOffset}).
787
801
  *
788
802
  * @internal
789
803
  */
790
804
  interface ItemInit extends DatasetInit {
791
805
  readonly index: number;
806
+ readonly fileOffset?: number;
792
807
  }
793
808
  /**
794
809
  * One sequence item. Inherits `fileMeta` (always `undefined` for nested
795
810
  * items), `warnings`, and the protected element map from `Dataset`.
796
811
  *
797
- * Phase 3 surfaces `Item.get(...)` / `Item.has(...)` / etc. via the
798
- * `Dataset` superclass extension per D-42.
812
+ * `Item.get(...)` / `Item.has(...)` and the rest come from the `Dataset`
813
+ * superclass.
799
814
  *
800
815
  * @example
801
816
  * ```ts
802
817
  * import { Item } from "@cosyte/dicom";
803
- * // Producers (parser plan 02-04) construct items as follows:
818
+ * // The parser constructs items as follows:
804
819
  * // const item = new Item({ index: 0, warnings: [], elements: new Map() });
805
820
  * ```
806
821
  */
807
822
  declare class Item extends Dataset {
808
823
  readonly index: number;
824
+ /**
825
+ * Byte offset of this Item's `(FFFE,E000)` Item tag in the Part 10 file it
826
+ * was read from, counted from the first byte of the File Preamble, which is
827
+ * how PS3.3 2026d Table F.3-3 counts a DICOMDIR's offsets. For a file read
828
+ * without a preamble, the 132 bytes of File Preamble and `DICM` prefix it
829
+ * lacks are still counted, so the File Meta group's first byte is offset 132.
830
+ *
831
+ * `undefined` for an Item with no position in a file: one inside a Deflated
832
+ * Data Set (a byte of a deflated stream is not a byte of the file), and one
833
+ * built by hand. `deidentify()` carries it from each source Item to the Item
834
+ * it rebuilds, as that Item's identity: it is how `serializeDicom` ties a
835
+ * DICOMDIR offset to the Directory Record it named.
836
+ *
837
+ * @example
838
+ * ```ts
839
+ * import { parseDicom } from "@cosyte/dicom";
840
+ * const ds = parseDicom(buf);
841
+ * const first = ds.get("00041220")?.items?.[0];
842
+ * first?.fileOffset; // where that Directory Record Item starts in the file
843
+ * ```
844
+ */
845
+ readonly fileOffset: number | undefined;
809
846
  /**
810
847
  * Construct a new structural `Item`. Producers are the SQ / FFFE
811
- * marker parsers in plan 02-04.
848
+ * marker parsers.
812
849
  *
813
850
  * @internal
814
851
  */
@@ -816,7 +853,7 @@ declare class Item extends Dataset {
816
853
  }
817
854
 
818
855
  /**
819
- * Decoded-value model for Phase 3 - the typed result of `Element.value`.
856
+ * Decoded-value model - the typed result of `Element.value`.
820
857
  *
821
858
  * `DicomValue` is a discriminated union keyed on `kind`; each variant maps
822
859
  * to a family of VRs that share a decode shape (per PS3.5 §6.2 Table 6.2-1):
@@ -1021,18 +1058,14 @@ type DicomValue = {
1021
1058
  };
1022
1059
 
1023
1060
  /**
1024
- * Phase 2 structural `Element` - leaf wrapper over an on-wire DICOM
1061
+ * The structural `Element` - leaf wrapper over an on-wire DICOM
1025
1062
  * Data Element.
1026
1063
  *
1027
- * Per `02-CONTEXT.md` D-04 + D-42: structural fields ONLY. No `.value`
1028
- * getter, no decoders, no `.items` navigation - those land in Phase 3,
1029
- * which extends this class.
1030
- *
1031
- * Per D-16: `rawBytes` is a `Buffer.subarray()` view over the source
1032
- * buffer by default (zero-copy), pinning the source ArrayBuffer until
1033
- * every Element is GC'd. The `parseDicom` `{ copyValues: true }` option
1034
- * flips production to `Buffer.from(slice)` so the source can be
1035
- * released; producers (parser plans 02-04 onward) honour it.
1064
+ * `rawBytes` is a `Buffer.subarray()` view over the source buffer by default
1065
+ * (zero-copy), pinning the source ArrayBuffer until every Element is GC'd.
1066
+ * The `parseDicom` `{ copyValues: true }` option flips production to
1067
+ * `Buffer.from(slice)` so the source can be released; every producer in the
1068
+ * parser honours it.
1036
1069
  *
1037
1070
  * @module
1038
1071
  */
@@ -1042,7 +1075,7 @@ type DicomValue = {
1042
1075
  *
1043
1076
  * `privateCreator` is omitted (not `undefined`) for standard tags;
1044
1077
  * present only when the parser resolved a `(gggg,EEFF)` private element
1045
- * to its registered Private Creator string per D-33 / D-34.
1078
+ * to its registered Private Creator string.
1046
1079
  *
1047
1080
  * @internal
1048
1081
  */
@@ -1055,10 +1088,10 @@ interface ElementInit {
1055
1088
  readonly byteOffset: number;
1056
1089
  readonly privateCreator?: string;
1057
1090
  /**
1058
- * Phase 2 hint set by the parser when an `Element` was promoted from
1059
- * `VR=UN` with undefined length to `VR=SQ` via the CP-246 fallback (D-30).
1060
- * Phase 3 reads this to choose the inner SQ decoder (Implicit VR LE) on
1061
- * first lazy access. Never set on standard SQ elements.
1091
+ * Hint set by the parser when an `Element` was promoted from
1092
+ * `VR=UN` with undefined length to `VR=SQ` via the CP-246 fallback.
1093
+ * The lazy value decoder reads this to choose the inner SQ decoder
1094
+ * (Implicit VR LE) on first access. Never set on standard SQ elements.
1062
1095
  *
1063
1096
  * @internal
1064
1097
  */
@@ -1066,14 +1099,14 @@ interface ElementInit {
1066
1099
  /**
1067
1100
  * Byte order of the value bytes, set by the parser per transfer syntax
1068
1101
  * (`true` for Implicit/Explicit VR LE + Deflated; `false` for Explicit VR
1069
- * BE). Phase 3 numeric decoders read this - signedness comes from the VR,
1102
+ * BE). The numeric decoders read this - signedness comes from the VR,
1070
1103
  * endianness from here.
1071
1104
  */
1072
1105
  readonly littleEndian: boolean;
1073
1106
  /**
1074
1107
  * The dataset's resolved `(0008,0005)` Specific Character Set terms in
1075
1108
  * effect for this element (parent dataset's, or the item's own override).
1076
- * Omitted when the Default Repertoire (ISO_IR 6) applies. Phase 3
1109
+ * Omitted when the Default Repertoire (ISO_IR 6) applies. The
1077
1110
  * charset-dependent text decoders (`LO SH UC LT ST UT PN`) read this.
1078
1111
  */
1079
1112
  readonly specificCharacterSet?: readonly string[];
@@ -1085,13 +1118,11 @@ interface ElementInit {
1085
1118
  readonly items?: readonly Item[];
1086
1119
  }
1087
1120
  /**
1088
- * One DICOM Data Element as parsed by Phase 2.
1121
+ * One DICOM Data Element as parsed.
1089
1122
  *
1090
- * Phase 2 surface (this plan): `tag`, `vr`, `vm`, `length`, `rawBytes`,
1091
- * `byteOffset`, `privateCreator`. NO `.value` getter, NO decoders, NO
1092
- * navigation methods. Phase 3 (per D-42) extends this class with a lazy,
1093
- * memoized `.value` getter and the VR-aware decoders under
1094
- * `src/dataset/vr/`.
1123
+ * Structural surface: `tag`, `vr`, `vm`, `length`, `rawBytes`,
1124
+ * `byteOffset`, `privateCreator`. On top of it sits a lazy, memoized
1125
+ * `.value` getter backed by the VR-aware decoders under `src/dataset/vr/`.
1095
1126
  *
1096
1127
  * `rawBytes` retention behaviour: zero-copy `Buffer.subarray` view by
1097
1128
  * default (pins source ArrayBuffer); pass `{ copyValues: true }` to
@@ -1124,10 +1155,10 @@ declare class Element {
1124
1155
  readonly byteOffset: number;
1125
1156
  readonly privateCreator: string | undefined;
1126
1157
  /**
1127
- * Phase 2 hint for Phase 3's lazy SQ decoder per D-30. `true` when this
1128
- * Element was promoted from `VR=UN` with undefined length to `VR=SQ` via
1129
- * the CP-246 fallback. Phase 3 uses this to choose the Implicit VR LE
1130
- * inner decoder. Always `undefined` on standard SQ elements.
1158
+ * Hint for the lazy SQ decoder. `true` when this Element was promoted
1159
+ * from `VR=UN` with undefined length to `VR=SQ` via the CP-246 fallback,
1160
+ * which is what selects the Implicit VR LE inner decoder. Always
1161
+ * `undefined` on standard SQ elements.
1131
1162
  *
1132
1163
  * @internal
1133
1164
  */
@@ -1141,9 +1172,9 @@ declare class Element {
1141
1172
  /** Lazily-decoded, memoized {@link DicomValue}. */
1142
1173
  private _valueCache;
1143
1174
  /**
1144
- * Construct a new structural `Element`. Producers (parser plans 02-03
1145
- * onward) build these directly from on-wire bytes; consumers receive
1146
- * them via `Dataset` after `parseDicom`.
1175
+ * Construct a new structural `Element`. The parser builds these directly
1176
+ * from on-wire bytes; consumers receive them via `Dataset` after
1177
+ * `parseDicom`.
1147
1178
  *
1148
1179
  * @internal
1149
1180
  */
@@ -1167,13 +1198,12 @@ declare class Element {
1167
1198
  }
1168
1199
 
1169
1200
  /**
1170
- * Phase 2 structural `FileMeta` view-object shape.
1201
+ * The structural `FileMeta` view-object shape.
1171
1202
  *
1172
- * Per `02-CONTEXT.md` D-04 + D-17 + FM-02: a plain interface (not a class)
1173
- * since Phase 2 has no methods. Only `transferSyntaxUID` is required;
1174
- * the rest are populated by `parseFileMeta` (plan 02-02) when present
1175
- * but never enforced (Phase 7's `validate()` enforces FM Type-1 fields
1176
- * per D-19). Phase 3 may promote to a class if helpers are added.
1203
+ * A plain interface rather than a class, because it carries no methods.
1204
+ * Only `transferSyntaxUID` is required; the rest are populated by
1205
+ * `parseFileMeta` when present but never enforced (`validate()` is what
1206
+ * enforces File Meta Type-1 fields).
1177
1207
  *
1178
1208
  * @module
1179
1209
  */
@@ -1189,6 +1219,11 @@ declare class Element {
1189
1219
  * on-wire value, even-length per PS3.5 §6.2) rather than dropped. `value` is a
1190
1220
  * defensive copy, so the view never aliases the parsed input buffer.
1191
1221
  *
1222
+ * 🛑 **THE ROUND TRIP IS PARSE-THEN-SERIALIZE ONLY. `deidentify()` DROPS EVERY
1223
+ * ELEMENT THIS TYPE DESCRIBES**, and the exact examples above are why: a Sending
1224
+ * AE Title and a Private Information pair are the identity information PS3.15
1225
+ * §E.1.1 names. See {@link FileMeta.extraElements} for the whole carve-out.
1226
+ *
1192
1227
  * @example
1193
1228
  * ```ts
1194
1229
  * import { parseDicom } from "@cosyte/dicom";
@@ -1217,9 +1252,9 @@ interface FileMetaRawElement {
1217
1252
  * over `(0002,xxxx)` elements parsed during the File Meta pre-pass.
1218
1253
  *
1219
1254
  * Only `transferSyntaxUID` is required because it is the dispatch input
1220
- * for the four v1 transfer-syntax parsers; everything else is optional
1255
+ * for the transfer-syntax parsers; everything else is optional
1221
1256
  * because real-world clinical files routinely omit one or more
1222
- * Type-1 elements. Phase 7's `validate()` adds opinion-bearing checks
1257
+ * Type-1 elements. `validate()` adds opinion-bearing checks
1223
1258
  * for those missing elements.
1224
1259
  *
1225
1260
  * @example
@@ -1243,15 +1278,139 @@ interface FileMeta {
1243
1278
  * Any `(0002,xxxx)` elements the source carried that the typed fields above
1244
1279
  * do not model, preserved in tag order so a round-trip re-emits the File Meta
1245
1280
  * group byte-for-byte. Omitted when the group held only modeled elements.
1281
+ *
1282
+ * ## 🛑 The byte-for-byte round trip does NOT hold for `deidentify()` output
1283
+ *
1284
+ * That promise is about **parse then serialize**, and it is scoped to it
1285
+ * deliberately rather than by omission. `deidentify()` rebuilds this group to
1286
+ * describe the de-identifying application instead of the source (PS3.15
1287
+ * §E.1.1: the File Meta Information "shall be replaced with a description of
1288
+ * the de-identifying application", because otherwise "identity information may
1289
+ * leak through unmodified File Meta Information ... includ[ing] information
1290
+ * regarding Application Entity Titles, Presentation Addresses, implementation
1291
+ * information, and private information"). So on the de-identify path this
1292
+ * array is **dropped in full** - not filtered, not sampled - and
1293
+ * `sourceApplicationEntityTitle` goes with it, while
1294
+ * `implementationClassUID` and `implementationVersionName` are replaced with
1295
+ * this library's own.
1296
+ *
1297
+ * The elements that survive are the ones that identify the **object** rather
1298
+ * than its sender: `fileMetaInformationVersion`, `mediaStorageSOPClassUID`,
1299
+ * `transferSyntaxUID`, and `mediaStorageSOPInstanceUID` (remapped unless
1300
+ * `RetainUIDs` is active). What was dropped is counted on the report
1301
+ * `deidentify()` returns, so a caller learns the fidelity loss from the run
1302
+ * rather than by diffing the bytes.
1303
+ *
1304
+ * A caller that needs the source group verbatim must read it off the
1305
+ * **parsed** dataset; it is not recoverable from de-identified output, and it
1306
+ * is not meant to be.
1246
1307
  */
1247
1308
  readonly extraElements?: readonly FileMetaRawElement[];
1248
1309
  }
1249
1310
 
1250
1311
  /**
1251
- * Typed view model for the Phase 4 safety-critical domain helpers
1312
+ * The DICOMDIR Directory Record model: which records a DICOMDIR's offsets name,
1313
+ * and the tree they form.
1314
+ *
1315
+ * A DICOMDIR is a Dataset whose File Meta Media Storage SOP Class UID
1316
+ * `(0002,0002)` is `1.2.840.10008.1.3.10` (Media Storage Directory Storage). Its
1317
+ * Directory Record Sequence `(0004,1220)` holds one Item per record, and four
1318
+ * `UL` attributes link them by byte offset: `(0004,1200)` and `(0004,1202)` name
1319
+ * the first and last record of the root directory entity, and each record's
1320
+ * `(0004,1400)` names its next sibling and `(0004,1420)` the first record of its
1321
+ * lower-level entity. PS3.3 2026d Table F.3-3 counts each offset as "a number of
1322
+ * bytes starting with the first byte of the File Meta Information" and says it
1323
+ * "includes the File Preamble and the DICM Prefix"; zero means no record.
1324
+ *
1325
+ * **An offset resolves to a record only when it is exactly the file offset of
1326
+ * that record's `(FFFE,E000)` Item tag**, which the parser records on each Item
1327
+ * as {@link Item.fileOffset}. Nothing here scans the bytes for an Item tag: an
1328
+ * offset that lands on an Item of a Sequence nested inside a record, inside a
1329
+ * record's Data Set, on the Sequence's own header, or past the end of the file
1330
+ * names no record, and the parser warns (`DICOM_DIRECTORY_OFFSET_UNRESOLVED`).
1331
+ *
1332
+ * The analysis is one pure function of a Dataset, {@link analyzeDirectory}, read
1333
+ * three ways: `parseDicom` turns its findings into warnings, `Dataset.directory`
1334
+ * exposes its tree, and `serializeDicom` ties each offset it writes to a record
1335
+ * through the same {@link Item.fileOffset} identity.
1336
+ *
1337
+ * @module
1338
+ */
1339
+
1340
+ /**
1341
+ * One Directory Record of a DICOMDIR: an Item of the Directory Record Sequence
1342
+ * `(0004,1220)`, with the records its offsets name.
1343
+ *
1344
+ * @example
1345
+ * ```ts
1346
+ * import { parseDicom } from "@cosyte/dicom";
1347
+ * const dir = parseDicom(buf).directory;
1348
+ * for (const patient of dir?.root ?? []) {
1349
+ * patient.type; // "PATIENT"
1350
+ * for (const study of patient.lowerLevel) study.item.get("0020000D"); // Study Instance UID
1351
+ * }
1352
+ * ```
1353
+ */
1354
+ interface DirectoryRecord {
1355
+ /** The record's 0-based index in the Directory Record Sequence. */
1356
+ readonly index: number;
1357
+ /**
1358
+ * The Directory Record Type `(0004,1430)` value (`"PATIENT"`, `"STUDY"`,
1359
+ * `"SERIES"`, `"IMAGE"` and the rest PS3.3 F.5 defines), or `undefined` when
1360
+ * the record carries none.
1361
+ */
1362
+ readonly type: string | undefined;
1363
+ /**
1364
+ * The Referenced File ID `(0004,1500)`: its components in order (a path
1365
+ * relative to the File-set root, one component per value), as the file wrote
1366
+ * them less only the padding PS3.5 makes insignificant in `CS` (the trailing
1367
+ * pad, and each value's leading and trailing spaces). Kept verbatim: never
1368
+ * de-identified, rewritten or joined, and the element's own bytes on `item`
1369
+ * are untouched. `undefined` when the record carries none.
1370
+ */
1371
+ readonly referencedFileId: readonly string[] | undefined;
1372
+ /**
1373
+ * The records of this record's lower-level directory entity: the one its
1374
+ * `(0004,1420)` names, then each `(0004,1400)` successor in order. Empty when
1375
+ * the offset is zero or names no record.
1376
+ */
1377
+ readonly lowerLevel: readonly DirectoryRecord[];
1378
+ /** The record's own Data Set, for its record keys. */
1379
+ readonly item: Item;
1380
+ }
1381
+ /**
1382
+ * The Directory Record tree of a DICOMDIR, read from its offsets.
1383
+ *
1384
+ * `records` is every Item of the Directory Record Sequence, in order, whether
1385
+ * or not an offset reaches it. `root` is the root directory entity: the record
1386
+ * `(0004,1200)` names, then each `(0004,1400)` successor in order. A record is
1387
+ * reached at most once: an offset that would reach one again is not followed.
1388
+ *
1389
+ * @example
1390
+ * ```ts
1391
+ * import { parseDicom, type DirectoryRecord } from "@cosyte/dicom";
1392
+ * const dir = parseDicom(buf).directory; // undefined unless the object is a DICOMDIR
1393
+ * const walk = (records: readonly DirectoryRecord[], depth = 0): void => {
1394
+ * for (const r of records) {
1395
+ * console.log(" ".repeat(depth), r.type, r.referencedFileId?.join("/"));
1396
+ * walk(r.lowerLevel, depth + 1);
1397
+ * }
1398
+ * };
1399
+ * if (dir !== undefined) walk(dir.root);
1400
+ * ```
1401
+ */
1402
+ interface DicomDirectory {
1403
+ /** Every Directory Record, in Directory Record Sequence order. */
1404
+ readonly records: readonly DirectoryRecord[];
1405
+ /** The root directory entity, in `(0004,1400)` order. */
1406
+ readonly root: readonly DirectoryRecord[];
1407
+ }
1408
+
1409
+ /**
1410
+ * Typed view model for the safety-critical domain helpers
1252
1411
  * (`Dataset.patient` / `study` / `series` / `image`).
1253
1412
  *
1254
- * Every field here obeys the §4 fail-safe contract from the dicom roadmap:
1413
+ * Every field here obeys the same fail-safe contract:
1255
1414
  * a missing value is **typed-absent** (`undefined`), never a substituted
1256
1415
  * default; a malformed numeric component stays `null` inside its array
1257
1416
  * rather than being coerced; raw bytes are always still reachable via the
@@ -1285,7 +1444,7 @@ interface OtherPatientId {
1285
1444
  readonly typeCode?: string;
1286
1445
  }
1287
1446
  /**
1288
- * Patient & study identity (§4.1 - the wrong-patient failure class).
1447
+ * Patient & study identity (the wrong-patient failure class).
1289
1448
  *
1290
1449
  * `id` is **not globally unique**; correct cross-system matching needs the
1291
1450
  * `{id, issuerOfId, issuerQualifiers}` tuple plus `otherIds`. `name` keeps
@@ -1310,7 +1469,7 @@ interface PatientView {
1310
1469
  readonly sex?: string;
1311
1470
  }
1312
1471
  /**
1313
- * Study-level identity (§4.1). `instanceUid` is the cross-system study key;
1472
+ * Study-level identity. `instanceUid` is the cross-system study key;
1314
1473
  * `accessionNumber` ties the study to the order.
1315
1474
  *
1316
1475
  * @example
@@ -1330,7 +1489,7 @@ interface StudyView {
1330
1489
  readonly description?: string;
1331
1490
  }
1332
1491
  /**
1333
- * Series-level identity & co-registration (§4.1, §4.3). Images sharing a
1492
+ * Series-level identity & co-registration. Images sharing a
1334
1493
  * `frameOfReferenceUid` are spatially co-registered.
1335
1494
  *
1336
1495
  * @example
@@ -1373,7 +1532,7 @@ interface CodedConcept {
1373
1532
  readonly schemeUid?: string;
1374
1533
  }
1375
1534
  /**
1376
- * A Real World Value Mapping (§4.5) - slope/intercept bound atomically to
1535
+ * A Real World Value Mapping - slope/intercept bound atomically to
1377
1536
  * its UCUM measurement-units code, so a number is never detached from its
1378
1537
  * units. From Real World Value Mapping Sequence `(0040,9096)`.
1379
1538
  *
@@ -1390,7 +1549,7 @@ interface RealWorldValueMap {
1390
1549
  }
1391
1550
  /**
1392
1551
  * The five Enhanced multi-frame functional-group macros, resolved for a
1393
- * single frame Per-Frame-else-Shared (§4.4, PS3.3 §C.7.6.16). Each macro is
1552
+ * single frame Per-Frame-else-Shared (PS3.3 §C.7.6.16). Each macro is
1394
1553
  * typed-absent when present in neither the per-frame nor the shared group
1395
1554
  * (the three *geometry* macros are treated as required for an enhanced
1396
1555
  * object - see {@link ImageView.frame}).
@@ -1427,7 +1586,7 @@ interface FrameFunctionalGroups {
1427
1586
  };
1428
1587
  }
1429
1588
  /**
1430
- * Pixel-interpretation + geometry metadata (§4.2 / §4.3 / §4.4 / §4.5) - the
1589
+ * Pixel-interpretation + geometry metadata - the
1431
1590
  * "wrong pixels look fine" and "looks fine, measures wrong" classes. v1 does
1432
1591
  * not decode pixels; this view surfaces exactly what a renderer needs so it
1433
1592
  * does not have to guess.
@@ -1492,14 +1651,13 @@ interface ImageView {
1492
1651
  }
1493
1652
 
1494
1653
  /**
1495
- * Phase 2 structural `Dataset` - top-level container produced by
1654
+ * The structural `Dataset` - top-level container produced by
1496
1655
  * `parseDicom`.
1497
1656
  *
1498
- * Per `02-CONTEXT.md` D-04 + D-42: only `fileMeta` and `warnings` are
1499
- * public; the element map is held internally and Phase 3 extends this
1500
- * class with the `get` / `has` / `elements` / `getAll` / `setElement` /
1501
- * `addElement` / `removeElement` / `addItem` / `removeItem` navigation
1502
- * surface.
1657
+ * Only `fileMeta` and `warnings` are public data; the element map is held
1658
+ * internally and reached through the `get` / `has` / `elements` / `getAll` /
1659
+ * `setElement` / `addElement` / `removeElement` / `addItem` / `removeItem`
1660
+ * navigation surface.
1503
1661
  *
1504
1662
  * The `warnings` array is frozen at the constructor boundary (mirrors
1505
1663
  * `@cosyte/hl7` sibling `model/message.ts`) so consumers receive
@@ -1523,12 +1681,11 @@ interface DatasetInit {
1523
1681
  readonly elements: ReadonlyMap<Tag, Element>;
1524
1682
  }
1525
1683
  /**
1526
- * One parsed DICOM dataset - the structural shell shipped by Phase 2.
1684
+ * One parsed DICOM dataset - the structural shell.
1527
1685
  *
1528
- * Phase 2 public surface: `fileMeta`, `warnings`. The internal element
1529
- * map is stored on `_elements` (protected) and Phase 3 promotes it to
1530
- * the public `get` / `has` / `elements` / `getAll` navigation API per
1531
- * D-42.
1686
+ * Public data: `fileMeta`, `warnings`. The internal element map is stored
1687
+ * on `_elements` (protected) and reached through the public
1688
+ * `get` / `has` / `elements` / `getAll` navigation API.
1532
1689
  *
1533
1690
  * @example
1534
1691
  * ```ts
@@ -1544,21 +1701,23 @@ declare class Dataset {
1544
1701
  readonly fileMeta: FileMeta | undefined;
1545
1702
  readonly warnings: readonly DicomParseWarning[];
1546
1703
  /**
1547
- * Element map, keyed by uppercase 8-hex tag. Phase 3 promotes to
1548
- * public navigation surface; Phase 2 keeps it `protected` so only
1549
- * subclasses (`Item`, future Phase-3 extensions) can introspect.
1704
+ * Element map, keyed by uppercase 8-hex tag. Reached publicly through the
1705
+ * navigation surface; kept `protected` here so only subclasses (`Item`, and
1706
+ * any later extension) can introspect it directly.
1550
1707
  *
1551
1708
  * @internal
1552
1709
  */
1553
1710
  protected readonly _elements: ReadonlyMap<Tag, Element>;
1554
- /** Memoised Phase 4 domain views (built once, on first access). */
1711
+ /** Memoised domain views (built once, on first access). */
1555
1712
  private _patient?;
1556
1713
  private _study?;
1557
1714
  private _series?;
1558
1715
  private _image?;
1716
+ /** Boxed so a memoised "not a DICOMDIR" is told apart from "not read yet". */
1717
+ private _directory?;
1559
1718
  /**
1560
- * Construct a new structural `Dataset`. Phase 2 freezes the warnings
1561
- * array at the model boundary (sibling `message.ts` lines 117–118).
1719
+ * Construct a new structural `Dataset`. The warnings array is frozen at
1720
+ * the model boundary.
1562
1721
  *
1563
1722
  * @internal
1564
1723
  */
@@ -1614,7 +1773,7 @@ declare class Dataset {
1614
1773
  */
1615
1774
  getAll(tag: Tag): readonly Element[];
1616
1775
  /**
1617
- * Patient-identity view (§4.1). Fail-safe typed-absent fields; `id` is
1776
+ * Patient-identity view. Fail-safe typed-absent fields; `id` is
1618
1777
  * **not** globally unique - match on the `{id, issuerOfId, ...}` tuple
1619
1778
  * plus `otherIds`, never on a bare `(0010,0020)`. Memoised on first read.
1620
1779
  *
@@ -1627,7 +1786,7 @@ declare class Dataset {
1627
1786
  */
1628
1787
  get patient(): PatientView;
1629
1788
  /**
1630
- * Study-identity view (§4.1). `instanceUid` is the cross-system study
1789
+ * Study-identity view. `instanceUid` is the cross-system study
1631
1790
  * key; `accessionNumber` ties it to the order. Memoised on first read.
1632
1791
  *
1633
1792
  * @example
@@ -1639,7 +1798,7 @@ declare class Dataset {
1639
1798
  */
1640
1799
  get study(): StudyView;
1641
1800
  /**
1642
- * Series-identity & co-registration view (§4.1, §4.3). A shared
1801
+ * Series-identity & co-registration view. A shared
1643
1802
  * `frameOfReferenceUid` means images are spatially co-registered.
1644
1803
  * Memoised on first read.
1645
1804
  *
@@ -1652,7 +1811,7 @@ declare class Dataset {
1652
1811
  */
1653
1812
  get series(): SeriesView;
1654
1813
  /**
1655
- * Pixel-interpretation + geometry view (§4.2–§4.5). Surfaces exactly
1814
+ * Pixel-interpretation + geometry view. Surfaces exactly
1656
1815
  * what a renderer needs without guessing: `rescaleSlope`/`signed`/
1657
1816
  * `photometricInterpretation` stay absent rather than defaulted, and the
1658
1817
  * three pixel-spacing tags are distinct. Memoised on first read.
@@ -1665,26 +1824,59 @@ declare class Dataset {
1665
1824
  * ```
1666
1825
  */
1667
1826
  get image(): ImageView;
1827
+ /**
1828
+ * The DICOMDIR Directory Record tree, or `undefined` when this Dataset is not
1829
+ * a DICOMDIR (its File Meta Media Storage SOP Class UID is not
1830
+ * `1.2.840.10008.1.3.10`). Read from the four offset attributes (PS3.3 2026d
1831
+ * Table F.3-3): `root` is the record `(0004,1200)` names and each
1832
+ * `(0004,1400)` successor, and each record's `lowerLevel` is the record its
1833
+ * `(0004,1420)` names and each successor. An offset resolves only when it is
1834
+ * exactly the file offset of an Item of the Directory Record Sequence
1835
+ * `(0004,1220)`; one that is not names no record, and `parseDicom` warns
1836
+ * (`DICOM_DIRECTORY_OFFSET_UNRESOLVED`, `DICOM_DIRECTORY_OFFSET_MALFORMED`,
1837
+ * `DICOM_DIRECTORY_RECORD_REVISITED`, `DICOM_DIRECTORY_OFFSET_DEFLATED`).
1838
+ * Memoised on first read.
1839
+ *
1840
+ * Limits: record keys are not checked against PS3.3 F.5, the File-set
1841
+ * Consistency Flag and `(0004,1202)`'s agreement with the end of the root
1842
+ * chain are not checked, and under Deflated Explicit VR LE no offset resolves,
1843
+ * so every record is listed in `records` and none is linked.
1844
+ *
1845
+ * @example
1846
+ * ```ts
1847
+ * import { parseDicom } from "@cosyte/dicom";
1848
+ * const dir = parseDicom(buf).directory;
1849
+ * for (const patient of dir?.root ?? []) {
1850
+ * for (const study of patient.lowerLevel) {
1851
+ * for (const series of study.lowerLevel) {
1852
+ * for (const image of series.lowerLevel) console.log(image.referencedFileId);
1853
+ * }
1854
+ * }
1855
+ * }
1856
+ * ```
1857
+ */
1858
+ get directory(): DicomDirectory | undefined;
1668
1859
  }
1669
1860
 
1670
1861
  /**
1671
1862
  * Public parser entry - `parseDicom`.
1672
1863
  *
1673
- * Pipeline (Phase 2 plan 02-02):
1674
- * 1. Dual EMPTY_INPUT check (raw input + post-normalize) per CONTEXT D-13.
1864
+ * Pipeline:
1865
+ * 1. Dual EMPTY_INPUT check (raw input + post-normalize).
1675
1866
  * 2. {@link normalizeInput} converts `Buffer | Uint8Array | ArrayBuffer`
1676
- * to a zero-copy `Buffer` view per PARSE-04.
1677
- * 3. {@link parsePart10Header} resolves preamble + DICM framing per D-14.
1867
+ * to a zero-copy `Buffer` view.
1868
+ * 3. {@link parsePart10Header} resolves preamble + DICM framing.
1678
1869
  * 4. {@link parseFileMeta} parses the (always Explicit VR LE) File Meta
1679
- * group per FM-01 / D-17.
1680
- * 5. {@link TRANSFER_SYNTAX_PARSERS} dispatches on TS UID per D-20;
1870
+ * group.
1871
+ * 5. {@link TRANSFER_SYNTAX_PARSERS} dispatches on TS UID;
1681
1872
  * unsupported UIDs throw `UNSUPPORTED_TRANSFER_SYNTAX` carrying the
1682
1873
  * `Dictionary.uid(uid)?.name` in `err.snippet`.
1683
- * 6. The chosen strategy returns a `ReadonlyMap<Tag, Element>` (Phase 2
1684
- * stubs - real bodies arrive in plans 02-03 / 02-04 / 02-05).
1685
- * 7. The result is assembled into a structural {@link Dataset}.
1874
+ * 6. The chosen strategy returns a `ReadonlyMap<Tag, Element>`.
1875
+ * 7. For a DICOMDIR, every Directory Record offset is resolved against the
1876
+ * Items' file offsets and each one that names no record is warned.
1877
+ * 8. The result is assembled into a structural {@link Dataset}.
1686
1878
  *
1687
- * Phase 6 (D-45) wires a source/vendor `Profile` via `ParseOptions.profile`:
1879
+ * A source/vendor `Profile` is wired in through `ParseOptions.profile`:
1688
1880
  * its `escalations` / `suppressions` reshape Tier-2 emission at the
1689
1881
  * {@link makeEmitter} chokepoint, and its private-dictionary overlay resolves
1690
1882
  * the Implicit VR of vendor private data elements.
@@ -1705,8 +1897,28 @@ declare class Dataset {
1705
1897
  * a recognizable `(0002,0000)` File Meta Group Length at offset 0.
1706
1898
  * - `INVALID_FILE_META` - File Meta is truncated or `(0002,0010)`
1707
1899
  * Transfer Syntax UID is missing.
1708
- * - `UNSUPPORTED_TRANSFER_SYNTAX` - Transfer Syntax UID is not one of the
1709
- * four v1 UIDs (`1.2.840.10008.1.2`, `…1.2.1`, `…1.2.2`, `…1.2.1.99`).
1900
+ * - `UNSUPPORTED_TRANSFER_SYNTAX` - Transfer Syntax UID is none of the
1901
+ * supported ones: the four native syntaxes (`1.2.840.10008.1.2`,
1902
+ * `…1.2.1`, `…1.2.2`, `…1.2.1.99`), every encapsulated Pixel Data
1903
+ * syntax PS3.5 2026c section A.4 names (JPEG, JPEG-LS, JPEG 2000, HTJ2K,
1904
+ * RLE, MPEG, HEVC, JPEG XL, Deflated Image Frame, Encapsulated
1905
+ * Uncompressed), and the four JPIP Referenced syntaxes of sections A.6,
1906
+ * A.7, A.11 and A.12. The SMPTE ST 2110 syntaxes and every retired UID
1907
+ * stay refused.
1908
+ *
1909
+ * An object under a section A.4 syntax is read under Explicit VR Little Endian
1910
+ * rules, as A.4 requires: its metadata is fully readable, and its Pixel Data
1911
+ * fragments are handed back as opaque, undecoded bytes by
1912
+ * {@link readPixelDataFragments}. **No pixel is ever decoded.**
1913
+ *
1914
+ * An object under a JPIP Referenced syntax is read the same way for metadata:
1915
+ * sections A.6 and A.11 make the Data Set Explicit VR Little Endian, and
1916
+ * sections A.7 and A.12 deflate that Data Set per RFC 1951, so it is inflated
1917
+ * first under the Deflated reader's decompression cap. Its Pixel Data Provider
1918
+ * URL (0028,7FE0) is returned as the `UR` element the file carries and is
1919
+ * **never fetched, resolved or validated**. The object is read-only here:
1920
+ * `serializeDicom` refuses to write the four JPIP syntaxes, and `deidentify`
1921
+ * refuses to de-identify an object under one of them.
1710
1922
  *
1711
1923
  * Pass `{ strict: true }` to escalate every Tier-2 warning to a thrown
1712
1924
  * `DicomParseError` carrying the warning code.
@@ -1716,7 +1928,7 @@ declare class Dataset {
1716
1928
  * import { parseDicom, WARNING_CODES, DicomParseError } from "@cosyte/dicom";
1717
1929
  * import { readFileSync } from "node:fs";
1718
1930
  *
1719
- * // Three input shapes (PARSE-04): Buffer, Uint8Array, ArrayBuffer.
1931
+ * // Three input shapes: Buffer, Uint8Array, ArrayBuffer.
1720
1932
  * const bytes = readFileSync("study.dcm");
1721
1933
  * const ds1 = parseDicom(bytes);
1722
1934
  * const ds2 = parseDicom(new Uint8Array(bytes));
@@ -1747,11 +1959,11 @@ declare function parseDicom(input: Buffer | Uint8Array | ArrayBuffer): Dataset;
1747
1959
  declare function parseDicom(input: Buffer | Uint8Array | ArrayBuffer, options: ParseOptions): Dataset;
1748
1960
 
1749
1961
  /**
1750
- * Phase 2 structural `Sequence` - wrapper around an SQ element's items.
1962
+ * The structural `Sequence` - wrapper around an SQ element's items.
1751
1963
  *
1752
- * Per `02-CONTEXT.md` D-04: structural surface only - `items` and
1753
- * `length`. No navigation methods. Phase 3 adds `Sequence.items[N].get(...)`
1754
- * via `Item`'s `Dataset`-superclass extension per D-42.
1964
+ * Structural surface only: `items` and `length`. No navigation methods of
1965
+ * its own; `Sequence.items[N].get(...)` comes from `Item`'s `Dataset`
1966
+ * superclass.
1755
1967
  *
1756
1968
  * @module
1757
1969
  */
@@ -1760,12 +1972,12 @@ declare function parseDicom(input: Buffer | Uint8Array | ArrayBuffer, options: P
1760
1972
  * One SQ (Sequence) element's structural body.
1761
1973
  *
1762
1974
  * `length` is the on-wire length: a real byte count for defined-length
1763
- * SQ, or `0xFFFFFFFF` (4_294_967_295) when undefined-length per D-29.
1975
+ * SQ, or `0xFFFFFFFF` (4_294_967_295) when undefined-length.
1764
1976
  *
1765
1977
  * @example
1766
1978
  * ```ts
1767
1979
  * import { Sequence } from "@cosyte/dicom";
1768
- * // Producers (parser plan 02-04) construct sequences as follows:
1980
+ * // The parser constructs sequences as follows:
1769
1981
  * // const sq = new Sequence([item0, item1], 0xFFFFFFFF);
1770
1982
  * ```
1771
1983
  */
@@ -1781,6 +1993,99 @@ declare class Sequence {
1781
1993
  constructor(items: readonly Item[], length: number);
1782
1994
  }
1783
1995
 
1996
+ /**
1997
+ * Encapsulated Pixel Data, handed back as raw bytes: the Basic Offset Table and
1998
+ * every fragment, exactly as the file carries them.
1999
+ *
2000
+ * An object under a PS3.5 2026c section A.4 Transfer Syntax (JPEG, JPEG-LS,
2001
+ * JPEG 2000, HTJ2K, RLE and the rest) carries its Pixel Data `(7FE0,0010)` as an
2002
+ * Item stream: a Basic Offset Table Item, then one Item per fragment of the
2003
+ * encoded pixel stream, then a Sequence Delimitation Item. The parser reads that
2004
+ * structure and never reads a fragment's content. This module is how a caller
2005
+ * gets the content: as opaque, **undecoded** bytes. No pixel is decompressed, no
2006
+ * frame is assembled from fragments, and the Basic Offset Table's offsets are
2007
+ * not interpreted, because each of those is a codec's or a viewer's decision and
2008
+ * this package decodes no pixels.
2009
+ *
2010
+ * @module
2011
+ */
2012
+
2013
+ /**
2014
+ * The Basic Offset Table and the fragments of one encapsulated Pixel Data
2015
+ * element, each as the raw bytes of its Item Value.
2016
+ *
2017
+ * Every `Buffer` here is a view over the element's own `Element.rawBytes`, with
2018
+ * no Item header and no Sequence Delimitation Item in it, so it follows that
2019
+ * element's retention: a zero-copy view over your input by default, and a copy
2020
+ * that outlives it under `parseDicom(bytes, { copyValues: true })`.
2021
+ *
2022
+ * @example
2023
+ * ```ts
2024
+ * import { parseDicom, readPixelDataFragments } from "@cosyte/dicom";
2025
+ * const pixels = readPixelDataFragments(parseDicom(buf));
2026
+ * if (pixels !== undefined) {
2027
+ * pixels.basicOffsetTable?.length; // 0 when the table is empty
2028
+ * pixels.fragments.length; // how many fragment Items the file carries
2029
+ * }
2030
+ * ```
2031
+ */
2032
+ interface PixelDataFragments {
2033
+ /**
2034
+ * The Basic Offset Table's Item Value, uninterpreted. **Zero bytes when the
2035
+ * table is empty**, which PS3.5 2026c section A.4 permits and decoders must
2036
+ * accept. `undefined` only when the Item stream holds no Item at all, which
2037
+ * A.4 does not permit; nothing is invented in its place.
2038
+ */
2039
+ readonly basicOffsetTable: Buffer | undefined;
2040
+ /**
2041
+ * The Item Value of every Item after the Basic Offset Table, in file order.
2042
+ * Each is returned whole: its content is never read, so bytes inside a
2043
+ * fragment that happen to look like a delimiter are fragment bytes (A.4: a
2044
+ * decoder "may not scan for a Sequence Delimitation Item").
2045
+ */
2046
+ readonly fragments: readonly Buffer[];
2047
+ }
2048
+ /**
2049
+ * Read the Basic Offset Table and every fragment of a Data Set's encapsulated
2050
+ * Pixel Data `(7FE0,0010)` as raw bytes, without decoding anything.
2051
+ *
2052
+ * Pass the root {@link Dataset} `parseDicom` returned: its Pixel Data is the
2053
+ * top-level element PS3.5 2026c section A.4 encapsulates. An `Item` is a
2054
+ * `Dataset` too, and is read the same way over its own elements.
2055
+ *
2056
+ * **`undefined` means this Data Set has no encapsulated fragments**: Pixel Data
2057
+ * is absent, or it is present in native format with an explicit Value Length.
2058
+ * That is never an empty list and never a Basic Offset Table, so it cannot be
2059
+ * mistaken for an encapsulated result.
2060
+ *
2061
+ * **The list can be short, and the parse says so.** A stream that ends with the
2062
+ * input before its Sequence Delimitation Item still parses, and
2063
+ * `DICOM_PIXEL_DATA_FRAGMENTS_NOT_DELIMITED` on `ds.warnings` says that the
2064
+ * fragments read may not be all the sender wrote. Read the warnings before
2065
+ * treating the list as complete.
2066
+ *
2067
+ * @throws `DicomParseError` with code `INVALID_FILE_META` when the element's
2068
+ * bytes are not an Item stream (an Item header cut short, an Item reaching past
2069
+ * the bytes, or a tag other than `(FFFE,E000)` / `(FFFE,E0DD)`). `parseDicom`
2070
+ * refuses such bytes itself, so only a hand-built `Element` reaches this; the
2071
+ * error carries no fragment byte and no length outside its 16-byte `snippet`.
2072
+ *
2073
+ * @example
2074
+ * ```ts
2075
+ * import { parseDicom, readPixelDataFragments } from "@cosyte/dicom";
2076
+ * const ds = parseDicom(buf); // e.g. a JPEG 2000 object
2077
+ * const pixels = readPixelDataFragments(ds);
2078
+ * if (pixels === undefined) {
2079
+ * // no encapsulated Pixel Data: absent, or native
2080
+ * } else {
2081
+ * for (const fragment of pixels.fragments) {
2082
+ * // hand `fragment` to a codec of your choice; it is the bytes as written
2083
+ * }
2084
+ * }
2085
+ * ```
2086
+ */
2087
+ declare function readPixelDataFragments(ds: Dataset): PixelDataFragments | undefined;
2088
+
1784
2089
  /**
1785
2090
  * VR-aware value dispatch - the body behind `Element.value`.
1786
2091
  *
@@ -1795,6 +2100,11 @@ declare class Sequence {
1795
2100
  * - **Charset**: `LO SH UC LT ST UT PN` decode through `(0008,0005)`;
1796
2101
  * the remaining string VRs are the Default Repertoire (ASCII, decoded
1797
2102
  * Latin-1-lenient with `DICOM_NON_ASCII_IN_ASCII_VR` on stray bytes).
2103
+ * A multi-valued `(0008,0005)` decodes with ISO 2022 code extensions
2104
+ * (`./iso2022`), resetting to Value 1 at the VR's own delimiters, and
2105
+ * flags `DICOM_CHARSET_ESCAPE_UNDECLARED`,
2106
+ * `DICOM_CHARSET_BYTES_UNDECODABLE` and
2107
+ * `DICOM_CHARSET_EXTENSION_NOT_RESET` at most once each per value.
1798
2108
  * - **Numeric strings** `DS`/`IS` → `number | null` (never `NaN`→0).
1799
2109
  * - **Temporal** `DA`/`TM`/`DT` → tolerant typed values (warn + preserve).
1800
2110
  * - **Bulk** `OB OD OF OL OV OW UN` → raw `binary` (not interpreted in v1).
@@ -1825,11 +2135,12 @@ declare function decodeElementValue(element: Element): DicomValue;
1825
2135
 
1826
2136
  /**
1827
2137
  * Specific Character Set `(0008,0005)` resolution + text decoding for the
1828
- * Phase 3 value decoders.
2138
+ * VR value decoders.
1829
2139
  *
1830
2140
  * Zero runtime deps: decoding rides on Node 22's full-ICU `TextDecoder`
1831
2141
  * (the `@cosyte/dicom` ≤3-dep ceiling keeps `iconv-lite` in reserve only if
1832
- * a future multi-extension CJK case forces it).
2142
+ * a future CJK case forces it). A multi-valued set's ISO 2022 code
2143
+ * extensions are decoded by `./iso2022`, set by set, on the same labels.
1833
2144
  *
1834
2145
  * Term-list corrections verified against PS3.3 §C.12.1.1.2 for this build
1835
2146
  * (the easy-to-get-wrong ones):
@@ -1885,6 +2196,10 @@ declare function isKnownCharsetTerm(term: string): boolean;
1885
2196
  * the term list mixes single-byte and code-extension sets; falls back to
1886
2197
  * the default repertoire (Latin-1, lenient) when no term resolves.
1887
2198
  *
2199
+ * {@link decodeText} decodes under this label when the list has one term, or
2200
+ * when Value 1 is `ISO_IR 192`, `GB18030` or `GBK`. Any other multi-term list
2201
+ * decodes with ISO 2022 code extensions instead, whatever label this returns.
2202
+ *
1888
2203
  * @example
1889
2204
  * ```ts
1890
2205
  * import { resolveDecoderLabel } from "@cosyte/dicom";
@@ -1894,14 +2209,26 @@ declare function isKnownCharsetTerm(term: string): boolean;
1894
2209
  */
1895
2210
  declare function resolveDecoderLabel(terms: readonly string[] | undefined): string;
1896
2211
  /**
1897
- * Decode bytes to a string under the resolved Specific Character Set,
1898
- * never throwing: an unsupported decoder label falls back to UTF-8, then to
1899
- * Latin-1 (1:1, cannot fail).
2212
+ * Decode bytes to a string under the Specific Character Set terms, never
2213
+ * throwing.
2214
+ *
2215
+ * With more than one term, and a Value 1 other than `ISO_IR 192`, `GB18030` or
2216
+ * `GBK`, the bytes decode with ISO 2022 code extensions (PS3.5 section
2217
+ * 6.1.2.5): every PS3.3 Table C.12-3 and C.12-4 escape sequence switches G0 or
2218
+ * G1, no escape byte reaches the string, and the Value 1 designations come back
2219
+ * after each CR, LF or FF, which are the reset points of a text VR. Bytes no
2220
+ * designated set decodes read as U+FFFD. Only the string is returned here;
2221
+ * `Element.value` carries the warnings for an element's value.
2222
+ *
2223
+ * Every other term list decodes under {@link resolveDecoderLabel}'s label: an
2224
+ * unsupported label falls back to UTF-8, then to Latin-1 (1:1, cannot fail).
1900
2225
  *
1901
2226
  * @example
1902
2227
  * ```ts
1903
2228
  * import { decodeText } from "@cosyte/dicom";
1904
2229
  * const s = decodeText(buf, ["ISO_IR 192"]); // UTF-8
2230
+ * // ESC $ B, the JIS X 0208 bytes of one character, ESC ( B
2231
+ * decodeText(Buffer.from([0x1b, 0x24, 0x42, 0x3b, 0x33, 0x1b, 0x28, 0x42]), ["", "ISO 2022 IR 87"]);
1905
2232
  * ```
1906
2233
  */
1907
2234
  declare function decodeText(bytes: Buffer, terms: readonly string[] | undefined): string;
@@ -1992,7 +2319,209 @@ declare function parseDateTime(raw: string): {
1992
2319
  };
1993
2320
 
1994
2321
  /**
1995
- * Coded-terminology surfacing (§4.6, PS3.16 §8 Table 8-1).
2322
+ * Calendar-component conversion for the decoded `DA` / `TM` / `DT` values.
2323
+ *
2324
+ * `parseDate`, `parseTime` and `parseDateTime` decode three separate wire
2325
+ * syntaxes into three separate shapes. These three functions project all of
2326
+ * them onto one: `toObject` for the calendar components, `toISO` for a string,
2327
+ * `toDate` for an absolute instant. The same three names, with the same
2328
+ * meanings, are exported by every `@cosyte/*` parser that decodes a date, so a
2329
+ * caller that learns them once can read a timestamp out of any of them.
2330
+ *
2331
+ * Three properties are the whole point of the surface and are asserted rather
2332
+ * than assumed in `test/conversion-surface.test.ts`:
2333
+ *
2334
+ * - **A component the value did not state is ABSENT.** Nothing is zero-filled,
2335
+ * so `Object.keys()` of the result is exactly the precision the sender wrote.
2336
+ * - **`millisecond` comes from the digits in `raw`, never from
2337
+ * `fractionalSeconds`.** `DicomTime.fractionalSeconds` is a binary float, and
2338
+ * the standard puts no bound on how many digits a `TM` may carry, so the
2339
+ * digits are read as written and the float is not consulted at all. `toISO`
2340
+ * renders the same digits verbatim, neither padded to three nor rounded.
2341
+ * - **`toDate` never guesses a zone.** A value with no `&ZZXX` offset and no
2342
+ * `assumeOffsetMinutes` is not an instant, so it answers `undefined` rather
2343
+ * than reading the host machine's zone.
2344
+ * - **A component outside its calendar range is REFUSED, never rolled over.**
2345
+ * The decoders bound the day at 1 to 31 without consulting the month, so
2346
+ * `parseDate("20240230")` is `valid: true`. Projecting that yields
2347
+ * `"2024-02-30"`, which every ISO-8601 reader silently moves to 1 March, and
2348
+ * an instant a day after the one the sender wrote. PatientBirthDate
2349
+ * (0010,0030) is a `DA`, so the harm is a date of birth shifted by a day with
2350
+ * nothing thrown. All three functions answer `undefined` instead.
2351
+ *
2352
+ * None of the three throws, for any input.
2353
+ *
2354
+ * The refusal lives HERE and not in `parseDate` / `parseTime` / `parseDateTime`:
2355
+ * those keep the behaviour they were pinned at, so a caller that wants the
2356
+ * sender's bytes still reads them off the decoded value and off `raw`. What is
2357
+ * refused is the PROJECTION of a value onto a calendar that has no such day.
2358
+ *
2359
+ * @module
2360
+ */
2361
+
2362
+ /**
2363
+ * The decoded temporal values this surface accepts: a `DA`, a `TM` or a `DT`.
2364
+ *
2365
+ * Deliberately not exported. The three members are exported types a caller can
2366
+ * already name, and the Contract these functions implement introduces no new
2367
+ * value type in any package that adopts it.
2368
+ */
2369
+ type DicomTemporal = DicomDate | DicomTime | DicomDateTime;
2370
+ /**
2371
+ * The calendar components a temporal value actually stated, and nothing else.
2372
+ *
2373
+ * Every value is a number, `month` is 1 to 12 (never the JS `Date` 0 to 11),
2374
+ * and a component the value did not state is ABSENT rather than present and
2375
+ * `undefined`: there is no `precision` key because the key set is the
2376
+ * precision. No `raw`, no `valid` and no parse bookkeeping reaches it.
2377
+ *
2378
+ * The shape is the one `Temporal.PlainDateTime.from` and luxon's
2379
+ * `DateTime.fromObject` accept, which is why the names are singular where
2380
+ * {@link DicomTime} and {@link DicomDateTime} spell them plurally. Delete
2381
+ * `offsetMinutes` and either constructor takes the rest with no key rename and
2382
+ * no value adjustment. That is a documented property rather than a tested one:
2383
+ * proving it would mean taking a dependency, and this package takes none for it.
2384
+ *
2385
+ * @example
2386
+ * ```ts
2387
+ * import { parseDateTime, toObject } from "@cosyte/dicom";
2388
+ * import type { DateParts } from "@cosyte/dicom";
2389
+ *
2390
+ * const parts: DateParts | undefined = toObject(parseDateTime("202401151330").value);
2391
+ * Object.keys(parts ?? {}); // ["year", "month", "day", "hour", "minute"]
2392
+ * ```
2393
+ */
2394
+ interface DateParts {
2395
+ /** Calendar year as written, so `0050` is the year 50 and never 1950. */
2396
+ readonly year?: number;
2397
+ /** Calendar month, 1 to 12. */
2398
+ readonly month?: number;
2399
+ /** Day of month, 1 to 31. */
2400
+ readonly day?: number;
2401
+ /** Hour of day, 0 to 23. */
2402
+ readonly hour?: number;
2403
+ /** Minute, 0 to 59. */
2404
+ readonly minute?: number;
2405
+ /**
2406
+ * Second, 0 to 59.
2407
+ *
2408
+ * `TM` and `DT` permit `60` for a leap second and the decoders keep it, but a
2409
+ * value stating it is not projected: there is no ISO rendering of it a reader
2410
+ * does not move, and no instant to build. See the module doc block.
2411
+ */
2412
+ readonly second?: number;
2413
+ /** The first three digits of the stated fraction, right-padded with zeroes. */
2414
+ readonly millisecond?: number;
2415
+ /** Signed minutes east of UTC, present only when the value carried an offset. */
2416
+ readonly offsetMinutes?: number;
2417
+ }
2418
+ /**
2419
+ * The one option {@link toDate} takes, and the only key it carries.
2420
+ *
2421
+ * `assumeOffsetMinutes` is the caller's declaration of the zone a value that
2422
+ * carries none was written in. An explicit `0` means "read this naive value as
2423
+ * UTC", which is a decision this library will not make on a caller's behalf.
2424
+ *
2425
+ * @example
2426
+ * ```ts
2427
+ * import { parseDate, toDate } from "@cosyte/dicom";
2428
+ * import type { ToDateOptions } from "@cosyte/dicom";
2429
+ *
2430
+ * const options: ToDateOptions = { assumeOffsetMinutes: -300 };
2431
+ * toDate(parseDate("20240115").value, options)?.toISOString(); // "2024-01-15T05:00:00.000Z"
2432
+ * ```
2433
+ */
2434
+ interface ToDateOptions {
2435
+ /** Minutes east of UTC to read a value that states no offset in. */
2436
+ readonly assumeOffsetMinutes?: number;
2437
+ }
2438
+ /**
2439
+ * The calendar components a `DA`, `TM` or `DT` value stated, as a frozen object.
2440
+ *
2441
+ * Returns `undefined` for a value the decoders marked `valid: false`, for
2442
+ * `null` / `undefined`, for a value that stated no component at all, and for
2443
+ * one whose components name no real calendar point (30 February, 29 February
2444
+ * outside a leap year, 31 April, second 60). It never throws.
2445
+ * `hours` / `minutes` / `seconds` are renamed to the singular
2446
+ * `hour` / `minute` / `second`; `raw`, `valid` and the `legacy` and
2447
+ * `nonstandardOffset` flags the decoders report beside the value never appear.
2448
+ *
2449
+ * @example
2450
+ * ```ts
2451
+ * import { parseDate, parseDateTime, parseTime, toObject } from "@cosyte/dicom";
2452
+ *
2453
+ * toObject(parseDateTime("20240115133015").value);
2454
+ * // { year: 2024, month: 1, day: 15, hour: 13, minute: 30, second: 15 }
2455
+ *
2456
+ * toObject(parseTime("133015.123456").value).millisecond; // 123, from the digits
2457
+ * toObject(parseDate("18700230").value); // undefined: February has no 30th
2458
+ * ```
2459
+ */
2460
+ declare function toObject(value: DicomTemporal | null | undefined): DateParts | undefined;
2461
+ /**
2462
+ * The value as an ISO-8601 string, TRUNCATED to the precision it stated.
2463
+ *
2464
+ * Nothing is padded out: a `DA` renders `2024-01-15`, a `DT` that stated only
2465
+ * an hour renders `2024-01-15T13`, and a `TM` renders the bare time. Fractional
2466
+ * digits are rendered exactly as written. A stated offset is appended, `Z` when
2467
+ * it is zero; a value that stated NO offset gets nothing appended, because a
2468
+ * fabricated `Z` would claim UTC the sender never wrote.
2469
+ *
2470
+ * Returns `undefined` for an invalid value, for `null` / `undefined`, for a
2471
+ * value that stated no component at all, and for one naming no real calendar
2472
+ * point: a string such as `"2024-02-30"` is worse than no answer, because every
2473
+ * ISO-8601 reader moves it silently to 1 March. It never throws. This is not a
2474
+ * byte round-trip of the wire value and is not meant to be: `serializeDicom`
2475
+ * remains the route that reproduces the original bytes.
2476
+ *
2477
+ * @example
2478
+ * ```ts
2479
+ * import { parseDate, parseDateTime, parseTime, toISO } from "@cosyte/dicom";
2480
+ *
2481
+ * toISO(parseDateTime("20240115133015-0500").value); // "2024-01-15T13:30:15-05:00"
2482
+ * toISO(parseTime("133015").value); // "13:30:15"
2483
+ * toISO(parseDate("18700431").value); // undefined: April has no 31st
2484
+ * ```
2485
+ */
2486
+ declare function toISO(value: DicomTemporal | null | undefined): string | undefined;
2487
+ /**
2488
+ * The value as an absolute instant, ONLY where the zone is determinate.
2489
+ *
2490
+ * A stated `&ZZXX` offset wins and `options.assumeOffsetMinutes` is ignored.
2491
+ * With no stated offset the caller's `assumeOffsetMinutes` is applied, an
2492
+ * explicit `0` meaning "treat this naive value as UTC". With neither, the
2493
+ * answer is `undefined`: the host machine's zone is never read and UTC is never
2494
+ * assumed. A value with no year is never an instant, so a `TM` always answers
2495
+ * `undefined` however the call is made. A non-finite `assumeOffsetMinutes`
2496
+ * (`NaN`, `Infinity`, `-Infinity`) names no zone either, so it answers
2497
+ * `undefined` rather than an `Invalid Date` whose `getTime()` is `NaN`, and so
2498
+ * does a finite offset so large that applying it leaves the range a `Date` can
2499
+ * represent. A value naming no real calendar point is refused before any of
2500
+ * that, so an impossible day is never rolled into the next month.
2501
+ *
2502
+ * Components below the stated precision fill to their lowest legal value for
2503
+ * the instant alone; the value's own precision is untouched, and a later
2504
+ * {@link toObject} or {@link toISO} on it returns what it returned before. A
2505
+ * four-digit year below 100 stays that year, so `0050` is the year 50 and the
2506
+ * two-digit remapping of `Date.UTC` never reaches the result.
2507
+ *
2508
+ * @example
2509
+ * ```ts
2510
+ * import { parseDate, parseDateTime, toDate } from "@cosyte/dicom";
2511
+ *
2512
+ * toDate(parseDate("20240115").value); // undefined: no zone was stated
2513
+ * toDate(parseDate("20240115").value, { assumeOffsetMinutes: 0 })?.toISOString();
2514
+ * // "2024-01-15T00:00:00.000Z"
2515
+ * toDate(parseDateTime("20240115133015+0100").value)?.toISOString();
2516
+ * // "2024-01-15T12:30:15.000Z"
2517
+ * toDate(parseDate("18700230").value, { assumeOffsetMinutes: 0 });
2518
+ * // undefined: never 1 March for a value the sender wrote as February
2519
+ * ```
2520
+ */
2521
+ declare function toDate(value: DicomTemporal | null | undefined, options?: ToDateOptions): Date | undefined;
2522
+
2523
+ /**
2524
+ * Coded-terminology surfacing (PS3.16 §8 Table 8-1).
1996
2525
  *
1997
2526
  * A coded concept is the triplet `Code Value (0008,0100)` /
1998
2527
  * `Coding Scheme Designator (0008,0102)` / `Code Meaning (0008,0104)`. This
@@ -2051,11 +2580,11 @@ declare function codingSchemeOid(designator: string | undefined): string | undef
2051
2580
  declare function readCode(item: Dataset): CodedConcept;
2052
2581
 
2053
2582
  /**
2054
- * Value-layer error taxonomy for the Phase 4 domain helpers.
2583
+ * Value-layer error taxonomy for the domain helpers.
2055
2584
  *
2056
2585
  * This is **separate** from the parser's four-code `FATAL_CODES` /
2057
2586
  * `DicomParseError` (locked to unrecoverable Part 10 structural corruption
2058
- * - see `../../parser/errors.ts`). The §4 helpers are otherwise fail-safe:
2587
+ * - see `../../parser/errors.ts`). The domain helpers are otherwise fail-safe:
2059
2588
  * a missing *value* is typed-absent (`undefined`), never an exception. The
2060
2589
  * helper layer throws only for a **structural contract violation** the
2061
2590
  * caller asked it to resolve and that cannot be answered safely:
@@ -2071,7 +2600,7 @@ declare function readCode(item: Dataset): CodedConcept;
2071
2600
  * @module
2072
2601
  */
2073
2602
  /**
2074
- * Stable string codes the Phase 4 helpers may throw. Narrow on
2603
+ * Stable string codes the domain helpers may throw. Narrow on
2075
2604
  * {@link DicomValueError.code} to react to a specific contract violation.
2076
2605
  *
2077
2606
  * @example
@@ -2103,7 +2632,7 @@ declare const VALUE_ERROR_CODES: {
2103
2632
  */
2104
2633
  type ValueErrorCode = (typeof VALUE_ERROR_CODES)[keyof typeof VALUE_ERROR_CODES];
2105
2634
  /**
2106
- * Thrown by the Phase 4 helpers for a structural contract violation that
2635
+ * Thrown by the domain helpers for a structural contract violation that
2107
2636
  * cannot be answered safely (see module doc). Never carries a decoded
2108
2637
  * value, so it is safe to log without leaking PHI: the `message` is built
2109
2638
  * only from the code and structural facts (indices, tag/macro names).
@@ -2134,7 +2663,7 @@ declare class DicomValueError extends Error {
2134
2663
  }
2135
2664
 
2136
2665
  /**
2137
- * `serializeDicom` - the Phase 5 Part 10 writer (the conservative half of
2666
+ * `serializeDicom` - the Part 10 writer (the conservative half of
2138
2667
  * Postel's Law).
2139
2668
  *
2140
2669
  * Takes a {@link Dataset} (from `parseDicom`, or hand-built) and emits a
@@ -2144,15 +2673,40 @@ declare class DicomValueError extends Error {
2144
2673
  * 2. File Meta group `0002`, always Explicit VR LE, with a correct
2145
2674
  * `(0002,0000)` group length - see {@link encodeFileMeta}.
2146
2675
  * 3. The dataset body, in the dataset's own transfer syntax (**no
2147
- * transcode**): Implicit VR LE, Explicit VR LE/BE, or - for the Deflated
2148
- * syntax - an Explicit VR LE body run through RFC 1951 raw deflate
2149
- * (`zlib.deflateRawSync`), symmetric to the parser's `inflateRawSync`.
2676
+ * transcode**): Implicit VR LE; Explicit VR LE/BE; for the Deflated
2677
+ * syntax, an Explicit VR LE body run through RFC 1951 raw deflate
2678
+ * (`zlib.deflateRawSync`), symmetric to the parser's `inflateRawSync`; and
2679
+ * for every Transfer Syntax PS3.5 2026c section A.4 names (the generated
2680
+ * list the parser dispatches), an Explicit VR LE body whose top-level
2681
+ * Pixel Data is an encapsulated fragment stream (see `./encapsulated.ts`).
2150
2682
  *
2151
2683
  * Conservative behaviour (PS3.5): scalar values are padded to even length on
2152
2684
  * write (§6.2); retired `(gggg,0000)` group-length elements are omitted from
2153
- * the dataset (§7.2); short/long-form headers are chosen by VR (§7.1.2);
2154
- * sequence and encapsulated-pixel-data spans pass through byte-for-byte
2155
- * (§7.5 / §A.4).
2685
+ * the dataset (§7.2); short/long-form headers are chosen by VR (§7.1.2); the
2686
+ * Data Elements of the root Data Set, and of every Sequence Item the writer can
2687
+ * walk, are emitted in ascending tag order (PS3.5 2026c §7.1 / §7.5.1, see
2688
+ * `./order.ts`), with Items kept in their order and no value byte changed;
2689
+ * encapsulated-pixel-data fragments pass through byte-for-byte (§A.4), and
2690
+ * under a section A.4 syntax a top-level Pixel Data that is not a fragment
2691
+ * stream section A.4 allows is refused, never repaired. A DICOMDIR's Directory
2692
+ * Record offsets are recomputed against the bytes written, and one that cannot
2693
+ * be tied to a record is refused (see `./directory.ts`).
2694
+ *
2695
+ * The ordering has limits, stated with it. A tag repeated inside an Item is
2696
+ * kept (both copies, in source order), so such output still breaks PS3.5 2026c
2697
+ * §7.1's "at most once". A Sequence that cannot be walked, one nested past
2698
+ * `NESTING_DEPTH_LIMIT`, one whose parsed `items` do not match its bytes, and
2699
+ * any `UN`-carried Sequence (under Implicit VR LE that includes a private
2700
+ * Sequence inside an Item, even one a `Profile` resolved to `SQ`, since a
2701
+ * default read resolves its tag to `UN`) are emitted as read, unordered. An
2702
+ * element whose own bytes do not show where a reader ends it (an
2703
+ * undefined-length `UN` the parser could not read as a Sequence, a value
2704
+ * missing its Sequence Delimitation Item) is written after the ascending rest of
2705
+ * its Data Set, since a reader would take whatever followed it into its value.
2706
+ * So is a Sequence nested past the bound, other than a defined-length one under
2707
+ * Implicit VR LE, since seeing where a reader ends it would take the walk past
2708
+ * the bound. An element the parser relocated because a length lied is ordered
2709
+ * where it was placed.
2156
2710
  *
2157
2711
  * @module
2158
2712
  */
@@ -2161,9 +2715,59 @@ declare class DicomValueError extends Error {
2161
2715
  * Serialize a {@link Dataset} to a spec-clean DICOM Part 10 `Buffer`.
2162
2716
  *
2163
2717
  * The dataset's transfer syntax is preserved (no transcoding): pixel-data
2164
- * fragments and nested sequences are written back byte-for-byte, while scalar
2165
- * values are re-emitted with correct even-length padding and File Meta group
2166
- * length. Pure function - the input `Dataset` is never mutated.
2718
+ * fragments are written back byte-for-byte, nested sequences carry the same
2719
+ * bytes with each Item's Data Elements in ascending tag order, and scalar values
2720
+ * are re-emitted with correct even-length padding and File Meta group length.
2721
+ * Pure function - the input `Dataset` is never mutated.
2722
+ *
2723
+ * **Element order.** Every Data Set is written in ascending tag order (PS3.5
2724
+ * 2026c §7.1, §7.5.1): the root, and the Data Set of every Item in every
2725
+ * Sequence the writer can walk on the wire, at every depth up to
2726
+ * `NESTING_DEPTH_LIMIT`. Items stay in their order (PS3.5 2026c §7.5) and no
2727
+ * value changes; only whole element spans move, and only where the parsed
2728
+ * `items` match the bytes, so the output reads back as the source did. Limits:
2729
+ * a tag repeated inside an Item is kept twice, in source order, so that output
2730
+ * still breaks PS3.5 2026c §7.1's "at most once"; a Sequence whose Item stream
2731
+ * cannot be walked to exactly its end, one nested past the bound, one whose
2732
+ * `items` do not match its bytes (a Sequence the parser did not descend, for
2733
+ * one), and any `UN`-carried Sequence (under Implicit VR LE that includes a
2734
+ * private Sequence inside an Item, even one a `Profile` resolved to `SQ`, since
2735
+ * a default read resolves its tag to `UN`) are written as read, unordered; an
2736
+ * element whose own bytes do not show where a reader ends it (an
2737
+ * undefined-length `UN` the parser could not read as a Sequence, or a value
2738
+ * missing its Sequence Delimitation Item) is written after the ascending rest
2739
+ * of its Data Set, because a reader takes what follows it into its value, and
2740
+ * so is a Sequence nested past the bound (other than a defined-length one under
2741
+ * Implicit VR LE), whose end the writer would have to walk past the bound to
2742
+ * see; and an element the parser relocated because a length lied is ordered
2743
+ * where it was placed, since ordering cannot recover an order the source
2744
+ * destroyed.
2745
+ *
2746
+ * **Encapsulated objects.** Under every Transfer Syntax PS3.5 2026c section A.4
2747
+ * names (JPEG, JPEG-LS, JPEG 2000, HTJ2K, RLE and the rest: the list `parseDicom`
2748
+ * reads), the File Meta Transfer Syntax UID is kept, the Data Set is written as
2749
+ * Explicit VR Little Endian, and the top-level Pixel Data `(7FE0,0010)` is
2750
+ * written as `OB` of undefined length: the input's Basic Offset Table Item and
2751
+ * fragment Items byte for byte and in order, then a zero-length Sequence
2752
+ * Delimitation Item. Nothing is decoded, transcoded or re-framed, and no offset
2753
+ * table is interpreted or rebuilt. The limit sits with it: a top-level Data Set
2754
+ * that section A.4 does not allow is refused with
2755
+ * `INVALID_ENCAPSULATED_PIXEL_DATA` rather than repaired, which includes a
2756
+ * fragment stream `parseDicom` read with `DICOM_PIXEL_DATA_FRAGMENTS_NOT_DELIMITED`,
2757
+ * an odd Item Length, an empty fragment, native or absent top-level Pixel Data,
2758
+ * and Float or Double Float Pixel Data. Pixel Data nested in a Sequence Item is
2759
+ * written as read.
2760
+ *
2761
+ * **DICOMDIR offsets.** For a `Dataset` whose File Meta Media Storage SOP Class
2762
+ * UID is `1.2.840.10008.1.3.10`, `(0004,1200)`, `(0004,1202)` and each Directory
2763
+ * Record's `(0004,1400)` and `(0004,1420)` are written as the byte offset, from
2764
+ * the first preamble byte of the output, of the Directory Record each named when
2765
+ * the file was read (PS3.3 2026d Table F.3-3), whatever moved it: the rebuilt
2766
+ * File Meta group, the ascending order above, or `deidentify()`. A record is the
2767
+ * Item of the Directory Record Sequence `(0004,1220)` whose `Item.fileOffset`
2768
+ * the offset equals; nothing is found by scanning for an Item tag. A zero offset
2769
+ * is written as zero. Limits: the retired MRDR offset `(0004,1504)` is written as
2770
+ * read, and nothing checks record keys or `(0004,1202)` against the root chain.
2167
2771
  *
2168
2772
  * **Input contract.** The writer is designed for a {@link Dataset} produced by
2169
2773
  * `parseDicom`: it relies on the parser's `Element.rawBytes` representation
@@ -2178,12 +2782,19 @@ declare class DicomValueError extends Error {
2178
2782
  * elements preserved on `extraElements`), not a byte-exact copy of the original
2179
2783
  * file: the 128-byte preamble is normalized to zeros, the File Meta group is
2180
2784
  * rebuilt in ascending tag order (modeled fields + `extraElements` - see
2181
- * {@link encodeFileMeta}), odd-length values are padded even, and retired
2182
- * `(gggg,0000)` group lengths are dropped.
2785
+ * {@link encodeFileMeta}), the Data Sets are ordered as above, odd-length values
2786
+ * are padded even, and retired `(gggg,0000)` group lengths are dropped.
2183
2787
  *
2184
2788
  * @throws {@link DicomSerializeError} with code `MISSING_TRANSFER_SYNTAX` when
2185
- * the dataset has no File Meta Transfer Syntax UID, or
2186
- * `UNSUPPORTED_TRANSFER_SYNTAX` when that UID is outside the v1 set.
2789
+ * the dataset has no File Meta Transfer Syntax UID,
2790
+ * `UNSUPPORTED_TRANSFER_SYNTAX` when that UID is neither one of the four
2791
+ * native syntaxes nor a section A.4 one, `INVALID_ENCAPSULATED_PIXEL_DATA`
2792
+ * when it is a section A.4 one and the top-level Pixel Data is not a fragment
2793
+ * stream section A.4 allows, `DIRECTORY_OFFSET_UNRESOLVED` when a DICOMDIR
2794
+ * carries an offset that is not zero and names no Directory Record the
2795
+ * `Dataset` holds, or is not one 32-bit unsigned integer (never written stale
2796
+ * or as zero), or `DIRECTORY_OFFSET_DEFLATED` when a DICOMDIR under Deflated
2797
+ * Explicit VR LE carries a non-zero offset. Nothing is returned on a throw.
2187
2798
  *
2188
2799
  * @example
2189
2800
  * ```ts
@@ -2196,9 +2807,9 @@ declare class DicomValueError extends Error {
2196
2807
  declare function serializeDicom(ds: Dataset): Buffer;
2197
2808
 
2198
2809
  /**
2199
- * Serializer error taxonomy for the Phase 5 Part 10 writer.
2810
+ * Serializer error taxonomy for the Part 10 writer.
2200
2811
  *
2201
- * Like the Phase 4 {@link "../dataset/helpers/errors".DicomValueError}, this
2812
+ * Like {@link "../dataset/helpers/errors".DicomValueError}, this
2202
2813
  * is **separate** from the parser's four-code `FATAL_CODES` /
2203
2814
  * `DicomParseError` (which are locked to *read*-side Part 10 structural
2204
2815
  * corruption - see `../parser/errors.ts`). The serializer throws only when
@@ -2208,17 +2819,45 @@ declare function serializeDicom(ds: Dataset): Buffer;
2208
2819
  * `fileMeta.transferSyntaxUID` is empty. The Transfer Syntax UID is the
2209
2820
  * dispatch input that decides every byte of the encoding, so there is no
2210
2821
  * safe default to fall back to.
2211
- * - `UNSUPPORTED_TRANSFER_SYNTAX` - the Transfer Syntax UID is not one of
2212
- * the four v1 syntaxes the writer (and parser) support. The writer never
2213
- * transcodes, so it cannot emit a syntax it does not understand.
2214
- *
2215
- * The message is built only from the Transfer Syntax UID and structural
2216
- * facts (never a decoded attribute value), so it is always safe to log.
2822
+ * - `UNSUPPORTED_TRANSFER_SYNTAX` - the Transfer Syntax UID is neither one of
2823
+ * the four native syntaxes nor one PS3.5 2026c section A.4 names for
2824
+ * encapsulated Pixel Data (the JPIP Referenced and SMPTE ST 2110 syntaxes
2825
+ * and every retired UID stay here). The writer never transcodes, so it
2826
+ * cannot emit a syntax it does not understand.
2827
+ * - `INVALID_ENCAPSULATED_PIXEL_DATA` - the Transfer Syntax UID is a section
2828
+ * A.4 one, but the top-level Data Set is not one that syntax may carry: no
2829
+ * top-level Pixel Data `(7FE0,0010)`, Float or Double Float Pixel Data
2830
+ * `(7FE0,0008)` / `(7FE0,0009)` present, Pixel Data with a defined Value
2831
+ * Length (Native Format), or a fragment stream that is not a Basic Offset
2832
+ * Table Item and one or more fragment Items of even, defined, non-zero
2833
+ * Item Length, ended by a Sequence Delimitation Item. The writer refuses
2834
+ * such a stream rather than repairing it: appending a delimiter to a stream
2835
+ * the reader flagged as possibly short would write a well-formed file that
2836
+ * may be missing frames.
2837
+ * - `DIRECTORY_OFFSET_UNRESOLVED` - the `Dataset` is a DICOMDIR (File Meta
2838
+ * Media Storage SOP Class UID `1.2.840.10008.1.3.10`) and one of its offset
2839
+ * attributes, `(0004,1200)`, `(0004,1202)`, or a Directory Record's
2840
+ * `(0004,1400)` or `(0004,1420)`, cannot be tied to a Directory Record the
2841
+ * `Dataset` holds: a non-zero value that is not the `Item.fileOffset` of an
2842
+ * Item of its Directory Record Sequence `(0004,1220)`, or a value that is
2843
+ * not one 32-bit unsigned integer. The writer recomputes every offset it
2844
+ * can tie; one it cannot is refused, never written stale (it would name the
2845
+ * wrong bytes) and never written as zero (zero means "no record" and would
2846
+ * silently prune the tree).
2847
+ * - `DIRECTORY_OFFSET_DEFLATED` - the DICOMDIR is to be written in Deflated
2848
+ * Explicit VR Little Endian and carries a non-zero offset (or one that is
2849
+ * not one 32-bit unsigned integer). A position inside a deflated stream
2850
+ * names no Item a reader can seek to, so no offset can be written for it.
2851
+ * A Deflated DICOMDIR whose offsets are all zero is written.
2852
+ *
2853
+ * The message is built only from structural constants (never a decoded
2854
+ * attribute value, a Transfer Syntax UID, or a length or byte read from the
2855
+ * input), so it is always safe to log.
2217
2856
  *
2218
2857
  * @module
2219
2858
  */
2220
2859
  /**
2221
- * Stable string codes the Phase 5 serializer may throw. Narrow on
2860
+ * Stable string codes the serializer may throw. Narrow on
2222
2861
  * {@link DicomSerializeError.code} to react to a specific failure.
2223
2862
  *
2224
2863
  * @example
@@ -2230,6 +2869,9 @@ declare function serializeDicom(ds: Dataset): Buffer;
2230
2869
  declare const SERIALIZE_ERROR_CODES: {
2231
2870
  readonly MISSING_TRANSFER_SYNTAX: "MISSING_TRANSFER_SYNTAX";
2232
2871
  readonly UNSUPPORTED_TRANSFER_SYNTAX: "UNSUPPORTED_TRANSFER_SYNTAX";
2872
+ readonly INVALID_ENCAPSULATED_PIXEL_DATA: "INVALID_ENCAPSULATED_PIXEL_DATA";
2873
+ readonly DIRECTORY_OFFSET_UNRESOLVED: "DIRECTORY_OFFSET_UNRESOLVED";
2874
+ readonly DIRECTORY_OFFSET_DEFLATED: "DIRECTORY_OFFSET_DEFLATED";
2233
2875
  };
2234
2876
  /**
2235
2877
  * Discriminant for {@link DicomSerializeError.code}, enabling exhaustive
@@ -2243,7 +2885,13 @@ declare const SERIALIZE_ERROR_CODES: {
2243
2885
  * case "MISSING_TRANSFER_SYNTAX":
2244
2886
  * return "dataset has no Transfer Syntax UID to serialize under";
2245
2887
  * case "UNSUPPORTED_TRANSFER_SYNTAX":
2246
- * return "Transfer Syntax UID is outside the v1 set";
2888
+ * return "Transfer Syntax UID is neither native nor a section A.4 one";
2889
+ * case "INVALID_ENCAPSULATED_PIXEL_DATA":
2890
+ * return "top-level Pixel Data is not a section A.4 fragment stream";
2891
+ * case "DIRECTORY_OFFSET_UNRESOLVED":
2892
+ * return "a DICOMDIR offset names no Directory Record the dataset holds";
2893
+ * case "DIRECTORY_OFFSET_DEFLATED":
2894
+ * return "a Deflated DICOMDIR carries a non-zero Directory Record offset";
2247
2895
  * }
2248
2896
  * }
2249
2897
  * ```
@@ -2253,7 +2901,8 @@ type SerializeErrorCode = (typeof SERIALIZE_ERROR_CODES)[keyof typeof SERIALIZE_
2253
2901
  * Thrown by `serializeDicom` when a `Dataset`
2254
2902
  * cannot be emitted as spec-clean Part 10. Never carries a decoded value, so
2255
2903
  * it is safe to log without leaking PHI: the `message` is built only from the
2256
- * code and the offending Transfer Syntax UID.
2904
+ * code and structural constants, never from the Transfer Syntax UID, a length
2905
+ * or a byte the `Dataset` holds.
2257
2906
  *
2258
2907
  * @example
2259
2908
  * ```ts
@@ -2265,6 +2914,12 @@ type SerializeErrorCode = (typeof SERIALIZE_ERROR_CODES)[keyof typeof SERIALIZE_
2265
2914
  * if (err instanceof DicomSerializeError && err.code === "MISSING_TRANSFER_SYNTAX") {
2266
2915
  * // dataset is missing the File Meta Transfer Syntax UID
2267
2916
  * }
2917
+ * if (err instanceof DicomSerializeError && err.code === "INVALID_ENCAPSULATED_PIXEL_DATA") {
2918
+ * // a section A.4 object whose top-level Pixel Data is not a fragment stream
2919
+ * }
2920
+ * if (err instanceof DicomSerializeError && err.code === "DIRECTORY_OFFSET_UNRESOLVED") {
2921
+ * // a DICOMDIR offset the writer cannot tie to a Directory Record
2922
+ * }
2268
2923
  * }
2269
2924
  * ```
2270
2925
  */
@@ -2282,7 +2937,7 @@ declare class DicomSerializeError extends Error {
2282
2937
 
2283
2938
  /**
2284
2939
  * `defineProfile()` - the public factory for building immutable {@link Profile}
2285
- * objects (Phase 6). Mirrors the `@cosyte/hl7` sibling's profile factory: a
2940
+ * objects. Mirrors the `@cosyte/hl7` sibling's profile factory: a
2286
2941
  * validated options object in, a frozen `Profile` with a `describe()` method
2287
2942
  * out. Invalid input throws {@link ProfileDefinitionError} with an actionable
2288
2943
  * message rather than producing a half-built profile.
@@ -2425,22 +3080,24 @@ declare const profiles: Readonly<{
2425
3080
 
2426
3081
  /**
2427
3082
  * PS3.15 Annex E attribute-action table - DICOM Basic Application Confidentiality
2428
- * Profile + 11 retention/clean option sets.
2429
- *
2430
- * Phase 1 deliverable (D-08 / D-09 / D-14). Consumed by Phase 7 `anonymize()`
2431
- * (ANON-01..ANON-10).
2432
- *
2433
- * NOTE: This module is NOT re-exported from the package's `src/index.ts` (per D-10 +
2434
- * D-27 - Phase 1's external surface is `Dictionary.{lookup,byKeyword,uid}` + `VERSION`
2435
- * only). Phase 7 imports `annexE` via the `@cosyte/dicom/dictionary/annex-e` internal
2436
- * path, which the `package.json` `exports` map will admit when Phase 7 lands its plans.
2437
- *
2438
- * The 9 *metadata-affecting* PS3.15 Annex E option-set columns (E.3.3–E.3.11 plus the
2439
- * collapsed E.3.6 "RetainLongitudinalTemporal") populate `AnnexEAction.optionSet`
2440
- * keys per attribute. The two *pixel-level* options - E.3.1 `CleanPixelData` and
2441
- * E.3.2 `CleanRecognizableVisual` - are not represented per-attribute (PS3.15 Table
2442
- * E.1-1 has no column for them); Phase 7 enforces them at the pixel-decode layer.
2443
- * Both names remain in `AnnexEOption` for completeness and for Phase 7's API.
3083
+ * Profile plus its retention/clean option sets.
3084
+ *
3085
+ * Consumed by the de-identifier.
3086
+ *
3087
+ * NOTE: This module is NOT re-exported from the package's `src/index.ts`. The
3088
+ * `Dictionary` namespace's external surface is `Dictionary.{lookup,byKeyword,uid}`
3089
+ * plus `VERSION`; `annexE` is reached internally through
3090
+ * `@cosyte/dicom/dictionary/annex-e`.
3091
+ *
3092
+ * The 10 *metadata-affecting* option columns of Table E.1-1 populate
3093
+ * `AnnexEAction.optionSet` keys per attribute. Ten rather than nine because
3094
+ * E.3.6 is two mutually exclusive Options with a column each, and both are
3095
+ * carried: `RetainLongitudinalTemporal` for full dates and
3096
+ * `RetainLongitudinalTemporalModifiedDates` for modified dates. The two
3097
+ * *pixel-level* options - E.3.1 `CleanPixelData` and E.3.2
3098
+ * `CleanRecognizableVisual` - are not represented per-attribute (PS3.15 Table
3099
+ * E.1-1 has no column for them); they are enforced at the pixel-decode layer.
3100
+ * Both names remain in `AnnexEOption` for completeness and for that API.
2444
3101
  */
2445
3102
 
2446
3103
  /**
@@ -2454,7 +3111,7 @@ declare const profiles: Readonly<{
2454
3111
  * - `U` = replace UID with a consistent new UID per session
2455
3112
  *
2456
3113
  * Compound codes (`Z/D`, `X/Z/D`, `X/Z/U*`, `C/X`) are preserved verbatim from the
2457
- * Annex E source; Phase 7 interprets them per the table's per-attribute semantics
3114
+ * Annex E source; the de-identifier interprets them per the table's per-attribute semantics
2458
3115
  * (e.g. `Z/D` = `Z` if absent, `D` if present).
2459
3116
  *
2460
3117
  * @example
@@ -2462,52 +3119,68 @@ declare const profiles: Readonly<{
2462
3119
  */
2463
3120
  type AnnexEActionCode = "D" | "Z" | "X" | "K" | "C" | "U" | "Z/D" | "X/Z" | "X/D" | "X/Z/D" | "X/Z/U*" | "C/X";
2464
3121
  /**
2465
- * One of the 11 PS3.15 Annex E option sets (E.3.1–E.3.11).
3122
+ * One name per PS3.15 Annex E option column (E.3.1 to E.3.11), plus the second
3123
+ * of the two Options E.3.6 defines.
2466
3124
  *
2467
- * Names match REQUIREMENTS.md ANON-02 verbatim. `CleanPixelData` (E.3.1) and
2468
- * `CleanRecognizableVisual` (E.3.2) act on pixel data, not metadata, and never
2469
- * appear as `optionSet` keys in the generated `ANNEX_E` map; they remain in the
2470
- * union for Phase 7's pixel-decode API.
3125
+ * `CleanPixelData` (E.3.1) and `CleanRecognizableVisual` (E.3.2) act on pixel
3126
+ * data, not metadata, and never appear as `optionSet` keys in the generated
3127
+ * `ANNEX_E` map; they remain in the union for the pixel-decode API.
3128
+ *
3129
+ * **E.3.6 is two mutually exclusive Options and Table E.1-1 gives them separate
3130
+ * columns**, so it takes two names here: `RetainLongitudinalTemporal` carries
3131
+ * `Rtn. Long. Full Dates Opt.` (keep the real dates) and
3132
+ * `RetainLongitudinalTemporalModifiedDates` carries `Rtn. Long. Modif. Dates
3133
+ * Opt.` (keep them only as modified values), which is the more protective of
3134
+ * the two on every row where they differ.
2471
3135
  *
2472
3136
  * @example
2473
- * const opt: AnnexEOption = "RetainLongitudinalTemporal";
3137
+ * const opt: AnnexEOption = "RetainLongitudinalTemporalModifiedDates";
2474
3138
  */
2475
- type AnnexEOption = "CleanPixelData" | "CleanRecognizableVisual" | "CleanGraphics" | "CleanStructuredContent" | "CleanDescriptors" | "RetainLongitudinalTemporal" | "RetainPatientCharacteristics" | "RetainDeviceIdentity" | "RetainUIDs" | "RetainSafePrivate" | "RetainInstitutionIdentity";
3139
+ type AnnexEOption = "CleanPixelData" | "CleanRecognizableVisual" | "CleanGraphics" | "CleanStructuredContent" | "CleanDescriptors" | "RetainLongitudinalTemporal" | "RetainLongitudinalTemporalModifiedDates" | "RetainPatientCharacteristics" | "RetainDeviceIdentity" | "RetainUIDs" | "RetainSafePrivate" | "RetainInstitutionIdentity";
2476
3140
 
2477
3141
  /**
2478
- * Public types + error taxonomy for Phase 7 metadata de-identification
3142
+ * Public types + error taxonomy for metadata de-identification
2479
3143
  * (PS3.15 Annex E).
2480
3144
  *
2481
3145
  * @module
2482
3146
  */
2483
3147
 
2484
3148
  /**
2485
- * The PS3.15 Annex E option sets `deidentify` honours - the nine
3149
+ * The PS3.15 Annex E option sets `deidentify` honours - the
2486
3150
  * *metadata-affecting* columns of Table E.1-1. The two pixel-level options
2487
3151
  * (`CleanPixelData` §E.3.1, `CleanRecognizableVisual` §E.3.2) are deliberately
2488
3152
  * excluded: this is a metadata-only de-identifier and cannot inspect pixels
2489
3153
  * (deferred to `@cosyte/dicom-pixel`). When pixel data is present it always
2490
3154
  * warns rather than claiming the image is clean.
2491
3155
  *
2492
- * **`RetainLongitudinalTemporal` gives you the full-dates branch.** PS3.15
2493
- * §E.3.6 is *two* options, and Table E.1-1 gives them separate columns:
2494
- * `Rtn. Long. Full Dates` (keep dates and times as they are) and
2495
- * `Rtn. Long. Modif. Dates` (keep them only as modified/shifted values). One
2496
- * name here covers both, and it carries the **full-dates** column - the *less*
2497
- * protective branch. That is not a rounding difference: the two columns disagree
2498
- * on **169** rows, and on every one of them full-dates says `K` (keep the real
2499
- * value) where modified-dates says `C` (clean it). Activate it only when real
2500
- * dates are genuinely required; leave it off and the Basic Profile action
2501
- * applies, which removes or empties them. Date *shifting* is not implemented at
2502
- * this layer - a caller who needs the modified-dates behaviour shifts the values
2503
- * themselves after the call.
3156
+ * **§E.3.6 is two Options, and each has its own name here.** Table E.1-1 gives
3157
+ * them separate columns: `Rtn. Long. Full Dates` (keep dates and times as they
3158
+ * are) and `Rtn. Long. Modif. Dates` (keep them only as modified values).
3159
+ *
3160
+ * - `RetainLongitudinalTemporal` carries the **full-dates** column, the *less*
3161
+ * protective branch: where the two columns differ, it says `K` (keep the real
3162
+ * value). Activate it only when real dates are genuinely required.
3163
+ * - `RetainLongitudinalTemporalModifiedDates` carries the **modified-dates**
3164
+ * column, which says `C` (clean it) on every row where the two differ, and
3165
+ * makes the run write `(0028,0303) = MODIFIED`.
3166
+ *
3167
+ * The two are **mutually exclusive**: §E.3.6 defines them as alternatives, and a
3168
+ * call naming both is rejected with {@link DeidentifyError}. Leave both off and
3169
+ * the Basic Profile action applies, which removes or empties dates.
3170
+ *
3171
+ * 🩺 **Date *modification* is the caller's, on both branches.** This library
3172
+ * applies the column and writes the declaration; it shifts, aggregates and
3173
+ * transforms nothing, so a `MODIFIED` it writes is true only if you performed
3174
+ * the transformation §E.3.6 describes. `report.warnings` carries
3175
+ * `DICOM_DEIDENT_DATES_NOT_TRANSFORMED` on every run that activates the
3176
+ * modified-dates Option, saying exactly that.
2504
3177
  *
2505
3178
  * @example
2506
3179
  * const retain: DeidentifyOption[] = ["RetainLongitudinalTemporal", "RetainSafePrivate"];
2507
3180
  */
2508
3181
  type DeidentifyOption = Exclude<AnnexEOption, "CleanPixelData" | "CleanRecognizableVisual">;
2509
3182
  /**
2510
- * The nine metadata option-set names, frozen for runtime validation.
3183
+ * The metadata option-set names, frozen for runtime validation.
2511
3184
  *
2512
3185
  * @example
2513
3186
  * ```ts
@@ -2594,8 +3267,16 @@ interface DeidentifiedAttribute {
2594
3267
  * `(0012,0062) Patient Identity Removed = YES`. That re-emission belongs to
2595
3268
  * the disclosed under-declare carrier class, not to this field, and neither is
2596
3269
  * a bound on the other: **redacting `contextPath` from a log does not make the
2597
- * object safe to share.** Pinned in
2598
- * `test/integration/phi-diagnostic-surface.test.ts`.
3270
+ * object safe to share.**
3271
+ *
3272
+ * **That fixture no longer reproduces either half.** `(5348,4E4F)` has no row
3273
+ * in this build's PS3.6 registry and none in Table E.1-1, so `deidentify()` now
3274
+ * removes the fabricated Sequence whole before it reads the VR, and neither
3275
+ * this field nor the object carries `"HSON"` (see
3276
+ * {@link UnregisteredElementRemoval}); that is what
3277
+ * `test/integration/phi-diagnostic-surface.test.ts` pins now. It is a fact
3278
+ * about that shape and **not a bound on this field**: a Sequence the run does
3279
+ * descend still contributes whatever tag the wire gave it.
2599
3280
  *
2600
3281
  * It is published anyway, on the same footing as
2601
3282
  * {@link DeidentifyReport.removedPrivateTags}: **where** an attribute sat is
@@ -2633,7 +3314,7 @@ interface DeidentifiedAttribute {
2633
3314
  * beside a genuine `(0010,0020)` reported `hidden: ["4D535449", "00100020"]`,
2634
3315
  * and `4D535449` is `"SMIT"` in wire order.
2635
3316
  *
2636
- * **An entry is now one of the 652 literal rows of PS3.15 Table E.1-1 that this
3317
+ * **An entry is now one of the 653 literal rows of PS3.15 Table E.1-1 that this
2637
3318
  * run's options left actionable.** That is a **membership** bound rather than a
2638
3319
  * shape one - the posture this package already takes for a VR and for a Private
2639
3320
  * Creator - so what survives names a published table entry rather than a
@@ -2686,11 +3367,12 @@ interface EmbeddedAttributeFinding {
2686
3367
  readonly contextPath?: readonly string[];
2687
3368
  }
2688
3369
  /**
2689
- * One Sequence-of-Items carrier that was emptied because this run had no item
2690
- * stream to walk, so the de-identifier had no Data Sets to reach.
3370
+ * One carrier this run could not reach the Data Sets inside, and **emptied** for
3371
+ * it. {@link UnauditableSequenceFinding.applied} names that outcome and is the
3372
+ * field to read first; it is the only outcome this finding has.
2691
3373
  *
2692
- * **Two producers, and the second is not a parsed `SQ`.** The ordinary one is an
2693
- * `SQ` element whose `items` the parser never materialized. The other is a
3374
+ * **Two producers, and neither ships bytes.** The ordinary one is an
3375
+ * `SQ` element whose `items` the parser never materialized. The second is a
2694
3376
  * private data element retained under `RetainSafePrivate` whose `Profile` entry
2695
3377
  * declares it `SQ` while the parse tree says otherwise - `UN` under Implicit VR
2696
3378
  * LE when the profile was passed to `deidentify()` but not to `parseDicom`, or
@@ -2701,6 +3383,19 @@ interface EmbeddedAttributeFinding {
2701
3383
  * parsed VR in the output and is emptied rather than re-typed to `SQ`
2702
3384
  * (`DICOM-PRIVATE-SQ-PARSE-VR`).
2703
3385
  *
3386
+ * **🛑 THERE WAS A THIRD PRODUCER AND IT IS RETIRED: `applied: "kept"` IS GONE
3387
+ * FROM THIS TYPE AND FROM EVERY RUN.** Through `0.0.19` a private data element
3388
+ * the profile vouched for whose value this run never enumerated was **retained
3389
+ * verbatim** and named here with `applied: "kept"` - the package reporting that
3390
+ * it had shipped a value it did not read. Such an instance is now **removed**
3391
+ * instead, and its record moved to
3392
+ * {@link DeidentifyReport.unenumerablePrivateRemovals}, which is a different
3393
+ * surface with a different guarantee: uncapped, complete, and stating a reason.
3394
+ * This array never carries a retained private value again, so an entry here
3395
+ * always means content is **not** in your output. That is an audit-contract
3396
+ * change for anyone who switched on `applied`; see
3397
+ * {@link UnenumerablePrivateRemoval}.
3398
+ *
2704
3399
  * PS3.5 2026c §7.5.1 "Item Encoding Rules" states that "Each Item Value shall
2705
3400
  * contain a DICOM Data Set composed of Data Elements", so an `SQ` element's
2706
3401
  * value is never opaque bytes - it is Data Elements this run is obliged to
@@ -2749,9 +3444,26 @@ interface EmbeddedAttributeFinding {
2749
3444
  * ```
2750
3445
  */
2751
3446
  interface UnauditableSequenceFinding {
2752
- /** The `SQ` element that was emptied. */
3447
+ /** The carrier element. */
2753
3448
  readonly tag: Tag;
2754
- /** Byte length of the value field that was dropped. Structural, never a value. */
3449
+ /**
3450
+ * What happened to that carrier's value: `"emptied"`, always. The value is not
3451
+ * in the de-identified output.
3452
+ *
3453
+ * **🛑 `"kept"` WAS A MEMBER OF THIS UNION AND IS NOT ANY MORE.** It meant "the
3454
+ * value IS in the output, byte for byte, unexamined", and a report field that
3455
+ * can say so is exactly what this package no longer does: that instance is
3456
+ * removed now and recorded on
3457
+ * {@link DeidentifyReport.unenumerablePrivateRemovals}. The field itself is
3458
+ * kept, so `applied === "emptied"` keeps compiling and keeps meaning what it
3459
+ * always meant; a comparison against `"kept"` does not compile any more, which
3460
+ * is the intended way to find out.
3461
+ */
3462
+ readonly applied: "emptied";
3463
+ /**
3464
+ * Byte length of the carrier's value field that was **dropped**. Structural,
3465
+ * never a decoded value.
3466
+ */
2755
3467
  readonly byteLength: number;
2756
3468
  /**
2757
3469
  * Tag/index chain when the carrier is inside a sequence item; omitted at the
@@ -2763,6 +3475,128 @@ interface UnauditableSequenceFinding {
2763
3475
  */
2764
3476
  readonly contextPath?: readonly string[];
2765
3477
  }
3478
+ /**
3479
+ * One private attribute **removed** because this run did not enumerate its
3480
+ * value - the record that discharges the audit half of the fail-safe, and the
3481
+ * one report surface that is complete at any input size.
3482
+ *
3483
+ * ## What "enumerated" means here, and what it deliberately does not
3484
+ *
3485
+ * A run has enumerated a private value when it **walked it as DICOM Data
3486
+ * Elements** and put each of them through the Annex E action table (a private
3487
+ * `SQ` the parser materialized items for), when the whole value **was matched
3488
+ * as a member of a closed table the caller supplied** (a Private Creator
3489
+ * `(gggg,00EE)` whose decoded value is in the profile's private dictionary), or
3490
+ * when the value is **zero-length** and so encodes no Data Set. Nothing else.
3491
+ *
3492
+ * **🛑 DECODING A VALUE UNDER THE VR THE PROFILE RESOLVED FOR IT IS NOT
3493
+ * ENUMERATION, AND NEITHER IS THE EMBEDDED-ATTRIBUTE SCANNER'S SILENCE.** A
3494
+ * decoded `LO`, `ST`, `OB`, `OW` or `UN` value is a byte run that can carry a
3495
+ * Data Set written in a transfer syntax this run never tested for; two cells of
3496
+ * the matrix in `test/integration/deident-private-reservation.test.ts` are
3497
+ * perfectly scannable Implicit VR LE string carriers whose values the scanner
3498
+ * DID read and found nothing in, and which carried a nested `(0010,0010)`
3499
+ * anyway, because the nested tiles are Explicit VR and the file is Implicit. So
3500
+ * the predicate is **what the run did with the value**, never the VR and never
3501
+ * the scanner's reach.
3502
+ *
3503
+ * ## The cost, stated at its real size
3504
+ *
3505
+ * With `RetainSafePrivate` plus a `Profile`, three classes of private value
3506
+ * reach the output on something this run enumerated: an instance the run walked
3507
+ * as Data Elements, a Private Creator the profile's dictionary vouches for, and
3508
+ * a zero-length value. **An ordinary vendor scalar under an ordinary string VR
3509
+ * that the file's own declaration does not name safe is removed**, because
3510
+ * this package enumerates nothing inside a retained private value that is not
3511
+ * one of those three. That is over-redaction traded for a closed identity leak:
3512
+ * PS3.15 2026c §E.3.10 retains Private Attributes "known by the de-identifier to
3513
+ * be safe from identity leakage" and sends "all other Private Attributes" to
3514
+ * removal or to the `(0008,0307)` element-specific action, which this library
3515
+ * does not implement - and a value nothing enumerated is not known to be safe
3516
+ * however ordinary it looks. Through `0.0.19` such a value was **kept** and
3517
+ * disclosed instead; the disclosure said outright that it was not a fix.
3518
+ *
3519
+ * ## 🩺 The fourth class, which this record never names
3520
+ *
3521
+ * A private value reaches the output on one more route, and it is the one that
3522
+ * rests on no enumeration at all: the **file's own** Private Data Element
3523
+ * Characteristics Sequence (0008,0300), which §E.3.10 names first among the ways
3524
+ * an Attribute is known safe and which needs no `Profile`. A value in a block
3525
+ * whose Block Identifying Information Status (0008,0303) reads `SAFE`, or listed
3526
+ * in a `MIXED` block's Nonidentifying Private Elements (0008,0304), is retained
3527
+ * unexamined on the **sender's** written assertion, so it is never removed and
3528
+ * never appears here. That is the opposite trade from the one above, made in the
3529
+ * open: the caller who does not trust the sender leaves `RetainSafePrivate` off,
3530
+ * which is the one mitigation §E.3.10 offers.
3531
+ *
3532
+ * ## Per INSTANCE, never per tag
3533
+ *
3534
+ * An entry names one **occurrence**: a tag together with the Data Set it lived
3535
+ * in, which `contextPath` identifies (absent means the root Data Set). Private
3536
+ * blocks are reserved per Data Set (PS3.5 §7.8.1), so the same private tag can
3537
+ * occur several times in one object with different enumerability, and removing
3538
+ * one occurrence never removes a sibling one.
3539
+ *
3540
+ * ## Uncapped, and that is a decision rather than an oversight
3541
+ *
3542
+ * Every consumer-controlled **diagnostic** in this package is capped, because a
3543
+ * finding emitted per element is amplified by an element count the input
3544
+ * chooses. This is not a diagnostic: it is the record of an **action**, on the
3545
+ * same footing as {@link DeidentifyReport.removedPrivateTags}, which is uncapped
3546
+ * for the same reason. A caller's whole guarantee is that they can separate the
3547
+ * unenumerable removals from the Annex E ones for **every** removal, so a cap
3548
+ * here would silently take the guarantee away exactly on the files that need it
3549
+ * most. The matching warnings stay bounded; this array does not.
3550
+ *
3551
+ * ## What it carries, and what that is worth logging
3552
+ *
3553
+ * `tag` is composed from four bytes of the source. On a well-formed file it is
3554
+ * the sender's own private tag number and carries nothing, but on a file whose
3555
+ * Value Lengths disagree with its bytes those four bytes can be document
3556
+ * content - the standing exception {@link DeidentifyReport.removedPrivateTags}
3557
+ * and {@link UnauditableSequenceFinding} already carry, by the same route, and
3558
+ * `contextPath` is unbound in the same way. It is published anyway, because
3559
+ * *which* attribute was removed and *where* is the whole audit value of the
3560
+ * record. Treat this array as PHI when the source is untrusted.
3561
+ *
3562
+ * @example
3563
+ * ```ts
3564
+ * import { deidentify, parseDicom, type UnenumerablePrivateRemoval } from "@cosyte/dicom";
3565
+ * const { report } = deidentify(parseDicom(buf), { retain: ["RetainSafePrivate"], profile });
3566
+ * report.unenumerablePrivateRemovals.forEach((r: UnenumerablePrivateRemoval) => {
3567
+ * console.warn(`${r.tag} ${r.applied} (${r.reason})`, r.contextPath ?? "root");
3568
+ * });
3569
+ * ```
3570
+ */
3571
+ interface UnenumerablePrivateRemoval {
3572
+ /** The removed instance's tag. */
3573
+ readonly tag: Tag;
3574
+ /**
3575
+ * The outcome, always `"removed"`: the Data Set that held this instance
3576
+ * carries no element bearing that tag, in the returned dataset and in the
3577
+ * serialized bytes. Distinct from **emptied**, where an element with that tag
3578
+ * is present carrying a zero-length value.
3579
+ */
3580
+ readonly applied: "removed";
3581
+ /**
3582
+ * Why, always `"unenumerable"`: this run did not enumerate the value, so
3583
+ * PS3.15 §E.3.10's "known ... to be safe" was never established for it. This
3584
+ * is what separates an entry here from an attribute the Annex E action table
3585
+ * removed (`report.attributes`, `applied: "removed"`) and from one whose value
3586
+ * was emptied.
3587
+ */
3588
+ readonly reason: "unenumerable";
3589
+ /**
3590
+ * Tag/index chain naming the Data Set this instance lived in; **omitted at the
3591
+ * root**, which is what identifies the removal as per-instance rather than
3592
+ * per-tag. **Built by the same descent as
3593
+ * {@link DeidentifiedAttribute.contextPath} and carrying the same caveat:
3594
+ * each segment's tag is read off the wire, bound by nothing, so on a
3595
+ * desynchronized read it can be four bytes of a value.** Read that field's
3596
+ * note before logging this one.
3597
+ */
3598
+ readonly contextPath?: readonly string[];
3599
+ }
2766
3600
  /**
2767
3601
  * One element that was emptied because its **on-wire VR is not one of the 34
2768
3602
  * PS3.5 section 6.2 defines**, so nothing this library did to its bytes counts
@@ -2877,6 +3711,214 @@ interface UndefinedVrFinding {
2877
3711
  */
2878
3712
  readonly contextPath?: readonly string[];
2879
3713
  }
3714
+ /**
3715
+ * One non-modeled `(0002,xxxx)` File Meta element `deidentify()` **dropped**
3716
+ * rather than re-emitting into de-identified output.
3717
+ *
3718
+ * ## Why the drop is unconditional
3719
+ *
3720
+ * PS3.15 2026c §E.1.1 requires the File Meta Information of a de-identified
3721
+ * DICOM File to "be replaced with a description of the de-identifying
3722
+ * application", because otherwise "identity information may leak through
3723
+ * unmodified File Meta Information ... [t]his includes information regarding
3724
+ * Application Entity Titles, Presentation Addresses, implementation
3725
+ * information, and private information". A non-modeled `(0002,xxxx)` element is
3726
+ * by definition one this library cannot describe: `(0002,0017)` Sending AE
3727
+ * Title, `(0002,0018)` Receiving AE Title, `(0002,0100)` Private Information
3728
+ * Creator UID and `(0002,0102)` Private Information are all in that class, and
3729
+ * so is anything a vendor invented. The fail-safe direction is to drop what
3730
+ * cannot be described, so no Annex E Option and no parse quality changes the
3731
+ * outcome.
3732
+ *
3733
+ * ## Capped record, complete count
3734
+ *
3735
+ * The array is bounded per run (`MAX_FILE_META_DROP_FINDINGS`) because the
3736
+ * number of elements a crafted File Meta group can carry is chosen by the input.
3737
+ * The **action** is not bounded: every such element is dropped whether or not it
3738
+ * is listed, and {@link DeidentifyReport.fileMetaElementsDroppedCount} is the
3739
+ * complete total at any input size. An array exactly the cap's length means "at
3740
+ * least this many"; read the count, not the length.
3741
+ *
3742
+ * ## What it carries
3743
+ *
3744
+ * `tag` and `vr` come off the wire. The File Meta pre-pass stops at the first
3745
+ * element whose group is not `0002`, so a tag reaching this record always has
3746
+ * `0002` as its group, but its element number is two bytes the file supplied and
3747
+ * a File Meta element that over-declared its length can desynchronize the
3748
+ * pre-pass onto two bytes of somebody's value. Same standing exception as
3749
+ * {@link DeidentifyReport.removedPrivateTags}, by a narrower route and with two
3750
+ * bytes rather than four. `byteLength` is the length of the value that was
3751
+ * dropped, read off that same header. It is published because *what* was dropped
3752
+ * is the whole audit value of a record of a deliberate fidelity loss.
3753
+ *
3754
+ * @example
3755
+ * ```ts
3756
+ * import { deidentify, parseDicom, type FileMetaDroppedElement } from "@cosyte/dicom";
3757
+ * const { report } = deidentify(parseDicom(buf));
3758
+ * report.fileMetaElementsDropped.forEach((d: FileMetaDroppedElement) => {
3759
+ * console.warn(`${d.tag} ${d.vr}: ${String(d.byteLength)} bytes not re-emitted`);
3760
+ * });
3761
+ * ```
3762
+ */
3763
+ interface FileMetaDroppedElement {
3764
+ /** The dropped element's `(0002,xxxx)` tag. */
3765
+ readonly tag: Tag;
3766
+ /** The VR the source file wrote for it (the File Meta group is Explicit VR LE). */
3767
+ readonly vr: VR;
3768
+ /** Byte length of the value that is not in the de-identified output. */
3769
+ readonly byteLength: number;
3770
+ }
3771
+ /**
3772
+ * One Data Element whose Group Number is `0004`, **removed** from de-identified
3773
+ * output.
3774
+ *
3775
+ * ## The rule, and the one object it does not apply to
3776
+ *
3777
+ * PS3.15 2026c §E.1.1: "All Data Elements with a Group Number of 0004 shall be
3778
+ * removed from any SOP Instance or DICOM File other than a DICOMDIR File." The
3779
+ * rule is unconditional over the Annex E Options - it sits in the list §E.1.1
3780
+ * imposes on any de-identifier claim and no Option qualifies it - so no
3781
+ * `retain` argument brings a `(0004,xxxx)` element back.
3782
+ *
3783
+ * The DICOMDIR carve-out is decided once per run from the source File Meta's
3784
+ * `(0002,0002)` Media Storage SOP Class UID, and only the value
3785
+ * `1.2.840.10008.1.3.10` (Media Storage Directory Storage) selects it. An object
3786
+ * that declares no Media Storage SOP Class UID at all is therefore **not** a
3787
+ * DICOMDIR and the removal applies to it. When the carve-out does fire, this
3788
+ * array is empty and `DICOM_DEIDENT_DICOMDIR_FILE_SET_NOT_DISCHARGED` says what
3789
+ * else the run did not do.
3790
+ *
3791
+ * ## Every depth, and the removal decides nothing below it
3792
+ *
3793
+ * The test runs on each element of each Data Set the run reaches, root and
3794
+ * Sequence Item alike, so a `(0004,xxxx)` planted inside an item is removed
3795
+ * there too. It is a test on **one element's tag**: it never stands in for a
3796
+ * decision about the Data Sets nested inside some other element, which is the
3797
+ * shape `DICOM-PRIVATE-SQ-CARVE-OUT` was opened for. Removing a `(0004,xxxx)`
3798
+ * that is itself a Sequence removes its items with it, exactly as a Table E.1-1
3799
+ * `X` action does.
3800
+ *
3801
+ * ## Capped record, complete count
3802
+ *
3803
+ * Bounded per run at `MAX_GROUP_0004_FINDINGS`, on its own counter so a flood of
3804
+ * one diagnostic class cannot spend another's budget. The removal itself is not
3805
+ * bounded, and {@link DeidentifyReport.group0004RemovalCount} is complete at any
3806
+ * input size.
3807
+ *
3808
+ * ## What it carries
3809
+ *
3810
+ * `tag` is composed from four bytes of the source and `contextPath` from four
3811
+ * more per segment - the standing exception
3812
+ * {@link DeidentifyReport.removedPrivateTags} and
3813
+ * {@link UnauditableSequenceFinding} already carry, by the same route. On a
3814
+ * well-formed file these are the DICOMDIR directory-structuring tags PS3.6
3815
+ * registers and carry nothing.
3816
+ *
3817
+ * @example
3818
+ * ```ts
3819
+ * import { deidentify, parseDicom, type Group0004Removal } from "@cosyte/dicom";
3820
+ * const { report } = deidentify(parseDicom(buf));
3821
+ * report.group0004Removals.forEach((r: Group0004Removal) => {
3822
+ * console.warn(`${r.tag} removed`, r.contextPath ?? "root");
3823
+ * });
3824
+ * ```
3825
+ */
3826
+ interface Group0004Removal {
3827
+ /** The removed element's tag. Its group is always `0004`. */
3828
+ readonly tag: Tag;
3829
+ /**
3830
+ * The outcome, always `"removed"`: no element bearing that tag is in the Data
3831
+ * Set that held it, in the returned dataset or in the serialized bytes.
3832
+ */
3833
+ readonly applied: "removed";
3834
+ /**
3835
+ * Tag/index chain when the element was inside a sequence item; omitted at the
3836
+ * root. **Built by the same descent as
3837
+ * {@link DeidentifiedAttribute.contextPath} and carrying the same caveat:
3838
+ * each segment's tag is read off the wire, bound by nothing, so on a
3839
+ * desynchronized read it can be four bytes of a value.** Read that field's
3840
+ * note before logging this one.
3841
+ */
3842
+ readonly contextPath?: readonly string[];
3843
+ }
3844
+ /**
3845
+ * One non-private Data Element **removed** because this build's PS3.6 2026d
3846
+ * registry does not carry its tag and PS3.15 2026d Table E.1-1 does not list it.
3847
+ *
3848
+ * ## The rule
3849
+ *
3850
+ * A Table E.1-1 miss used to mean "keep": an attribute the table does not list
3851
+ * went into de-identified output verbatim with a report that said nothing about
3852
+ * it. That is right for an attribute PS3.6 registers, which the Profile has
3853
+ * judged, and wrong for one it does not: the notes to PS3.15 2026d Table E.1-1
3854
+ * name "new Standard Attributes" among the places identifying information may
3855
+ * be, and say removing only the known risks "may fail when the Standard is
3856
+ * extended, or when a vendor adds unanticipated Standard Attributes". So an
3857
+ * even-group tag with **no registry row and no Table E.1-1 row** is removed, at
3858
+ * every depth `deidentify()` walks, whatever its VR (`UN`, an on-wire VR outside
3859
+ * the 34 PS3.5 section 6.2 defines, and `SQ` included; a Sequence goes whole and
3860
+ * nothing inside it is walked or recorded).
3861
+ *
3862
+ * "Registered" means a literal PS3.6 row, or a masked row the tag matches, with
3863
+ * the `50xx` / `60xx` groups bounded by PS3.5 2026c section 7.6 (even groups
3864
+ * `5000`-`501E` and `6000`-`601E`). Private tags, group `0004`, group `0002`,
3865
+ * group lengths `(gggg,0000)` and every tag Table E.1-1 lists are not decided by
3866
+ * this rule.
3867
+ *
3868
+ * ## What it costs
3869
+ *
3870
+ * **A conformant attribute from a PS3.6 edition newer than this build's pin is
3871
+ * removed too**, because nothing separates it from an invented one. That is
3872
+ * over-redaction and it is deliberate; a caller who needs such an attribute back
3873
+ * has no switch for it in this release.
3874
+ *
3875
+ * ## What it carries, and what it deliberately does not
3876
+ *
3877
+ * **No tag and no VR.** The trigger is "no registry row carries this tag", and
3878
+ * four bytes an under-declared length upstream made the reader take as a header
3879
+ * satisfy it by construction: the fixture in
3880
+ * `test/integration/deident-undefined-vr.test.ts` fabricates `(4854,4F53)`,
3881
+ * `"THSO"` in wire order, four letters of a surname. Publishing the tag would
3882
+ * republish them, so the element is identified by `byteOffset`, a position this
3883
+ * parser counted, exactly as {@link UndefinedVrFinding} does.
3884
+ *
3885
+ * `contextPath` names the Sequences this run **descended** to reach the
3886
+ * element, never the removed element itself, and carries the caveat every
3887
+ * finding's `contextPath` carries: see {@link DeidentifiedAttribute.contextPath}.
3888
+ *
3889
+ * ## Capped record, complete count
3890
+ *
3891
+ * Bounded per run at `MAX_UNREGISTERED_ELEMENT_FINDINGS`, on its own counter so
3892
+ * a flood of one diagnostic class cannot spend another's budget. The removal
3893
+ * itself is not bounded, and
3894
+ * {@link DeidentifyReport.unregisteredElementRemovalCount} is complete at any
3895
+ * input size.
3896
+ *
3897
+ * @example
3898
+ * ```ts
3899
+ * import { deidentify, parseDicom, type UnregisteredElementRemoval } from "@cosyte/dicom";
3900
+ * const { report } = deidentify(parseDicom(buf));
3901
+ * report.unregisteredElementRemovals.forEach((r: UnregisteredElementRemoval) => {
3902
+ * console.warn(`removed at offset ${String(r.byteOffset)}`, r.contextPath ?? "root");
3903
+ * });
3904
+ * ```
3905
+ */
3906
+ interface UnregisteredElementRemoval {
3907
+ /**
3908
+ * Byte offset of the removed element's header, as the parser recorded it on
3909
+ * `Element.byteOffset`. **This is how the element is identified, and there is
3910
+ * deliberately no `tag` field**: see the note above.
3911
+ */
3912
+ readonly byteOffset: number;
3913
+ /**
3914
+ * Tag/index chain when the element was inside a sequence item; omitted at the
3915
+ * root. **Built by the same descent as
3916
+ * {@link DeidentifiedAttribute.contextPath} and carrying the same caveat:
3917
+ * each segment's tag is read off the wire.** Read that field's note before
3918
+ * logging this one.
3919
+ */
3920
+ readonly contextPath?: readonly string[];
3921
+ }
2880
3922
  /**
2881
3923
  * The audit trail returned alongside the de-identified dataset.
2882
3924
  *
@@ -2900,13 +3942,18 @@ interface UndefinedVrFinding {
2900
3942
  * theoretical.
2901
3943
  * - **`unauditableSequences[].tag`** - see
2902
3944
  * {@link UnauditableSequenceFinding}. Same shape as `removedPrivateTags` by a
2903
- * narrower route: a private carrier a {@link Profile} declares `SQ` is named
2904
- * there, and an under-declared length upstream can resynchronize the reader
2905
- * onto four bytes that spell such a block. The package's usual answer to a
3945
+ * narrower route: a private carrier a {@link Profile} vouched for and this run
3946
+ * emptied is named there, and an under-declared length upstream can
3947
+ * resynchronize the reader onto four bytes that spell such a block. The
3948
+ * package's usual answer to a
2906
3949
  * fabricated header, `undefinedVrElements`, carries a byte offset and no tag,
2907
3950
  * and still answers it whenever the fabricated VR is outside the 34 PS3.5
2908
3951
  * §6.2 defines. It cannot when the fabricated VR is one of them, because those
2909
3952
  * two files are byte-identical.
3953
+ * - **`unenumerablePrivateRemovals[].tag`** - see
3954
+ * {@link UnenumerablePrivateRemoval}. The same four bytes by the same route,
3955
+ * on the record of the removal rather than of an emptying, and this one is
3956
+ * **uncapped** because it is the record of an action rather than a diagnostic.
2910
3957
  * - **`undefinedVrElements[].byteLength` and
2911
3958
  * `unauditableSequences[].byteLength`** - the declared Value Length read off
2912
3959
  * the element header, so on a fabricated header it is four document bytes
@@ -2919,7 +3966,17 @@ interface UndefinedVrFinding {
2919
3966
  * them is a product call rather than a defect fix: a bound empties the field
2920
3967
  * on every well-formed file, where the number is exactly the audit
2921
3968
  * information it exists to carry.
2922
- * - **`contextPath`, on all four findings that carry one** - see
3969
+ * - **`group0004Removals[].tag`** - see {@link Group0004Removal}. The same four
3970
+ * bytes by the same route as `removedPrivateTags`, on the record of a removal
3971
+ * PS3.15 §E.1.1 requires. On a well-formed file these are PS3.6's registered
3972
+ * DICOMDIR directory-structuring tags and carry nothing.
3973
+ * - **`fileMetaElementsDropped[].tag` and `.vr`** - see
3974
+ * {@link FileMetaDroppedElement}. Two bytes rather than four: the File Meta
3975
+ * pre-pass stops at the first non-`0002` group, so the group half of the tag
3976
+ * is fixed and only the element number is free, and the VR is two more bytes
3977
+ * the same desynchronized read would supply. `.byteLength` joins
3978
+ * `undefinedVrElements[].byteLength` in the entry above by the same route.
3979
+ * - **`contextPath`, on every finding that carries one** - see
2923
3980
  * {@link DeidentifiedAttribute.contextPath}, which holds the measurement. The
2924
3981
  * segment tags come off the wire with no table behind them, so a fabricated
2925
3982
  * `SQ` header the reader descended is named there, `PRE-EXISTING`, with **no
@@ -2954,7 +4011,14 @@ interface DeidentifyReport {
2954
4011
  /** Per-attribute outcomes for every attribute Annex E acted on. */
2955
4012
  readonly attributes: readonly DeidentifiedAttribute[];
2956
4013
  /**
2957
- * Private tags removed under the Basic Profile (kept ones are omitted).
4014
+ * Private tags removed (kept ones are omitted) - **every one of them,
4015
+ * whichever rule removed it**: the Basic Profile's default removal, a
4016
+ * reservation the file did not settle, and, since the unenumerable class
4017
+ * became a removal, a private attribute this run did not enumerate. Its
4018
+ * meaning is unchanged and it is still uncapped; what it does not carry is a
4019
+ * **reason**, which is why the unenumerable removals are also recorded on
4020
+ * {@link DeidentifyReport.unenumerablePrivateRemovals}, where they can be told
4021
+ * apart from the rest.
2958
4022
  *
2959
4023
  * **This is the second field that is not value-free, and the qualification is
2960
4024
  * measured.** A tag here is composed from four bytes of the source, and on a
@@ -2981,18 +4045,33 @@ interface DeidentifyReport {
2981
4045
  */
2982
4046
  readonly embeddedAttributes: readonly EmbeddedAttributeFinding[];
2983
4047
  /**
2984
- * `SQ` elements emptied because the parser did not materialize their items, so
2985
- * the run had no Data Sets to walk and could not discharge PS3.15 §E.1.1's
2986
- * obligation inside them. Empty on a well-formed file; a non-empty array means
2987
- * content was dropped from the de-identified output, and the matching
2988
- * `DICOM_SQ_NOT_DESCENDED` entry on `Dataset.warnings` says why the parse
2989
- * refused. See {@link UnauditableSequenceFinding}.
4048
+ * Carriers whose nested Data Sets this run could not reach and **emptied** for
4049
+ * it: an `SQ` whose items the parser did not materialize, so the run had no
4050
+ * Data Sets to walk and could not discharge PS3.15 §E.1.1's obligation inside
4051
+ * them, and a private carrier a {@link Profile} declares `SQ` that the parse
4052
+ * tree resolved otherwise. Content was dropped from the de-identified output,
4053
+ * and for the first producer the matching `DICOM_SQ_NOT_DESCENDED` entry on
4054
+ * `Dataset.warnings` says why the parse refused.
4055
+ *
4056
+ * 🛑 **THIS ARRAY NO LONGER PRODUCES `applied: "kept"`, AND THAT IS AN
4057
+ * AUDIT-CONTRACT CHANGE.** A private attribute retained under
4058
+ * `RetainSafePrivate` whose value this run never enumerated used to be listed
4059
+ * here, kept verbatim, with the array saying so. That instance is **removed**
4060
+ * now and recorded on
4061
+ * {@link DeidentifyReport.unenumerablePrivateRemovals}. An entry here has one
4062
+ * meaning again: this content is **not** in your output.
4063
+ *
4064
+ * **Empty on a well-formed file**, including one whose private attributes a
4065
+ * profile vouches for. It stopped being populated by ordinary conformant files
4066
+ * when the retained class left it.
2990
4067
  *
2991
4068
  * **Capped, and the cap is on the record only.** A crafted input can carry
2992
4069
  * tens of thousands of un-auditable elements, so this array (and its matching
2993
4070
  * warnings) stops at `MAX_UNAUDITABLE_SEQUENCE_FINDINGS`. Every un-auditable
2994
- * sequence is still emptied; an array exactly that long means "at least this
2995
- * many", so read it as truncated rather than as a total.
4071
+ * carrier is still emptied whether or not it is listed; an array exactly that
4072
+ * long means "at least this many", so read it as truncated rather than as a
4073
+ * total. The unenumerable **removals** are budgeted apart from it and their
4074
+ * record is not capped at all.
2996
4075
  *
2997
4076
  * **It covers private sequences too, since `DICOM-PRIVATE-SQ-CARVE-OUT`.** A
2998
4077
  * private `SQ` a {@link Profile} vouches for under `RetainSafePrivate` used to
@@ -3001,6 +4080,34 @@ interface DeidentifyReport {
3001
4080
  * is over a private attribute and not over an item stream nothing could read.
3002
4081
  */
3003
4082
  readonly unauditableSequences: readonly UnauditableSequenceFinding[];
4083
+ /**
4084
+ * Private attributes **removed because this run did not enumerate their
4085
+ * value** - one entry per instance, naming the tag, the outcome (`"removed"`),
4086
+ * the reason (`"unenumerable"`) and the Data Set it lived in.
4087
+ *
4088
+ * **This is the surface the fail-safe's audit half is built on, and it is the
4089
+ * one array on this report that is never capped or truncated.** A caller can
4090
+ * separate, from this record alone and for every such removal at any input
4091
+ * size, the attributes removed for being unenumerable from the attributes
4092
+ * removed by the Annex E action table (`report.attributes`) and from the ones
4093
+ * whose value was emptied. The matching warnings ARE bounded, so past their
4094
+ * cap this record is the only complete account and the diagnostics are the
4095
+ * bounded one.
4096
+ *
4097
+ * **Empty unless `RetainSafePrivate` plus a `Profile` is active**, because
4098
+ * without both nothing private is retained far enough to be judged: the Basic
4099
+ * Profile removes every private attribute and names it in
4100
+ * {@link DeidentifyReport.removedPrivateTags} instead. A profile lookup that
4101
+ * **misses** is that case too, and never appears here. **The file's own
4102
+ * (0008,0300) declaration does not populate it either**, and that is not an
4103
+ * omission: a value that declaration covers is one §E.3.10 says the run knows
4104
+ * about, so it is retained rather than reaching this rule at all.
4105
+ *
4106
+ * See {@link UnenumerablePrivateRemoval} for what counts as enumeration, for
4107
+ * the over-redaction this costs, and for why it names an instance rather than
4108
+ * a tag.
4109
+ */
4110
+ readonly unenumerablePrivateRemovals: readonly UnenumerablePrivateRemoval[];
3004
4111
  /**
3005
4112
  * Elements emptied because their on-wire VR is not one of the 34 PS3.5 §6.2
3006
4113
  * defines, so their bytes are not a Value Field this library decoded and
@@ -3040,6 +4147,82 @@ interface DeidentifyReport {
3040
4147
  * because a vouched-for private `SQ` is routed into the ordinary `SQ` branches.
3041
4148
  */
3042
4149
  readonly undefinedVrElements: readonly UndefinedVrFinding[];
4150
+ /**
4151
+ * Non-modeled `(0002,xxxx)` File Meta elements this run **dropped** instead of
4152
+ * re-emitting, because the de-identified File Meta group describes this
4153
+ * de-identifying application rather than the source (PS3.15 §E.1.1). Empty
4154
+ * when the source group held only modeled elements, and empty when the source
4155
+ * parsed with no File Meta group at all.
4156
+ *
4157
+ * **This is the record of a deliberate fidelity loss, not of a defect.** The
4158
+ * byte-for-byte File Meta round trip `FileMeta.extraElements` documents is
4159
+ * scoped to parse-then-serialize and does not hold here.
4160
+ *
4161
+ * **Capped; the count beside it is not.** See {@link FileMetaDroppedElement}
4162
+ * and {@link DeidentifyReport.fileMetaElementsDroppedCount}.
4163
+ */
4164
+ readonly fileMetaElementsDropped: readonly FileMetaDroppedElement[];
4165
+ /**
4166
+ * How many non-modeled `(0002,xxxx)` elements this run dropped, **complete at
4167
+ * any input size**.
4168
+ *
4169
+ * The field exists because its sibling array is capped: a caller that needs to
4170
+ * *count* the fidelity loss cannot get the number from an array whose length
4171
+ * saturates, and re-parsing the output does not give it either - the elements
4172
+ * are gone from the output by design. One number cannot be inflated by an
4173
+ * attacker-chosen element count, so bounding it would buy nothing.
4174
+ */
4175
+ readonly fileMetaElementsDroppedCount: number;
4176
+ /**
4177
+ * Data Elements whose Group Number is `0004`, **removed** at every depth this
4178
+ * run reached, as PS3.15 §E.1.1 requires "from any SOP Instance or DICOM File
4179
+ * other than a DICOMDIR File".
4180
+ *
4181
+ * Empty when the object carried none, and empty when it declared Media Storage
4182
+ * SOP Class UID `1.2.840.10008.1.3.10` - the carve-out, which is disclosed on
4183
+ * {@link DeidentifyReport.warnings} as
4184
+ * `DICOM_DEIDENT_DICOMDIR_FILE_SET_NOT_DISCHARGED` rather than left to be
4185
+ * inferred from an empty array.
4186
+ *
4187
+ * **Capped; the count beside it is not.** See {@link Group0004Removal} and
4188
+ * {@link DeidentifyReport.group0004RemovalCount}.
4189
+ */
4190
+ readonly group0004Removals: readonly Group0004Removal[];
4191
+ /**
4192
+ * How many `(0004,xxxx)` elements this run removed, **complete at any input
4193
+ * size**, for the reason
4194
+ * {@link DeidentifyReport.fileMetaElementsDroppedCount} states.
4195
+ *
4196
+ * It is also the field that tells the two §E.1.1 rules apart at a glance: this
4197
+ * one counts group-0004 removals, the other counts File Meta drops, and
4198
+ * neither can be read off the other.
4199
+ */
4200
+ readonly group0004RemovalCount: number;
4201
+ /**
4202
+ * Non-private Data Elements **removed** at every depth this run reached
4203
+ * because this build's PS3.6 2026d registry does not carry their tag and Table
4204
+ * E.1-1 does not list it. See {@link UnregisteredElementRemoval} for the rule,
4205
+ * for what it costs on a newer-edition file, and for why an entry names a byte
4206
+ * offset and no tag.
4207
+ *
4208
+ * Empty on a run that removed none. A separate record from
4209
+ * {@link DeidentifyReport.attributes}, {@link DeidentifyReport.removedPrivateTags},
4210
+ * {@link DeidentifyReport.group0004Removals} and
4211
+ * {@link DeidentifyReport.undefinedVrElements}: an element removed here is on
4212
+ * none of those.
4213
+ *
4214
+ * **Capped; the count beside it is not.** See
4215
+ * {@link DeidentifyReport.unregisteredElementRemovalCount}.
4216
+ */
4217
+ readonly unregisteredElementRemovals: readonly UnregisteredElementRemoval[];
4218
+ /**
4219
+ * How many elements this run removed under the rule
4220
+ * {@link DeidentifyReport.unregisteredElementRemovals} records, **complete at
4221
+ * any input size**, for the reason
4222
+ * {@link DeidentifyReport.fileMetaElementsDroppedCount} states. `0` on a run
4223
+ * that removed none.
4224
+ */
4225
+ readonly unregisteredElementRemovalCount: number;
3043
4226
  /**
3044
4227
  * Source UID → replacement UID, for cross-file consistency. The **keys are
3045
4228
  * document values**, not composed identifiers: this is the one field of the
@@ -3051,11 +4234,13 @@ interface DeidentifyReport {
3051
4234
  /**
3052
4235
  * The Retain/Clean options that were active for this run.
3053
4236
  *
3054
- * **Not a list of what survived.** Attributes Table E.1-1 does not list are
3055
- * kept without appearing anywhere in this field - `(0012,0063)`
3056
- * De-identification Method is the one whose retention is disclosed, and it is
3057
- * disclosed as `DICOM_DEIDENT_METHOD_PRIOR_RETAINED` on
3058
- * {@link DeidentifyReport.warnings}, because it is not an option set.
4237
+ * **Not a list of what survived.** Registered attributes Table E.1-1 does not
4238
+ * list are kept without appearing anywhere in this field - `(0012,0063)`
4239
+ * De-identification Method and the prior Items of `(0012,0064)`
4240
+ * De-identification Method Code Sequence are the ones whose retention is
4241
+ * disclosed, as `DICOM_DEIDENT_METHOD_PRIOR_RETAINED` and
4242
+ * `DICOM_DEIDENT_METHOD_CODES_PRIOR_RETAINED` on
4243
+ * {@link DeidentifyReport.warnings}, because neither is an option set.
3059
4244
  */
3060
4245
  readonly retained: readonly DeidentifyOption[];
3061
4246
  }
@@ -3083,22 +4268,47 @@ interface DeidentifyOptions {
3083
4268
  */
3084
4269
  readonly uidMap?: Map<string, string>;
3085
4270
  /**
3086
- * A Phase 6 {@link Profile} whose private-dictionary overlay names the
4271
+ * A {@link Profile} whose private-dictionary overlay names the
3087
4272
  * known-safe private attributes to keep when `RetainSafePrivate` is active.
3088
- * Without it, `RetainSafePrivate` keeps nothing (fail-safe).
4273
+ *
4274
+ * **Not the only way an attribute is known safe, and no longer required for
4275
+ * `RetainSafePrivate` to keep anything.** PS3.15 2026c §E.3.10 lists four ways
4276
+ * that knowledge may be established and a profile is two of them
4277
+ * (Conformance Statement documentation, and "some other means"); the first is
4278
+ * the **file's own** Private Data Element Characteristics Sequence
4279
+ * (0008,0300), which `deidentify()` reads when this Option is active whether
4280
+ * or not a profile is passed. So a file from a vendor you hold no profile for
4281
+ * keeps the private attributes **that file declares** non-identifying, and
4282
+ * passing a profile only ever adds to that: a profile retention survives a
4283
+ * block the declaration calls `UNSAFE` or says nothing about.
4284
+ *
4285
+ * 🩺 **What the declaration route retains rests on the SENDER's assertion.**
4286
+ * The system that wrote the file said those blocks carry no identifying
4287
+ * information; nothing here re-derives it, and an opaque value in a block
4288
+ * declared `SAFE` reaches de-identified output unexamined. If you do not trust
4289
+ * the sender, leave `RetainSafePrivate` off - §E.3.10's own alternative, and
4290
+ * the only mitigation the standard offers.
4291
+ *
4292
+ * With neither this nor a declaration in the file, `RetainSafePrivate` keeps
4293
+ * nothing (fail-safe).
3089
4294
  */
3090
4295
  readonly profile?: Profile;
3091
4296
  /**
3092
4297
  * Text **added to** `(0012,0063)` De-identification Method. Default names the
3093
4298
  * Basic Profile and the active options.
3094
4299
  *
4300
+ * **It never changes `(0012,0064)`.** The CID 7050 codes the run writes there
4301
+ * follow the options that ran, and free text cannot alter what ran; see
4302
+ * `DEIDENTIFICATION_METHOD_CODES`.
4303
+ *
3095
4304
  * **The default is multi-valued, one Value per name, and that is a conformance
3096
4305
  * fix rather than a style choice.** PS3.5 2026c Table 6.2-1 caps an `LO` at
3097
4306
  * **64 characters per Value**, and `(0012,0063)` is `1-n`; the single-value
3098
4307
  * default this replaced measured 76 characters with no options and 272 with
3099
- * all nine, so every run this library ever made wrote a value no `LO` may
3100
- * legally carry. The Profile name is now one Value of 61 characters and each
3101
- * active option is its own, so no option subset can exceed the maximum.
4308
+ * every option name published at that time, so every run this library ever
4309
+ * made wrote a value no `LO` may legally carry. The Profile name is now one
4310
+ * Value of 61 characters and each active option is its own, so no option
4311
+ * subset can exceed the maximum.
3102
4312
  *
3103
4313
  * **Your string is not bounded here, but it is no longer silent.** A value of
3104
4314
  * your own longer than 64 characters is written through as given - it is
@@ -3175,14 +4385,27 @@ interface DeidentifyResult<TDataset> {
3175
4385
  /**
3176
4386
  * Stable codes for {@link DeidentifyError}.
3177
4387
  *
4388
+ * - `INVALID_OPTIONS`: an author-time misconfiguration of the call itself (an
4389
+ * unknown Retain option, both PS3.15 §E.3.6 temporal Options at once, a
4390
+ * malformed `uidRoot`).
4391
+ * - `UNSUPPORTED_TRANSFER_SYNTAX`: the Dataset's File Meta Transfer Syntax UID
4392
+ * is one this de-identifier refuses to act on, whatever the options. Today
4393
+ * that is exactly the four JPIP Referenced syntaxes of PS3.5 2026c sections
4394
+ * A.6, A.7, A.11 and A.12: such an object references its pixels through
4395
+ * Pixel Data Provider URL `(0028,7FE0)`, which has no PS3.15 Table E.1-1 row,
4396
+ * so a de-identified copy would keep the URL by omission. Nothing is
4397
+ * de-identified and no dataset or report is returned.
4398
+ *
3178
4399
  * @example
3179
4400
  * ```ts
3180
4401
  * import { DEIDENTIFY_ERROR_CODES } from "@cosyte/dicom";
3181
4402
  * DEIDENTIFY_ERROR_CODES.INVALID_OPTIONS; // "INVALID_OPTIONS"
4403
+ * DEIDENTIFY_ERROR_CODES.UNSUPPORTED_TRANSFER_SYNTAX; // "UNSUPPORTED_TRANSFER_SYNTAX"
3182
4404
  * ```
3183
4405
  */
3184
4406
  declare const DEIDENTIFY_ERROR_CODES: Readonly<{
3185
4407
  readonly INVALID_OPTIONS: "INVALID_OPTIONS";
4408
+ readonly UNSUPPORTED_TRANSFER_SYNTAX: "UNSUPPORTED_TRANSFER_SYNTAX";
3186
4409
  }>;
3187
4410
  /**
3188
4411
  * One of the {@link DEIDENTIFY_ERROR_CODES} values.
@@ -3196,11 +4419,16 @@ declare const DEIDENTIFY_ERROR_CODES: Readonly<{
3196
4419
  */
3197
4420
  type DeidentifyErrorCode = (typeof DEIDENTIFY_ERROR_CODES)[keyof typeof DEIDENTIFY_ERROR_CODES];
3198
4421
  /**
3199
- * Thrown for an author-time misconfiguration of {@link deidentify} (an unknown
3200
- * Retain option, a malformed UID root). Distinct from the parser's fatal codes,
3201
- * the value layer's `DicomValueError`, and the serializer's `DicomSerializeError`.
3202
- * The message carries only structural facts (option names, the UID root) - never
3203
- * a decoded value.
4422
+ * Thrown by {@link deidentify} when it will not de-identify, for one of two
4423
+ * reasons a caller tells apart by `code` (see {@link DEIDENTIFY_ERROR_CODES}):
4424
+ * an author-time misconfiguration of the call (`INVALID_OPTIONS`: an unknown
4425
+ * Retain option, a malformed UID root), or a Dataset under a Transfer Syntax this
4426
+ * de-identifier refuses (`UNSUPPORTED_TRANSFER_SYNTAX`: the four JPIP Referenced
4427
+ * syntaxes). Distinct from the parser's fatal codes, the value layer's
4428
+ * `DicomValueError`, and the serializer's `DicomSerializeError`. The message
4429
+ * carries only structural facts (option names, the UID root) or a fixed string -
4430
+ * never a decoded value, and for `UNSUPPORTED_TRANSFER_SYNTAX` nothing read from
4431
+ * the Dataset at all.
3204
4432
  *
3205
4433
  * @example
3206
4434
  * ```ts
@@ -3245,17 +4473,41 @@ declare class DeidentifyError extends Error {
3245
4473
  * exact-tag miss. A match is removed *and* reported, with
3246
4474
  * `DeidentifiedAttribute.repeatingGroup` naming the mask.
3247
4475
  * - Recurses into kept sequences and **re-encodes** them so nested PHI is removed
3248
- * in the *serialized* bytes too - not just the object model (the Phase 5 writer
4476
+ * in the *serialized* bytes too - not just the object model (the writer
3249
4477
  * blits `SQ` spans verbatim, so a rebuilt `items` array alone would not survive
3250
4478
  * serialization). Rebuilt sequences are normalized to defined length.
3251
- * - Removes all private attributes by default; with `RetainSafePrivate` + a
3252
- * {@link Profile}, keeps the private data elements the profile's overlay names
3253
- * as safe (and the private-creator elements the profile recognizes).
4479
+ * - Removes every non-private attribute Table E.1-1 does not list **and this
4480
+ * build's PS3.6 2026d registry does not carry** - a Standard Attribute from a
4481
+ * later edition, or one the sender invented - at every depth and whatever its
4482
+ * VR, recording it on `report.unregisteredElementRemovals` by byte offset and
4483
+ * never by tag. A registered attribute Table E.1-1 does not list is kept as it
4484
+ * always was. See {@link isUnregisteredStandardTag}.
4485
+ * - Removes all private attributes by default. With `RetainSafePrivate` it keeps
4486
+ * the ones PS3.15 §E.3.10 lets it know are safe, by two routes that only ever
4487
+ * add to each other: the private data elements a caller {@link Profile}'s
4488
+ * overlay names as safe (and the private-creator elements it recognizes), and
4489
+ * the blocks the **file itself** declares non-identifying in Private Data
4490
+ * Element Characteristics Sequence `(0008,0300)`, which needs no profile. The
4491
+ * second one is the sender's assertion rather than this run's finding; see
4492
+ * {@link declaredSafe}.
4493
+ * - Writes `(0028,0303)` Longitudinal Temporal Information Modified with the
4494
+ * state this run's option set put the object in: `UNMODIFIED` under
4495
+ * `RetainLongitudinalTemporal`, `MODIFIED` under
4496
+ * `RetainLongitudinalTemporalModifiedDates`, `REMOVED` under neither (PS3.15
4497
+ * §E.3.6 and §E.2). The two temporal Options are mutually exclusive and a call
4498
+ * naming both is rejected. **Replaced, not joined** - it is `VM 1`, so a prior
4499
+ * sender's declaration is discarded rather than appended to. `MODIFIED` says
4500
+ * the modified-dates column was resolved and **not** that this library
4501
+ * transformed a date, which it never does; see {@link TEMPORAL_STATE}.
3254
4502
  * - Remaps `(0002,0003)` Media Storage SOP Instance UID consistently (unless
3255
4503
  * `RetainUIDs`), writes `(0012,0062)` Patient Identity Removed = `YES`, **adds**
3256
4504
  * its method text to `(0012,0063)` De-identification Method rather than
3257
4505
  * replacing what the file already recorded there (PS3.15 E.1.1 - see
3258
- * `addDeidentificationMethod`), and warns
4506
+ * `addDeidentificationMethod`), **adds** the PS3.16 CID 7050 codes for the
4507
+ * Profile and each active Option to `(0012,0064)` De-identification Method Code
4508
+ * Sequence, after any Items the file already carried there and without
4509
+ * repeating one it already records (see `addDeidentificationMethodCodes`), and
4510
+ * warns
3259
4511
  * (`DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED`) when Pixel Data is present and not
3260
4512
  * marked free of burned-in annotation - this metadata-only pass cannot clean
3261
4513
  * pixels (deferred to `@cosyte/dicom-pixel`).
@@ -3266,6 +4518,16 @@ declare class DeidentifyError extends Error {
3266
4518
  * dummy is instead removed/emptied.
3267
4519
  * - `C` (clean) is a conservative blank, not a meaning-preserving structured
3268
4520
  * replacement (which needs domain context the metadata layer lacks).
4521
+ * - **A conformant attribute from a PS3.6 edition newer than this build's pin is
4522
+ * removed**, because it has no registry row here and nothing separates it from
4523
+ * an invented one. Over-redaction, deliberate, with no caller switch in this
4524
+ * release (`DICOM-DEIDENT-OVER-REDACTION`).
4525
+ * - The temporal declaration is written at the **top level only**, which is where
4526
+ * §E.2 and §E.3.6 put it and where a recipient reads it. `(0028,0303)` has no
4527
+ * row in Table E.1-1, so a copy the sender nested inside a Sequence Item is
4528
+ * retained by omission like any other unlisted attribute and still says
4529
+ * whatever that sender wrote. Read the Data Set's own `(0028,0303)`, never a
4530
+ * nested one.
3269
4531
  * - Pixel-level options (`CleanPixelData`, `CleanRecognizableVisual`) are out of
3270
4532
  * scope; burned-in text is warned, never cleaned.
3271
4533
  * - A private data element kept under `RetainSafePrivate` is kept *verbatim*
@@ -3283,6 +4545,22 @@ declare class DeidentifyError extends Error {
3283
4545
  * wins). A carrier the profile declares `SQ` and the tree did not resolve is
3284
4546
  * emptied rather than kept, keeping its parsed VR
3285
4547
  * (`DICOM-PRIVATE-SQ-PARSE-VR`). See {@link keepRetainedPrivate}.
4548
+ * - **A retained private value this run did not ENUMERATE is REMOVED**, named in
4549
+ * `report.removedPrivateTags` and recorded per instance in
4550
+ * `report.unenumerablePrivateRemovals` with its reason, under the existing
4551
+ * `DICOM_DEIDENT_PRIVATE_CARRIER_NOT_AUDITABLE` warning. Through `0.0.19` it
4552
+ * was kept verbatim and merely disclosed, which shipped a Data Set the sender
4553
+ * nested inside it into output stamped `(0012,0062) = YES` on a **fully
4554
+ * conformant** file. 🛑 **The cost is priced at its real size and is not a
4555
+ * corner of the option:** this package enumerates nothing inside a retained
4556
+ * private value beyond the classes named on
4557
+ * {@link removeUnenumerablePrivate}, so an ordinary vendor scalar under an
4558
+ * ordinary string VR that the file's own `(0008,0300)` declaration does not
4559
+ * name safe is removed too. Getting such a value back needs either a
4560
+ * declaration in the file - the sender's assertion, not an enumeration this
4561
+ * run performed - or the content test that separates a nested Data Set from a
4562
+ * legitimate binary blob, which is an open product question
4563
+ * (`DICOM-DEIDENT-OVER-REDACTION`).
3286
4564
  * - A `SQ` whose `items` the parser did not
3287
4565
  * materialize is **emptied**, not kept:
3288
4566
  * its value is by PS3.5 §7.5.1 a stream of Data Sets, and a run that cannot
@@ -3334,14 +4612,14 @@ declare class DeidentifyError extends Error {
3334
4612
  * which removal is the branch available here (`(0008,0307)` is not
3335
4613
  * implemented). Every private element the recursion **reaches** in such an
3336
4614
  * Item, at any depth below it, is removed and named in
3337
- * `report.removedPrivateTags`. **The carve-out `#54` was refused for asserting
4615
+ * `report.removedPrivateTags`. **The carve-out an earlier review refused for asserting
3338
4616
  * away is CLOSED** (`DICOM-PRIVATE-SQ-CARVE-OUT`): `keepsPrivate` still decides
3339
4617
  * before the descent, but a **private `SQ`** it vouches for now routes through
3340
4618
  * {@link keepRetainedPrivate} into the ordinary `SQ` branches instead of being
3341
4619
  * kept verbatim, so its items **are** walked and this rule **is** consulted
3342
4620
  * inside them. Read that as "the retention decision no longer decides the fate
3343
- * of the Data Sets below it", never as "the class is closed" - the residual
3344
- * list in `CLAUDE.md` is the census. That costs content
4621
+ * of the Data Sets below it", never as "the class is closed" - the open
4622
+ * residuals in `docs-content/limitations.md` are the census. That costs content
3345
4623
  * on a malformed file and nothing at all on a conformant one; without it a
3346
4624
  * private value the sender wrote **outside** the sequence was retained on the
3347
4625
  * Item's reservation and written into output stamped `PatientIdentityRemoved =
@@ -3374,8 +4652,33 @@ declare class DeidentifyError extends Error {
3374
4652
  * map. Everything in it is composed from static tables except `uidMap`, whose
3375
4653
  * keys are the source UIDs the file carried: treat that field as PHI.
3376
4654
  *
3377
- * @throws {@link DeidentifyError} (`INVALID_OPTIONS`) for an unknown Retain option
3378
- * or a malformed `uidRoot`.
4655
+ * The returned Data Set says what this run did to its **dates** as well as to its
4656
+ * identity: `(0028,0303) Longitudinal Temporal Information Modified` carries
4657
+ * `UNMODIFIED` when `RetainLongitudinalTemporal` was active, `MODIFIED` when
4658
+ * `RetainLongitudinalTemporalModifiedDates` was, and `REMOVED` under neither,
4659
+ * replacing any value the source carried at that tag. It is **not** on
4660
+ * {@link DeidentifyReport} - like `(0012,0062)`, it is a statement the object
4661
+ * makes about itself, and the report's shape is unchanged by it. 🩺 A
4662
+ * `MODIFIED` means the modified-dates column was resolved; **you** perform the
4663
+ * date transformation §E.3.6 describes, and `report.warnings` carries
4664
+ * `DICOM_DEIDENT_DATES_NOT_TRANSFORMED` saying so.
4665
+ *
4666
+ * **A JPIP Referenced object is refused, not de-identified.** When
4667
+ * `ds.fileMeta.transferSyntaxUID` is one of the four JPIP Referenced syntaxes
4668
+ * (PS3.5 2026c sections A.6, A.7, A.11 and A.12), the call throws before it
4669
+ * reads the options or the Data Set, because that object's Pixel Data Provider
4670
+ * URL `(0028,7FE0)` has no PS3.15 Table E.1-1 action and would otherwise be kept
4671
+ * by omission. The refusal keys on the File Meta alone: a non-JPIP object that
4672
+ * carries `(0028,7FE0)` is de-identified like any other, and keeps it.
4673
+ *
4674
+ * @throws {@link DeidentifyError} (`UNSUPPORTED_TRANSFER_SYNTAX`) for a `Dataset`
4675
+ * whose File Meta Transfer Syntax UID is one of the four JPIP Referenced
4676
+ * syntaxes, whatever the options; its message is a fixed string. Checked
4677
+ * first, so it is the code such a call gets even when its options are
4678
+ * invalid too.
4679
+ * @throws {@link DeidentifyError} (`INVALID_OPTIONS`) for an unknown Retain option,
4680
+ * for both PS3.15 §E.3.6 temporal Options in one call, or for a malformed
4681
+ * `uidRoot`.
3379
4682
  *
3380
4683
  * @example
3381
4684
  * ```ts
@@ -3451,4 +4754,90 @@ interface UidRemapper {
3451
4754
  */
3452
4755
  declare function makeUidRemapper(root?: string, cache?: Map<string, string>): UidRemapper;
3453
4756
 
3454
- export { type AppliedAction, CODING_SCHEME_OIDS, type CodedConcept, DEFAULT_UID_ROOT, DEIDENTIFY_ERROR_CODES, DEIDENTIFY_OPTIONS, Dataset, type DefineProfileOptions, type DeidentifiedAttribute, DeidentifyError, type DeidentifyErrorCode, type DeidentifyOption, type DeidentifyOptions, type DeidentifyReport, type DeidentifyResult, type DicomDate, type DicomDateTime, DicomParseError, type DicomParseWarning, type DicomPosition, DicomSerializeError, type DicomTime, type DicomValue, DicomValueError, index as Dictionary, Element, type EmbeddedAttributeFinding, FATAL_CODES, type FatalCode, type FileMeta, type FileMetaRawElement, type FrameFunctionalGroups, type ImageView, Item, OFFSET_FRAMES, type OffsetFrame, type OnWarningCallback, type OtherPatientId, type ParseOptions, type PatientView, type PersonName, type PersonNameGroup, type PrivateTagDefinition, type Profile, ProfileDefinitionError, type ProfilePrivateTags, type RealWorldValueMap, SERIALIZE_ERROR_CODES, Sequence, type SerializeErrorCode, type SeriesView, type StudyView, type UidRemapper, type UnauditableSequenceFinding, type UndefinedVrFinding, VALUE_ERROR_CODES, VERSION, type ValueErrorCode, WARNING_CODES, type WarningCode, codingSchemeOid, decodeElementValue, decodeText, defineProfile, deidentify, isKnownCharsetTerm, makeUidRemapper, parseDate, parseDateTime, parseDicom, parsePersonName, parseSpecificCharacterSet, parseTime, profiles, readCode, resolveDecoderLabel, serializeDicom };
4757
+ /**
4758
+ * PS3.16 2026d CID 7050 "De-identification Method", carried as pinned data, and
4759
+ * the map from what a `deidentify()` run activated to the code it records.
4760
+ *
4761
+ * PS3.15 2026c §E.1.1: "one or more codes from CID 7050 "De-identification
4762
+ * Method" corresponding to the Profile and Options used shall be added to
4763
+ * De-identification Method Code Sequence (0012,0064), and/or a text string
4764
+ * describing the method used shall be inserted in or added to De-identification
4765
+ * Method (0012,0063)." This package writes both halves: the text on
4766
+ * `(0012,0063)` and these codes on `(0012,0064)`.
4767
+ *
4768
+ * **A cited constant, not a vendored PS3.16 plus a generator.** The context group
4769
+ * is thirteen rows and its version, `20170914`, is unchanged between the PS3.15
4770
+ * 2026d edition this package vendors and the PS3.16 2026d page these rows are
4771
+ * quoted from (Type Extensible, UID `1.2.840.10008.6.1.925`, every row
4772
+ * designator `DCM`). The "Option" suffix is part of each Code Meaning.
4773
+ *
4774
+ * Two rows are carried and **never written**: `113101` Clean Pixel Data Option and
4775
+ * `113102` Clean Recognizable Visual Features Option. This is a metadata-only
4776
+ * de-identifier and cannot inspect pixels, which is why neither is a
4777
+ * {@link DeidentifyOption}.
4778
+ *
4779
+ * @module
4780
+ */
4781
+
4782
+ /**
4783
+ * One row of PS3.16 CID 7050 "De-identification Method": the three Basic Coded
4784
+ * Entry Attributes (PS3.3 2026d Table 8.8-1a) of one code, exactly as the context
4785
+ * group spells them.
4786
+ *
4787
+ * `deidentify()` writes each code it records as one Item of
4788
+ * `(0012,0064)` De-identification Method Code Sequence carrying Code Value
4789
+ * `(0008,0100)`, Coding Scheme Designator `(0008,0102)` and Code Meaning
4790
+ * `(0008,0104)`, and nothing else. Coding Scheme Version is omitted because PS3.3
4791
+ * requires it only where the designator is not sufficient to identify the code,
4792
+ * and `DCM` is.
4793
+ *
4794
+ * @example
4795
+ * ```ts
4796
+ * import { DEIDENTIFICATION_METHOD_CODES, type DeidentificationMethodCode } from "@cosyte/dicom";
4797
+ * const profile: DeidentificationMethodCode = DEIDENTIFICATION_METHOD_CODES.profile;
4798
+ * profile.codeValue; // "113100"
4799
+ * ```
4800
+ */
4801
+ interface DeidentificationMethodCode {
4802
+ /** Code Value `(0008,0100)`, a VR `SH` value. */
4803
+ readonly codeValue: string;
4804
+ /** Coding Scheme Designator `(0008,0102)`. Every CID 7050 row is `DCM`. */
4805
+ readonly codingSchemeDesignator: "DCM";
4806
+ /** Code Meaning `(0008,0104)`, a VR `LO` value, spelled as CID 7050 spells it. */
4807
+ readonly codeMeaning: string;
4808
+ }
4809
+ /**
4810
+ * PS3.16 2026d CID 7050 "De-identification Method" (context group version
4811
+ * `20170914`, UID `1.2.840.10008.6.1.925`), with the code `deidentify()` records
4812
+ * for the Profile and for each {@link DeidentifyOption}.
4813
+ *
4814
+ * - `rows` - all thirteen rows, in the context group's own order.
4815
+ * - `profile` - `113100` Basic Application Confidentiality Profile, written by
4816
+ * every run.
4817
+ * - `options` - the code written when that Option was active for the run. An
4818
+ * Option "ran" when it was active: the Annex E column it selects is resolved
4819
+ * off the option set, whether or not the object carried an attribute it acts
4820
+ * on. `113107` therefore says the modified-dates column was resolved, **not**
4821
+ * that any date was shifted; this library transforms no date, and every such
4822
+ * run carries `DICOM_DEIDENT_DATES_NOT_TRANSFORMED` on `report.warnings`.
4823
+ *
4824
+ * `113101` and `113102` are in `rows` and are never written: they name the two
4825
+ * pixel-level Options, which this metadata-only layer does not perform.
4826
+ *
4827
+ * @example
4828
+ * ```ts
4829
+ * import { DEIDENTIFICATION_METHOD_CODES } from "@cosyte/dicom";
4830
+ * DEIDENTIFICATION_METHOD_CODES.contextGroupUid; // "1.2.840.10008.6.1.925"
4831
+ * DEIDENTIFICATION_METHOD_CODES.options.RetainUIDs.codeValue; // "113110"
4832
+ * DEIDENTIFICATION_METHOD_CODES.rows.length; // 13
4833
+ * ```
4834
+ */
4835
+ declare const DEIDENTIFICATION_METHOD_CODES: {
4836
+ readonly contextGroupUid: "1.2.840.10008.6.1.925";
4837
+ readonly contextGroupVersion: "20170914";
4838
+ readonly rows: readonly DeidentificationMethodCode[];
4839
+ readonly profile: DeidentificationMethodCode;
4840
+ readonly options: Readonly<Record<DeidentifyOption, DeidentificationMethodCode>>;
4841
+ };
4842
+
4843
+ export { type AppliedAction, CODING_SCHEME_OIDS, type CodedConcept, DEFAULT_UID_ROOT, DEIDENTIFICATION_METHOD_CODES, DEIDENTIFY_ERROR_CODES, DEIDENTIFY_OPTIONS, Dataset, type DateParts, type DefineProfileOptions, type DeidentificationMethodCode, type DeidentifiedAttribute, DeidentifyError, type DeidentifyErrorCode, type DeidentifyOption, type DeidentifyOptions, type DeidentifyReport, type DeidentifyResult, type DicomDate, type DicomDateTime, type DicomDirectory, DicomParseError, type DicomParseWarning, type DicomPosition, DicomSerializeError, type DicomTime, type DicomValue, DicomValueError, index as Dictionary, type DirectoryRecord, Element, type EmbeddedAttributeFinding, FATAL_CODES, type FatalCode, type FileMeta, type FileMetaDroppedElement, type FileMetaRawElement, type FrameFunctionalGroups, type Group0004Removal, type ImageView, Item, OFFSET_FRAMES, type OffsetFrame, type OnWarningCallback, type OtherPatientId, type ParseOptions, type PatientView, type PersonName, type PersonNameGroup, type PixelDataFragments, type PrivateTagDefinition, type Profile, ProfileDefinitionError, type ProfilePrivateTags, type RealWorldValueMap, SERIALIZE_ERROR_CODES, Sequence, type SerializeErrorCode, type SeriesView, type StudyView, type ToDateOptions, type UidRemapper, type UnauditableSequenceFinding, type UndefinedVrFinding, type UnenumerablePrivateRemoval, type UnregisteredElementRemoval, VALUE_ERROR_CODES, VERSION, type ValueErrorCode, WARNING_CODES, type WarningCode, codingSchemeOid, decodeElementValue, decodeText, defineProfile, deidentify, isKnownCharsetTerm, makeUidRemapper, parseDate, parseDateTime, parseDicom, parsePersonName, parseSpecificCharacterSet, parseTime, profiles, readCode, readPixelDataFragments, resolveDecoderLabel, serializeDicom, toDate, toISO, toObject };