@cosyte/dicom 0.0.6 → 0.0.8

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
@@ -415,6 +415,9 @@ declare const WARNING_CODES: {
415
415
  readonly DICOM_CHARSET_AMBIGUOUS_SEPARATOR: "DICOM_CHARSET_AMBIGUOUS_SEPARATOR";
416
416
  readonly DICOM_UNSUPPORTED_CHARSET: "DICOM_UNSUPPORTED_CHARSET";
417
417
  readonly DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED: "DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED";
418
+ readonly DICOM_DEIDENT_EMBEDDED_ATTRIBUTE_REMOVED: "DICOM_DEIDENT_EMBEDDED_ATTRIBUTE_REMOVED";
419
+ readonly DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE: "DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE";
420
+ readonly DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE: "DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE";
418
421
  readonly DICOM_PRIVATE_CREATOR_UNKNOWN: "DICOM_PRIVATE_CREATOR_UNKNOWN";
419
422
  };
420
423
  /**
@@ -2348,6 +2351,178 @@ interface DeidentifiedAttribute {
2348
2351
  */
2349
2352
  readonly repeatingGroup?: string;
2350
2353
  }
2354
+ /**
2355
+ * One value that was emptied because a Data Element was found **inside** it.
2356
+ *
2357
+ * PS3.5 defines Value Length as the length of that element's own Value Field. A
2358
+ * sender that over-declares it produces a file whose reading is self-consistent
2359
+ * and whose next element has been absorbed into the previous one's value - and
2360
+ * Table E.1-1 is keyed by tag, so an absorbed `(0010,0020)` is not an attribute
2361
+ * any longer and no action fires on it. `deidentify` therefore refuses to keep a
2362
+ * value whose tail decodes as whole Data Elements it would have acted on
2363
+ * (PS3.15 §E.1 "all instances"; §E.3.5 is the standard's own precedent for
2364
+ * removing identifying information embedded inside a string attribute).
2365
+ *
2366
+ * Every field is structural: `tag` and `vr` are the carrier's, and `hidden`
2367
+ * holds tags composed from four bytes each. No decoded value appears here, so
2368
+ * this is safe to log.
2369
+ *
2370
+ * @example
2371
+ * ```ts
2372
+ * import { deidentify, parseDicom } from "@cosyte/dicom";
2373
+ * const { report } = deidentify(parseDicom(buf));
2374
+ * for (const e of report.embeddedAttributes) {
2375
+ * console.warn(`${e.tag} hid ${e.hidden.join(", ")} in its value`);
2376
+ * }
2377
+ * ```
2378
+ */
2379
+ interface EmbeddedAttributeFinding {
2380
+ /** The carrier - the element whose over-declared value held the others. */
2381
+ readonly tag: Tag;
2382
+ /** The carrier's VR. Always one of the string VRs; binary VRs are not scanned. */
2383
+ readonly vr: VR;
2384
+ /** The tags of the Data Elements found inside the carrier's value, in wire order. */
2385
+ readonly hidden: readonly Tag[];
2386
+ /** Tag/index chain when the carrier is inside a sequence item; omitted at the root. */
2387
+ readonly contextPath?: readonly string[];
2388
+ }
2389
+ /**
2390
+ * One `SQ` element that was emptied because the parser never materialized its
2391
+ * items, so the de-identifier had no Data Sets to walk.
2392
+ *
2393
+ * PS3.5 2026c §7.5.1 "Item Encoding Rules" states that "Each Item Value shall
2394
+ * contain a DICOM Data Set composed of Data Elements", so an `SQ` element's
2395
+ * value is never opaque bytes - it is Data Elements this run is obliged to
2396
+ * reach. PS3.15 2026c §E.1.1 "De-identifier" states that obligation directly:
2397
+ * an implementation claiming the Basic Application Level Confidentiality
2398
+ * Profile "shall protect or retain all instances of the Attributes listed in
2399
+ * [Table E.1-1], whether contained in the top level Data Set or embedded in an
2400
+ * Item of a Sequence of Items". When the item stream cannot be enumerated the
2401
+ * obligation cannot be discharged element by element, so it falls on the
2402
+ * enclosing attribute - the escalation §E.1.1 itself uses for a SOP Instance
2403
+ * UID inside a Sequence, where "the enclosing Attribute in the top-level Data
2404
+ * Set must be encrypted in its entirety". (That sentence is written about the
2405
+ * encrypt-and-replace mechanism for SOP Instance UIDs, not about Table E.1-1
2406
+ * generally; it is cited here as the standard's own precedent for escalating to
2407
+ * the carrier, not as a rule about this case.)
2408
+ *
2409
+ * Both fields are structural: `tag` is the carrier's and `byteLength` is the
2410
+ * declared size of the value that was dropped. No decoded value appears here,
2411
+ * so this is safe to log.
2412
+ *
2413
+ * The parser always announces the underlying refusal first, on
2414
+ * `Dataset.warnings`: `DICOM_SQ_NOT_DESCENDED` for a defined-length Implicit VR
2415
+ * LE value whose dictionary-resolved `SQ` was not a valid item stream.
2416
+ *
2417
+ * @example
2418
+ * ```ts
2419
+ * import { deidentify, parseDicom } from "@cosyte/dicom";
2420
+ * const { report } = deidentify(parseDicom(buf));
2421
+ * for (const s of report.unauditableSequences) {
2422
+ * console.warn(`${s.tag}: ${String(s.byteLength)} bytes dropped, item stream unreadable`);
2423
+ * }
2424
+ * ```
2425
+ */
2426
+ interface UnauditableSequenceFinding {
2427
+ /** The `SQ` element that was emptied. */
2428
+ readonly tag: Tag;
2429
+ /** Byte length of the value field that was dropped. Structural, never a value. */
2430
+ readonly byteLength: number;
2431
+ /** Tag/index chain when the carrier is inside a sequence item; omitted at the root. */
2432
+ readonly contextPath?: readonly string[];
2433
+ }
2434
+ /**
2435
+ * One element that was emptied because its **on-wire VR is not one of the 34
2436
+ * PS3.5 section 6.2 defines**, so nothing this library did to its bytes counts
2437
+ * as decoding a Value Field.
2438
+ *
2439
+ * ## Why such an element exists at all
2440
+ *
2441
+ * Under an Explicit VR Transfer Syntax the VR is two bytes the sender wrote, and
2442
+ * this parser trusts them (Postel's Law on the read path). The routine way two
2443
+ * arbitrary bytes end up in a VR field is an **under**-declared Value Length
2444
+ * upstream: the reader finishes the short value, and the leftover bytes of the
2445
+ * value that was actually encoded are read as the next Data Element header. Tag,
2446
+ * VR and length are then all fragments of somebody's value, and the element that
2447
+ * genuinely followed is consumed as this fabricated element's "value".
2448
+ *
2449
+ * Measured on `scripts/measure-sq-bound-grid.ts`: a carrier under-declaring by 6
2450
+ * produces `(4156,554C)` with the VR bytes `"E "`, whose value holds the source
2451
+ * `(0010,0020)` Patient ID in full. It reaches **string** carriers exactly as it
2452
+ * reaches binary ones, because the carrier's own VR is not what decides it.
2453
+ *
2454
+ * ## Why emptying, and why it is not a guess
2455
+ *
2456
+ * PS3.5 2026c section 6.2 requires every VR not yet defined to use the long-form
2457
+ * Data Element Structure - "with reserved bytes after the VR and a 32-bit
2458
+ * unsigned integer VL" - so an unrecognized VR read short-form, which is what
2459
+ * this parser does, is by the standard's own structure rule not a reading of a
2460
+ * Value Field. There is nothing to prove about the content: the test is a
2461
+ * membership check against the closed 34-VR set on a field the parser already
2462
+ * recorded, so there is no scan, no per-offset loop, and no cost that follows an
2463
+ * attacker-chosen value length.
2464
+ *
2465
+ * PS3.15 2026c section E.1.1 obliges an implementation claiming the Basic
2466
+ * Application Level Confidentiality Profile to "protect or retain all instances
2467
+ * of the Attributes listed in [Table E.1-1]". Those instances cannot be reached
2468
+ * inside bytes that were never a value, so the obligation falls on the carrier.
2469
+ *
2470
+ * **`UN` is not this.** `UN` is one of the 34, so an ordinary unknown-VR element
2471
+ * - the Implicit VR fallback for a tag this build's dictionary does not publish,
2472
+ * and the CP-246 shape - never reaches here. That is the line the sibling
2473
+ * `SQ`-with-no-items rule could not draw.
2474
+ *
2475
+ * ## Why this finding names no tag, when every sibling finding does
2476
+ *
2477
+ * Because the tag **may be content**, and nothing here can tell. The paragraph
2478
+ * above is the whole argument: when an under-declare desynchronized the reader,
2479
+ * the four tag bytes and the two VR bytes were read out of the middle of some
2480
+ * element's Value Field, so reporting the "tag" would republish four bytes of
2481
+ * the document. An unrecognized VR written honestly, at a correct length, raises
2482
+ * this same code and has an ordinary tag - **and the two are indistinguishable
2483
+ * here**, so the tag is withheld on both routes rather than on a guess. Measured on a synthetic `ST` carrier holding
2484
+ * `"MR BRAIN SMITHSON"`, the fabricated tag is `48544F53` - four bytes of the
2485
+ * surname. {@link EmbeddedAttributeFinding} and
2486
+ * {@link UnauditableSequenceFinding} may carry a tag because theirs came from a
2487
+ * header the sender really wrote; this one may not, and the asymmetry is the
2488
+ * finding rather than an inconsistency.
2489
+ *
2490
+ * `byteOffset` locates the element instead - a position this parser counted.
2491
+ * Nothing here renders a document byte: an offset the parser counted, a decoded
2492
+ * length, and the structural `contextPath`. Safe to log.
2493
+ *
2494
+ * @example
2495
+ * ```ts
2496
+ * import { deidentify, parseDicom } from "@cosyte/dicom";
2497
+ * const { report } = deidentify(parseDicom(buf));
2498
+ * for (const u of report.undefinedVrElements) {
2499
+ * console.warn(`offset ${String(u.byteOffset)}: ${String(u.byteLength)} bytes dropped`);
2500
+ * }
2501
+ * ```
2502
+ */
2503
+ interface UndefinedVrFinding {
2504
+ /**
2505
+ * Byte offset of the emptied element's header. **This is how the element is
2506
+ * identified, and there is deliberately no `tag` field** - see the note above:
2507
+ * a fabricated header's tag bytes are part of some element's value.
2508
+ */
2509
+ readonly byteOffset: number;
2510
+ /**
2511
+ * Byte length of the value field that was dropped.
2512
+ *
2513
+ * **An input-derived count, and on this finding specifically that is not a
2514
+ * formality.** For the sibling findings the length came from a header the
2515
+ * sender wrote; here the two length bytes can themselves be value bytes, like
2516
+ * the tag bytes. What is published is the *number* they decode to, never the
2517
+ * bytes - the same footing as every `{n}` in the warning registry, which is
2518
+ * documented there as "an input-derived count". A number is not a rendering,
2519
+ * and the reach is at most a character or so, but do not describe this field
2520
+ * as "structural, never a value" the way its siblings are described.
2521
+ */
2522
+ readonly byteLength: number;
2523
+ /** Tag/index chain when the carrier is inside a sequence item; omitted at the root. */
2524
+ readonly contextPath?: readonly string[];
2525
+ }
2351
2526
  /**
2352
2527
  * The audit trail returned alongside the de-identified dataset.
2353
2528
  *
@@ -2372,6 +2547,69 @@ interface DeidentifyReport {
2372
2547
  readonly attributes: readonly DeidentifiedAttribute[];
2373
2548
  /** Private tags removed under the Basic Profile (kept ones are omitted). */
2374
2549
  readonly removedPrivateTags: readonly Tag[];
2550
+ /**
2551
+ * Values emptied because whole Data Elements were encoded inside them by an
2552
+ * over-declared Value Length. Empty on a well-formed file; a non-empty array
2553
+ * means the *source* was malformed in a way that hid attributes from the
2554
+ * action table, so treat it as a data-quality alarm on the sender as well as
2555
+ * an audit line. See {@link EmbeddedAttributeFinding}.
2556
+ */
2557
+ readonly embeddedAttributes: readonly EmbeddedAttributeFinding[];
2558
+ /**
2559
+ * `SQ` elements emptied because the parser did not materialize their items, so
2560
+ * the run had no Data Sets to walk and could not discharge PS3.15 §E.1.1's
2561
+ * obligation inside them. Empty on a well-formed file; a non-empty array means
2562
+ * content was dropped from the de-identified output, and the matching
2563
+ * `DICOM_SQ_NOT_DESCENDED` entry on `Dataset.warnings` says why the parse
2564
+ * refused. See {@link UnauditableSequenceFinding}.
2565
+ *
2566
+ * **Capped, and the cap is on the record only.** A crafted input can carry
2567
+ * tens of thousands of un-auditable elements, so this array (and its matching
2568
+ * warnings) stops at `MAX_UNAUDITABLE_SEQUENCE_FINDINGS`. Every un-auditable
2569
+ * sequence is still emptied; an array exactly that long means "at least this
2570
+ * many", so read it as truncated rather than as a total.
2571
+ *
2572
+ * **It is not a complete list of what went un-audited, either**: a private
2573
+ * `SQ` a {@link Profile} vouches for under `RetainSafePrivate` is kept
2574
+ * verbatim and never appears here.
2575
+ */
2576
+ readonly unauditableSequences: readonly UnauditableSequenceFinding[];
2577
+ /**
2578
+ * Elements emptied because their on-wire VR is not one of the 34 PS3.5 §6.2
2579
+ * defines, so their bytes are not a Value Field this library decoded and
2580
+ * PS3.15 §E.1.1's obligation over what is inside them could not be discharged.
2581
+ * Empty on a file conformant to **PS3.5 2026c**: a sender that writes one of
2582
+ * the 34 VRs that edition defines never produces one, and an Implicit VR LE
2583
+ * file **cannot** - there the VR comes from the dictionary. The edition is not
2584
+ * pedantry: §6.2 exists precisely to say how a *future* VR will be encoded, so
2585
+ * a file conformant to a later edition using a newly defined VR is the
2586
+ * population that sentence exists for. (What such a file does on this
2587
+ * library's parse path is not summarized here - it was measured and the
2588
+ * shapes disagree.) A non-empty array
2589
+ * means the source desynchronized the reader, usually by under-declaring a
2590
+ * Value Length somewhere earlier. See {@link UndefinedVrFinding}.
2591
+ *
2592
+ * **Capped, and the cap is on the record only**, exactly as
2593
+ * {@link DeidentifyReport.unauditableSequences} is: a crafted 1 MiB input can
2594
+ * carry over a hundred thousand such elements, so this array and its matching
2595
+ * warnings stop at `MAX_UNDEFINED_VR_FINDINGS`. Every one of them is still
2596
+ * emptied; an array exactly that long means "at least this many".
2597
+ *
2598
+ * **A finding here names a byte offset, not a tag** - uniquely among the
2599
+ * report's findings, and for a reason worth reading in
2600
+ * {@link UndefinedVrFinding}: the tag of a fabricated header is itself part of
2601
+ * some element's value.
2602
+ *
2603
+ * Unlike its sibling this list has **no carve-out**, and the reason is
2604
+ * structural rather than a promise: `keepOrEmpty` is the **only** path that
2605
+ * writes a source value into de-identified output unchanged, and the test sits
2606
+ * at the top of it. Every other outcome - `X` remove, `Z`/`C` empty, `D` dummy,
2607
+ * `U` remap, and a private tag the Basic Profile drops - already replaces the
2608
+ * value. So a `RetainSafePrivate` element a {@link Profile} vouches for still
2609
+ * reaches this test and is still emptied, which is where the sibling
2610
+ * `SQ`-with-no-items rule has a real carve-out and this one does not.
2611
+ */
2612
+ readonly undefinedVrElements: readonly UndefinedVrFinding[];
2375
2613
  /**
2376
2614
  * Source UID → replacement UID, for cross-file consistency. The **keys are
2377
2615
  * document values**, not composed identifiers: this is the one field of the
@@ -2529,15 +2767,43 @@ declare class DeidentifyError extends Error {
2529
2767
  * - A private data element kept under `RetainSafePrivate` is kept *verbatim* - if
2530
2768
  * it is itself a sequence carrying standard PHI attributes, that nested content
2531
2769
  * is not recursed. The profile vouches the element is safe; nest accordingly.
2532
- * - A sequence whose `items` the parser did not materialize is kept verbatim
2533
- * rather than recursed, so any nested listed attributes are not de-identified.
2534
- * That is now a **narrow and announced** set: an undefined-length `UN` value the
2535
- * CP-246 descent could not read as a sequence, and a defined-length Implicit VR
2536
- * LE value whose resolved `SQ` turned out not to be an item stream - the latter
2537
- * carrying `DICOM_SQ_NOT_DESCENDED` on `ds.warnings`. It used to include
2538
- * **every** defined-length sequence under Implicit VR LE, silently
2539
- * (`DICOM-IMPLICIT-SQ-NOT-DESCENDED`, fixed after `0.0.5`). Check
2540
- * `ds.warnings` before treating a report as complete.
2770
+ * - A **standard** (non-private) `SQ` whose `items` the parser did not
2771
+ * materialize is **emptied**, not kept:
2772
+ * its value is by PS3.5 §7.5.1 a stream of Data Sets, and a run that cannot
2773
+ * enumerate them cannot discharge §E.1.1's obligation inside them, so the
2774
+ * fail-safe answer is to drop the carrier and say so
2775
+ * (`DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` + `report.unauditableSequences`).
2776
+ * That costs content, and the trade is deliberate: it used to be *kept
2777
+ * verbatim*, which wrote the sender's own un-audited Data Elements - measured,
2778
+ * `(0010,0020)` Patient IDs among them - into output stamped
2779
+ * `(0012,0062) PatientIdentityRemoved = YES`
2780
+ * (`DICOM-DEIDENT-RAWBYTES-PASSTHROUGH`, live through `0.0.6`).
2781
+ * - Two things are still kept verbatim, deliberately. A **private** `SQ`
2782
+ * retained under `RetainSafePrivate` + a {@link Profile}, where the profile has
2783
+ * vouched for the element by creator and tag - the pre-existing "kept
2784
+ * verbatim" limitation above, unchanged. And an undefined-length **`UN`** whose
2785
+ * CP-246 descent was refused: it keeps `vr === "UN"`, and since every ordinary
2786
+ * `UN` element also has no items, the test above cannot be applied there
2787
+ * without emptying every unknown-VR element in every file. That one is
2788
+ * measured and **still leaks** (`PRE-EXISTING`); the reliable consumer-side
2789
+ * test remains `el.items === undefined` on a `UN` element you are trusting a
2790
+ * report about.
2791
+ * - An element whose **on-wire VR is not one of the 34** PS3.5 §6.2 defines is
2792
+ * **emptied**, not kept (`DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` +
2793
+ * `report.undefinedVrElements`). Such an element is what an *under*-declared
2794
+ * Value Length upstream produces: the reader desynchronizes, reads leftover
2795
+ * value bytes as a Data Element header, and the element that genuinely
2796
+ * followed becomes its "value" - measured carrying a `(0010,0020)` Patient ID
2797
+ * in full. §6.2 requires every undefined VR to be long-form, and this parser
2798
+ * reads it short-form, so those bytes are not a Value Field this library
2799
+ * decoded under any VR. See {@link hasUndefinedVr}. **Unlike the `SQ` rule
2800
+ * above this one has no carve-out** - it sits in `keepOrEmpty`, the only path
2801
+ * that keeps a value verbatim, so `RetainSafePrivate` does not exempt it.
2802
+ * - **Still leaking, measured, and its own decision:** the over-declare swallow
2803
+ * into a **binary** carrier (`OB`/`OW`/`US`/`UN`), 11 grid cells at
2804
+ * `35adc2d`. No content test can decide it - arbitrary bytes are what those
2805
+ * VRs are for - and the one candidate remedy empties conformant binary values.
2806
+ * See `./embedded.ts`.
2541
2807
  *
2542
2808
  * @module
2543
2809
  */
@@ -2628,4 +2894,4 @@ interface UidRemapper {
2628
2894
  */
2629
2895
  declare function makeUidRemapper(root?: string, cache?: Map<string, string>): UidRemapper;
2630
2896
 
2631
- 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, FATAL_CODES, type FatalCode, type FileMeta, type FileMetaRawElement, type FrameFunctionalGroups, type ImageView, Item, 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, 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 };
2897
+ 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, 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 };
package/dist/index.d.ts CHANGED
@@ -415,6 +415,9 @@ declare const WARNING_CODES: {
415
415
  readonly DICOM_CHARSET_AMBIGUOUS_SEPARATOR: "DICOM_CHARSET_AMBIGUOUS_SEPARATOR";
416
416
  readonly DICOM_UNSUPPORTED_CHARSET: "DICOM_UNSUPPORTED_CHARSET";
417
417
  readonly DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED: "DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED";
418
+ readonly DICOM_DEIDENT_EMBEDDED_ATTRIBUTE_REMOVED: "DICOM_DEIDENT_EMBEDDED_ATTRIBUTE_REMOVED";
419
+ readonly DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE: "DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE";
420
+ readonly DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE: "DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE";
418
421
  readonly DICOM_PRIVATE_CREATOR_UNKNOWN: "DICOM_PRIVATE_CREATOR_UNKNOWN";
419
422
  };
420
423
  /**
@@ -2348,6 +2351,178 @@ interface DeidentifiedAttribute {
2348
2351
  */
2349
2352
  readonly repeatingGroup?: string;
2350
2353
  }
2354
+ /**
2355
+ * One value that was emptied because a Data Element was found **inside** it.
2356
+ *
2357
+ * PS3.5 defines Value Length as the length of that element's own Value Field. A
2358
+ * sender that over-declares it produces a file whose reading is self-consistent
2359
+ * and whose next element has been absorbed into the previous one's value - and
2360
+ * Table E.1-1 is keyed by tag, so an absorbed `(0010,0020)` is not an attribute
2361
+ * any longer and no action fires on it. `deidentify` therefore refuses to keep a
2362
+ * value whose tail decodes as whole Data Elements it would have acted on
2363
+ * (PS3.15 §E.1 "all instances"; §E.3.5 is the standard's own precedent for
2364
+ * removing identifying information embedded inside a string attribute).
2365
+ *
2366
+ * Every field is structural: `tag` and `vr` are the carrier's, and `hidden`
2367
+ * holds tags composed from four bytes each. No decoded value appears here, so
2368
+ * this is safe to log.
2369
+ *
2370
+ * @example
2371
+ * ```ts
2372
+ * import { deidentify, parseDicom } from "@cosyte/dicom";
2373
+ * const { report } = deidentify(parseDicom(buf));
2374
+ * for (const e of report.embeddedAttributes) {
2375
+ * console.warn(`${e.tag} hid ${e.hidden.join(", ")} in its value`);
2376
+ * }
2377
+ * ```
2378
+ */
2379
+ interface EmbeddedAttributeFinding {
2380
+ /** The carrier - the element whose over-declared value held the others. */
2381
+ readonly tag: Tag;
2382
+ /** The carrier's VR. Always one of the string VRs; binary VRs are not scanned. */
2383
+ readonly vr: VR;
2384
+ /** The tags of the Data Elements found inside the carrier's value, in wire order. */
2385
+ readonly hidden: readonly Tag[];
2386
+ /** Tag/index chain when the carrier is inside a sequence item; omitted at the root. */
2387
+ readonly contextPath?: readonly string[];
2388
+ }
2389
+ /**
2390
+ * One `SQ` element that was emptied because the parser never materialized its
2391
+ * items, so the de-identifier had no Data Sets to walk.
2392
+ *
2393
+ * PS3.5 2026c §7.5.1 "Item Encoding Rules" states that "Each Item Value shall
2394
+ * contain a DICOM Data Set composed of Data Elements", so an `SQ` element's
2395
+ * value is never opaque bytes - it is Data Elements this run is obliged to
2396
+ * reach. PS3.15 2026c §E.1.1 "De-identifier" states that obligation directly:
2397
+ * an implementation claiming the Basic Application Level Confidentiality
2398
+ * Profile "shall protect or retain all instances of the Attributes listed in
2399
+ * [Table E.1-1], whether contained in the top level Data Set or embedded in an
2400
+ * Item of a Sequence of Items". When the item stream cannot be enumerated the
2401
+ * obligation cannot be discharged element by element, so it falls on the
2402
+ * enclosing attribute - the escalation §E.1.1 itself uses for a SOP Instance
2403
+ * UID inside a Sequence, where "the enclosing Attribute in the top-level Data
2404
+ * Set must be encrypted in its entirety". (That sentence is written about the
2405
+ * encrypt-and-replace mechanism for SOP Instance UIDs, not about Table E.1-1
2406
+ * generally; it is cited here as the standard's own precedent for escalating to
2407
+ * the carrier, not as a rule about this case.)
2408
+ *
2409
+ * Both fields are structural: `tag` is the carrier's and `byteLength` is the
2410
+ * declared size of the value that was dropped. No decoded value appears here,
2411
+ * so this is safe to log.
2412
+ *
2413
+ * The parser always announces the underlying refusal first, on
2414
+ * `Dataset.warnings`: `DICOM_SQ_NOT_DESCENDED` for a defined-length Implicit VR
2415
+ * LE value whose dictionary-resolved `SQ` was not a valid item stream.
2416
+ *
2417
+ * @example
2418
+ * ```ts
2419
+ * import { deidentify, parseDicom } from "@cosyte/dicom";
2420
+ * const { report } = deidentify(parseDicom(buf));
2421
+ * for (const s of report.unauditableSequences) {
2422
+ * console.warn(`${s.tag}: ${String(s.byteLength)} bytes dropped, item stream unreadable`);
2423
+ * }
2424
+ * ```
2425
+ */
2426
+ interface UnauditableSequenceFinding {
2427
+ /** The `SQ` element that was emptied. */
2428
+ readonly tag: Tag;
2429
+ /** Byte length of the value field that was dropped. Structural, never a value. */
2430
+ readonly byteLength: number;
2431
+ /** Tag/index chain when the carrier is inside a sequence item; omitted at the root. */
2432
+ readonly contextPath?: readonly string[];
2433
+ }
2434
+ /**
2435
+ * One element that was emptied because its **on-wire VR is not one of the 34
2436
+ * PS3.5 section 6.2 defines**, so nothing this library did to its bytes counts
2437
+ * as decoding a Value Field.
2438
+ *
2439
+ * ## Why such an element exists at all
2440
+ *
2441
+ * Under an Explicit VR Transfer Syntax the VR is two bytes the sender wrote, and
2442
+ * this parser trusts them (Postel's Law on the read path). The routine way two
2443
+ * arbitrary bytes end up in a VR field is an **under**-declared Value Length
2444
+ * upstream: the reader finishes the short value, and the leftover bytes of the
2445
+ * value that was actually encoded are read as the next Data Element header. Tag,
2446
+ * VR and length are then all fragments of somebody's value, and the element that
2447
+ * genuinely followed is consumed as this fabricated element's "value".
2448
+ *
2449
+ * Measured on `scripts/measure-sq-bound-grid.ts`: a carrier under-declaring by 6
2450
+ * produces `(4156,554C)` with the VR bytes `"E "`, whose value holds the source
2451
+ * `(0010,0020)` Patient ID in full. It reaches **string** carriers exactly as it
2452
+ * reaches binary ones, because the carrier's own VR is not what decides it.
2453
+ *
2454
+ * ## Why emptying, and why it is not a guess
2455
+ *
2456
+ * PS3.5 2026c section 6.2 requires every VR not yet defined to use the long-form
2457
+ * Data Element Structure - "with reserved bytes after the VR and a 32-bit
2458
+ * unsigned integer VL" - so an unrecognized VR read short-form, which is what
2459
+ * this parser does, is by the standard's own structure rule not a reading of a
2460
+ * Value Field. There is nothing to prove about the content: the test is a
2461
+ * membership check against the closed 34-VR set on a field the parser already
2462
+ * recorded, so there is no scan, no per-offset loop, and no cost that follows an
2463
+ * attacker-chosen value length.
2464
+ *
2465
+ * PS3.15 2026c section E.1.1 obliges an implementation claiming the Basic
2466
+ * Application Level Confidentiality Profile to "protect or retain all instances
2467
+ * of the Attributes listed in [Table E.1-1]". Those instances cannot be reached
2468
+ * inside bytes that were never a value, so the obligation falls on the carrier.
2469
+ *
2470
+ * **`UN` is not this.** `UN` is one of the 34, so an ordinary unknown-VR element
2471
+ * - the Implicit VR fallback for a tag this build's dictionary does not publish,
2472
+ * and the CP-246 shape - never reaches here. That is the line the sibling
2473
+ * `SQ`-with-no-items rule could not draw.
2474
+ *
2475
+ * ## Why this finding names no tag, when every sibling finding does
2476
+ *
2477
+ * Because the tag **may be content**, and nothing here can tell. The paragraph
2478
+ * above is the whole argument: when an under-declare desynchronized the reader,
2479
+ * the four tag bytes and the two VR bytes were read out of the middle of some
2480
+ * element's Value Field, so reporting the "tag" would republish four bytes of
2481
+ * the document. An unrecognized VR written honestly, at a correct length, raises
2482
+ * this same code and has an ordinary tag - **and the two are indistinguishable
2483
+ * here**, so the tag is withheld on both routes rather than on a guess. Measured on a synthetic `ST` carrier holding
2484
+ * `"MR BRAIN SMITHSON"`, the fabricated tag is `48544F53` - four bytes of the
2485
+ * surname. {@link EmbeddedAttributeFinding} and
2486
+ * {@link UnauditableSequenceFinding} may carry a tag because theirs came from a
2487
+ * header the sender really wrote; this one may not, and the asymmetry is the
2488
+ * finding rather than an inconsistency.
2489
+ *
2490
+ * `byteOffset` locates the element instead - a position this parser counted.
2491
+ * Nothing here renders a document byte: an offset the parser counted, a decoded
2492
+ * length, and the structural `contextPath`. Safe to log.
2493
+ *
2494
+ * @example
2495
+ * ```ts
2496
+ * import { deidentify, parseDicom } from "@cosyte/dicom";
2497
+ * const { report } = deidentify(parseDicom(buf));
2498
+ * for (const u of report.undefinedVrElements) {
2499
+ * console.warn(`offset ${String(u.byteOffset)}: ${String(u.byteLength)} bytes dropped`);
2500
+ * }
2501
+ * ```
2502
+ */
2503
+ interface UndefinedVrFinding {
2504
+ /**
2505
+ * Byte offset of the emptied element's header. **This is how the element is
2506
+ * identified, and there is deliberately no `tag` field** - see the note above:
2507
+ * a fabricated header's tag bytes are part of some element's value.
2508
+ */
2509
+ readonly byteOffset: number;
2510
+ /**
2511
+ * Byte length of the value field that was dropped.
2512
+ *
2513
+ * **An input-derived count, and on this finding specifically that is not a
2514
+ * formality.** For the sibling findings the length came from a header the
2515
+ * sender wrote; here the two length bytes can themselves be value bytes, like
2516
+ * the tag bytes. What is published is the *number* they decode to, never the
2517
+ * bytes - the same footing as every `{n}` in the warning registry, which is
2518
+ * documented there as "an input-derived count". A number is not a rendering,
2519
+ * and the reach is at most a character or so, but do not describe this field
2520
+ * as "structural, never a value" the way its siblings are described.
2521
+ */
2522
+ readonly byteLength: number;
2523
+ /** Tag/index chain when the carrier is inside a sequence item; omitted at the root. */
2524
+ readonly contextPath?: readonly string[];
2525
+ }
2351
2526
  /**
2352
2527
  * The audit trail returned alongside the de-identified dataset.
2353
2528
  *
@@ -2372,6 +2547,69 @@ interface DeidentifyReport {
2372
2547
  readonly attributes: readonly DeidentifiedAttribute[];
2373
2548
  /** Private tags removed under the Basic Profile (kept ones are omitted). */
2374
2549
  readonly removedPrivateTags: readonly Tag[];
2550
+ /**
2551
+ * Values emptied because whole Data Elements were encoded inside them by an
2552
+ * over-declared Value Length. Empty on a well-formed file; a non-empty array
2553
+ * means the *source* was malformed in a way that hid attributes from the
2554
+ * action table, so treat it as a data-quality alarm on the sender as well as
2555
+ * an audit line. See {@link EmbeddedAttributeFinding}.
2556
+ */
2557
+ readonly embeddedAttributes: readonly EmbeddedAttributeFinding[];
2558
+ /**
2559
+ * `SQ` elements emptied because the parser did not materialize their items, so
2560
+ * the run had no Data Sets to walk and could not discharge PS3.15 §E.1.1's
2561
+ * obligation inside them. Empty on a well-formed file; a non-empty array means
2562
+ * content was dropped from the de-identified output, and the matching
2563
+ * `DICOM_SQ_NOT_DESCENDED` entry on `Dataset.warnings` says why the parse
2564
+ * refused. See {@link UnauditableSequenceFinding}.
2565
+ *
2566
+ * **Capped, and the cap is on the record only.** A crafted input can carry
2567
+ * tens of thousands of un-auditable elements, so this array (and its matching
2568
+ * warnings) stops at `MAX_UNAUDITABLE_SEQUENCE_FINDINGS`. Every un-auditable
2569
+ * sequence is still emptied; an array exactly that long means "at least this
2570
+ * many", so read it as truncated rather than as a total.
2571
+ *
2572
+ * **It is not a complete list of what went un-audited, either**: a private
2573
+ * `SQ` a {@link Profile} vouches for under `RetainSafePrivate` is kept
2574
+ * verbatim and never appears here.
2575
+ */
2576
+ readonly unauditableSequences: readonly UnauditableSequenceFinding[];
2577
+ /**
2578
+ * Elements emptied because their on-wire VR is not one of the 34 PS3.5 §6.2
2579
+ * defines, so their bytes are not a Value Field this library decoded and
2580
+ * PS3.15 §E.1.1's obligation over what is inside them could not be discharged.
2581
+ * Empty on a file conformant to **PS3.5 2026c**: a sender that writes one of
2582
+ * the 34 VRs that edition defines never produces one, and an Implicit VR LE
2583
+ * file **cannot** - there the VR comes from the dictionary. The edition is not
2584
+ * pedantry: §6.2 exists precisely to say how a *future* VR will be encoded, so
2585
+ * a file conformant to a later edition using a newly defined VR is the
2586
+ * population that sentence exists for. (What such a file does on this
2587
+ * library's parse path is not summarized here - it was measured and the
2588
+ * shapes disagree.) A non-empty array
2589
+ * means the source desynchronized the reader, usually by under-declaring a
2590
+ * Value Length somewhere earlier. See {@link UndefinedVrFinding}.
2591
+ *
2592
+ * **Capped, and the cap is on the record only**, exactly as
2593
+ * {@link DeidentifyReport.unauditableSequences} is: a crafted 1 MiB input can
2594
+ * carry over a hundred thousand such elements, so this array and its matching
2595
+ * warnings stop at `MAX_UNDEFINED_VR_FINDINGS`. Every one of them is still
2596
+ * emptied; an array exactly that long means "at least this many".
2597
+ *
2598
+ * **A finding here names a byte offset, not a tag** - uniquely among the
2599
+ * report's findings, and for a reason worth reading in
2600
+ * {@link UndefinedVrFinding}: the tag of a fabricated header is itself part of
2601
+ * some element's value.
2602
+ *
2603
+ * Unlike its sibling this list has **no carve-out**, and the reason is
2604
+ * structural rather than a promise: `keepOrEmpty` is the **only** path that
2605
+ * writes a source value into de-identified output unchanged, and the test sits
2606
+ * at the top of it. Every other outcome - `X` remove, `Z`/`C` empty, `D` dummy,
2607
+ * `U` remap, and a private tag the Basic Profile drops - already replaces the
2608
+ * value. So a `RetainSafePrivate` element a {@link Profile} vouches for still
2609
+ * reaches this test and is still emptied, which is where the sibling
2610
+ * `SQ`-with-no-items rule has a real carve-out and this one does not.
2611
+ */
2612
+ readonly undefinedVrElements: readonly UndefinedVrFinding[];
2375
2613
  /**
2376
2614
  * Source UID → replacement UID, for cross-file consistency. The **keys are
2377
2615
  * document values**, not composed identifiers: this is the one field of the
@@ -2529,15 +2767,43 @@ declare class DeidentifyError extends Error {
2529
2767
  * - A private data element kept under `RetainSafePrivate` is kept *verbatim* - if
2530
2768
  * it is itself a sequence carrying standard PHI attributes, that nested content
2531
2769
  * is not recursed. The profile vouches the element is safe; nest accordingly.
2532
- * - A sequence whose `items` the parser did not materialize is kept verbatim
2533
- * rather than recursed, so any nested listed attributes are not de-identified.
2534
- * That is now a **narrow and announced** set: an undefined-length `UN` value the
2535
- * CP-246 descent could not read as a sequence, and a defined-length Implicit VR
2536
- * LE value whose resolved `SQ` turned out not to be an item stream - the latter
2537
- * carrying `DICOM_SQ_NOT_DESCENDED` on `ds.warnings`. It used to include
2538
- * **every** defined-length sequence under Implicit VR LE, silently
2539
- * (`DICOM-IMPLICIT-SQ-NOT-DESCENDED`, fixed after `0.0.5`). Check
2540
- * `ds.warnings` before treating a report as complete.
2770
+ * - A **standard** (non-private) `SQ` whose `items` the parser did not
2771
+ * materialize is **emptied**, not kept:
2772
+ * its value is by PS3.5 §7.5.1 a stream of Data Sets, and a run that cannot
2773
+ * enumerate them cannot discharge §E.1.1's obligation inside them, so the
2774
+ * fail-safe answer is to drop the carrier and say so
2775
+ * (`DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` + `report.unauditableSequences`).
2776
+ * That costs content, and the trade is deliberate: it used to be *kept
2777
+ * verbatim*, which wrote the sender's own un-audited Data Elements - measured,
2778
+ * `(0010,0020)` Patient IDs among them - into output stamped
2779
+ * `(0012,0062) PatientIdentityRemoved = YES`
2780
+ * (`DICOM-DEIDENT-RAWBYTES-PASSTHROUGH`, live through `0.0.6`).
2781
+ * - Two things are still kept verbatim, deliberately. A **private** `SQ`
2782
+ * retained under `RetainSafePrivate` + a {@link Profile}, where the profile has
2783
+ * vouched for the element by creator and tag - the pre-existing "kept
2784
+ * verbatim" limitation above, unchanged. And an undefined-length **`UN`** whose
2785
+ * CP-246 descent was refused: it keeps `vr === "UN"`, and since every ordinary
2786
+ * `UN` element also has no items, the test above cannot be applied there
2787
+ * without emptying every unknown-VR element in every file. That one is
2788
+ * measured and **still leaks** (`PRE-EXISTING`); the reliable consumer-side
2789
+ * test remains `el.items === undefined` on a `UN` element you are trusting a
2790
+ * report about.
2791
+ * - An element whose **on-wire VR is not one of the 34** PS3.5 §6.2 defines is
2792
+ * **emptied**, not kept (`DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` +
2793
+ * `report.undefinedVrElements`). Such an element is what an *under*-declared
2794
+ * Value Length upstream produces: the reader desynchronizes, reads leftover
2795
+ * value bytes as a Data Element header, and the element that genuinely
2796
+ * followed becomes its "value" - measured carrying a `(0010,0020)` Patient ID
2797
+ * in full. §6.2 requires every undefined VR to be long-form, and this parser
2798
+ * reads it short-form, so those bytes are not a Value Field this library
2799
+ * decoded under any VR. See {@link hasUndefinedVr}. **Unlike the `SQ` rule
2800
+ * above this one has no carve-out** - it sits in `keepOrEmpty`, the only path
2801
+ * that keeps a value verbatim, so `RetainSafePrivate` does not exempt it.
2802
+ * - **Still leaking, measured, and its own decision:** the over-declare swallow
2803
+ * into a **binary** carrier (`OB`/`OW`/`US`/`UN`), 11 grid cells at
2804
+ * `35adc2d`. No content test can decide it - arbitrary bytes are what those
2805
+ * VRs are for - and the one candidate remedy empties conformant binary values.
2806
+ * See `./embedded.ts`.
2541
2807
  *
2542
2808
  * @module
2543
2809
  */
@@ -2628,4 +2894,4 @@ interface UidRemapper {
2628
2894
  */
2629
2895
  declare function makeUidRemapper(root?: string, cache?: Map<string, string>): UidRemapper;
2630
2896
 
2631
- 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, FATAL_CODES, type FatalCode, type FileMeta, type FileMetaRawElement, type FrameFunctionalGroups, type ImageView, Item, 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, 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 };
2897
+ 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, 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 };