From 9a17b7ab1f13dd8df79c489c5e9e7b90b650bbce Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Wed, 7 Oct 2026 22:49:36 +0000 Subject: [PATCH 1/2] docs: describe the serializer and escalations as scoped - README: drops "The serializer always writes spec-clean Part 10." from the tolerance bullet; the Tier 0 row reads "No deviation detected"; escalations are "a stricter posture". - intro: the writer "emits a correct File Meta group length, even-length values and proper padding"; escalations are "a stricter posture". - profiles notes: escalations are "a stricter posture for a trusted sender". - tolerance notes: "the serializer is conservative". - serialization: the page is "Serializing Part 10"; the writer "applies the guarantees below" and "throws only with one of the codes below". - cookbook: escalation is "a stricter gate for a trusted sender". - troubleshooting: on DICOM_VR_MISMATCH the on-wire VR is used and the warning recorded. - limitations: the index row reads "Writing bytes back out". Checked on the published 0.1.0 from a clean install: a parsed synthetic object re-serializes with the preamble, DICM, an even length and no warnings on re-parse, and SERIALIZE_ERROR_CODES holds the five codes the serialization page lists. Claude-Session: https://claude.ai/code/session_011dC8vE9axM3hqC8rNEX3W9 --- README.md | 6 +++--- docs-content/cookbook.md | 2 +- docs-content/intro.md | 8 ++++---- docs-content/limitations.md | 2 +- docs-content/serialization.md | 12 ++++++------ docs-content/spec-notes-profiles.md | 2 +- docs-content/spec-notes-tolerance.md | 4 ++-- docs-content/troubleshooting.md | 2 +- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 58be78e..843b999 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Everything is exported from the single top-level entry point, `@cosyte/dicom`. T - **Two access patterns**: named views, or structural `ds.get("00100010")` by 8-character `(group,element)` tag (resolve a keyword to its tag with `Dictionary.byKeyword`), plus `ds.elements()` to walk everything. - **Compressed objects read, pixels untouched**: every PS3.5 2026c section A.4 encapsulation syntax (JPEG, JPEG-LS, JPEG 2000, HTJ2K, RLE and the rest) parses under Explicit VR LE rules, and `readPixelDataFragments(ds)` hands back the Basic Offset Table and every fragment as raw bytes. Pixels are never decoded. `serializeDicom` writes those syntaxes back, fragments byte for byte, and refuses with `INVALID_ENCAPSULATED_PIXEL_DATA` a top-level Pixel Data section A.4 does not allow. - **Lazy typed value decode**: `element.value` decodes raw bytes into a discriminated `DicomValue` across all 34 VRs (numbers, `bigint`s, person names, dates/times, sequences, raw `binary`), honoring `(0008,0005)` Specific Character Set through nested items. -- **Real-world tolerance, Postel's Law**: a lenient reader emits stable warning codes for what it tolerated; only 4 truly-structural conditions are fatal. The serializer always writes spec-clean Part 10. (The count used to be quoted here and had drifted: it read `25` against a `WARNING_CODES` of `28`. The locked snapshot in `test/property/__snapshots__/warning-codes.snapshot.test.ts.snap` is the pin, and it is measured on every run rather than narrated here.) +- **Real-world tolerance, Postel's Law**: a lenient reader emits stable warning codes for what it tolerated; only 4 truly-structural conditions are fatal. (The count used to be quoted here and had drifted: it read `25` against a `WARNING_CODES` of `28`. The locked snapshot in `test/property/__snapshots__/warning-codes.snapshot.test.ts.snap` is the pin, and it is measured on every run rather than narrated here.) - **Source/vendor profile system**: `defineProfile()` + 5 built-ins (`ge`, `siemens`, `philips`, `strict`, `lenient`) that only ever _tighten or annotate_ a parse, resolving vendor private tags by the file's live Private Creator string, never a wrong decode. - **Metadata-level de-identification**: `deidentify()` applies the PS3.15 Annex E Basic Profile + the metadata-affecting Options, returning a fresh dataset and an audit report built from static tables, with two fields that carry source bytes and are named as such on the type: `report.uidMap` (source UIDs) and `report.removedPrivateTags`. - **Spec-clean serializer**: `serializeDicom(ds)` round-trips a dataset back to Part 10 bytes in its source transfer syntax (no transcode; the four native syntaxes and every PS3.5 2026c section A.4 encapsulation syntax, whose top-level Pixel Data is written as an `OB` fragment stream with its Items byte for byte, or refused with `INVALID_ENCAPSULATED_PIXEL_DATA` when section A.4 does not allow it), with correct File Meta group length, even-length padding, every Data Set it can walk (the root and each Sequence Item, at every depth up to `NESTING_DEPTH_LIMIT`) emitted in ascending tag order with Items kept in order and no value changed, and lossless File Meta: non-modeled `(0002,xxxx)` elements are preserved and re-emitted in tag order. Not ordered: a tag repeated inside an Item (both copies kept), a Sequence that cannot be walked, is nested past the bound or whose parsed `items` do not match its bytes, and any `UN`-carried Sequence. Written last whatever its tag: an element whose own bytes do not show where a reader ends it, such as an undefined-length `UN` the parser could not read as a Sequence. @@ -306,7 +306,7 @@ At an RSNA-era interoperability test, ~80% of real-world patient CDs failed stri | Tier | Behavior | When | Example codes | | ---- | -------------- | ------------------------------ | ------------------------ | -| 0 | Silent | Spec-compliant input | none | +| 0 | Silent | No deviation detected | none | | 1 | Auto-handled | Trivial deviation, no warning | trailing-space tidy | | 2 | Warning | Recoverable deviation | `DICOM_MISSING_PREAMBLE` | | 3 | Fatal (always) | Unrecoverable structural error | `NOT_DICOM_PART_10` | @@ -346,7 +346,7 @@ const ds = parseDicom(buf, { profile: profiles.siemens }); A profile bundles three things that only ever **tighten or annotate** a parse, never loosen it past the lenient default: - **Private-dictionary overlay**: resolves the Implicit VR of vendor private data elements by the file's _live_ Private Creator string (e.g. `"SIEMENS CSA HEADER"`), keyed canonically as `"GGGGxxLL"` (PS3.5 §7.8.1), never a hard-coded block number. (This is why Agfa IMPAX re-assigning blocks still resolves.) An unknown creator degrades to `UN` plus a `DICOM_PRIVATE_CREATOR_UNKNOWN` warning. The lookup is scoped to one Data Set, and every Sequence Item is its own (PS3.5 §7.5.1, §7.8.1): a block claimed at the root does not resolve an element inside an item, and an element whose block was never claimed in its own Data Set reads `UN` plus `DICOM_PRIVATE_TAG_NO_CREATOR` rather than borrowing a neighbour's VR. Declare the creator in each item that writes private data. -- **Escalations**: Tier-2 warning codes promoted to a thrown `DicomParseError` (a stricter posture for known-unsafe deviations). +- **Escalations**: Tier-2 warning codes promoted to a thrown `DicomParseError` (a stricter posture). - **Suppressions**: benign, high-volume warning codes silenced for a known-quirky source. Five built-ins ship under the `profiles` namespace: `ge`, `siemens`, `philips` (vendor overlays, grounded in the public GDCM / dcm4che / dcm2niix dictionaries) and `strict` / `lenient` (posture presets). Build your own with `defineProfile()`. It validates input, composes via `extends`, and returns a frozen profile: diff --git a/docs-content/cookbook.md b/docs-content/cookbook.md index 35499d1..a96ed8d 100644 --- a/docs-content/cookbook.md +++ b/docs-content/cookbook.md @@ -712,7 +712,7 @@ ds.warnings.every((w) => typeof w.code === "string"); // => true ``` **Escalate when you want strictness.** A [source profile](./spec-notes-profiles) can promote chosen -warning codes to a thrown `DicomParseError` (a spec-conformance gate for a trusted sender) or +warning codes to a thrown `DicomParseError` (a stricter gate for a trusted sender) or suppress benign, high-volume codes for a known-quirky source. Note that `{ strict: true }` turns every Tier-2 warning into a `DicomParseError`, which carries the raw `snippet` the warning does not, so a PHI review of the lenient path does not transfer to the strict one. diff --git a/docs-content/intro.md b/docs-content/intro.md index 9d73c5f..c7ce4f9 100644 --- a/docs-content/intro.md +++ b/docs-content/intro.md @@ -145,8 +145,8 @@ if (img.isEnhancedMultiFrame) { The parser is **lenient by default**: the quirks real scanners emit (odd-length values, missing padding, off-spec VRs) become warnings carrying a stable code and the byte offset where they occurred, not failures. Only an unrecoverable structural condition throws, and `FATAL_CODES` is the -whole set of them. When you re-serialize, the writer always emits spec-clean Part 10: correct File -Meta group length, even-length values, proper padding (Postel's Law). See +whole set of them. When you re-serialize, the writer emits a correct File Meta group length, +even-length values and proper padding (Postel's Law). See [Serialization](./serialization) for what the writer will and will not do. ## Source profiles @@ -168,8 +168,8 @@ the lenient default: - **Private-dictionary overlay**: resolves the Implicit VR of vendor private data elements by the file's _live_ private-creator string (e.g. `"SIEMENS CSA HEADER"`), never a hard-coded block number. A creator the profile does not know degrades to `UN` plus a `DICOM_PRIVATE_CREATOR_UNKNOWN` warning. -- **Escalations**: Tier-2 warning codes promoted to a thrown `DicomParseError` (a stricter posture - for known-unsafe deviations). +- **Escalations**: Tier-2 warning codes promoted to a thrown `DicomParseError` (a stricter + posture). - **Suppressions**: benign, high-volume warning codes silenced for a known-quirky source. Five built-ins ship under the `profiles` namespace: `ge`, `siemens`, `philips` (vendor overlays) and diff --git a/docs-content/limitations.md b/docs-content/limitations.md index ca29550..e1ef796 100644 --- a/docs-content/limitations.md +++ b/docs-content/limitations.md @@ -400,7 +400,7 @@ than substituting a plausible value. `undefined` means the object did not carry | What a diagnostic carries and what it does not | [Tolerance](./spec-notes-tolerance) | | Keeping PHI out of logs | [Keeping PHI out of logs](./troubleshooting#keeping-phi-out-of-logs) | | The safety-critical views and their fail-safe rules | [Safety](./spec-notes-safety) | -| Writing spec-clean bytes back out, and what the writer will not do | [Serialization](./serialization) | +| Writing bytes back out, and what the writer will not do | [Serialization](./serialization) | | The de-identification surface and the scope limits on it | [De-identification](./deidentification) | | Working recipes, each citing the PS3 clause it reads | [Cookbook](./cookbook) | diff --git a/docs-content/serialization.md b/docs-content/serialization.md index bd8731c..e7e099c 100644 --- a/docs-content/serialization.md +++ b/docs-content/serialization.md @@ -1,14 +1,14 @@ --- id: serialization -title: Serializing spec-clean Part 10 +title: Serializing Part 10 sidebar_label: Serialization --- -# Serializing spec-clean Part 10 +# Serializing Part 10 `serializeDicom(ds)` writes a `Dataset` back to a Part 10 `Buffer`. It is the conservative half of -Postel's Law: the parser accepts what real scanners emit, and the writer emits only what the standard -describes. Whatever quirks came in, what goes out is spec-clean. +Postel's Law: the parser accepts what real scanners emit, and the writer applies the guarantees +below. Every DICOM object on this page is **synthetic** (an invented patient, obviously-fake UIDs) and encoded as a base64 buffer, so an example needs no file on disk. Never paste a real object into a @@ -89,8 +89,8 @@ serializeDicom(parseDicom(out)).equals(out); // => true ## When it throws, and what the error carries The writer has its own error taxonomy, separate from the parser's `FATAL_CODES` (which are locked to -read-side structural corruption) and from the value layer's `DicomValueError`. It throws only when it -is asked to emit a buffer it cannot make spec-clean. +read-side structural corruption) and from the value layer's `DicomValueError`. It throws only with +one of the codes below. | Export | What it is | | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs-content/spec-notes-profiles.md b/docs-content/spec-notes-profiles.md index 89eeade..6eddb7f 100644 --- a/docs-content/spec-notes-profiles.md +++ b/docs-content/spec-notes-profiles.md @@ -50,7 +50,7 @@ default: parses under the lenient default and throws under `strict`. - **Escalations**: chosen Tier-2 warning codes promoted to a thrown `DicomParseError`, a stricter - posture for known-unsafe deviations from a trusted sender. + posture for a trusted sender. - **Suppressions**: benign, high-volume warning codes silenced for a known-quirky source. ## The five built-ins diff --git a/docs-content/spec-notes-tolerance.md b/docs-content/spec-notes-tolerance.md index 45d74bc..b48794f 100644 --- a/docs-content/spec-notes-tolerance.md +++ b/docs-content/spec-notes-tolerance.md @@ -9,8 +9,8 @@ sidebar_label: Tolerance & warnings Real scanners and archives emit objects that deviate from the letter of the standard in documented, recoverable ways: odd-length values with no padding, a missing preamble, an off-spec VR, a group-length that disagrees with reality. `@cosyte/dicom` follows **Postel's Law**: the parser is -liberal (it recovers and records a stable-coded warning), and the serializer is conservative (it -always emits spec-clean Part 10). A recoverable quirk is **never** a silent change and never a throw. +liberal (it recovers and records a stable-coded warning), and the serializer is conservative. A +recoverable quirk is **never** a silent change and never a throw. ## Two tiers plus a small fatal set diff --git a/docs-content/troubleshooting.md b/docs-content/troubleshooting.md index 58a1ae3..42db44a 100644 --- a/docs-content/troubleshooting.md +++ b/docs-content/troubleshooting.md @@ -51,7 +51,7 @@ warning you triage, not an exception you catch. | `ds.get("PatientName")` is `undefined` | `get` takes the **tag** form, not a keyword | Use the tag (`ds.get("00100010")`), or resolve a keyword with `Dictionary.byKeyword("PatientName")?.tag`. | | `ds.image.rescaleSlope` is `undefined` | Rescale Slope was absent | This is by design. It is **not** defaulted to `1`. Apply a fallback deliberately in your own code if the modality warrants it. | | `ds.image.signed` is `undefined` | Pixel Representation `(0028,0103)` was absent | Signedness is unknown, never guessed. Do not assume unsigned. | -| A `DICOM_VR_MISMATCH` warning | The on-wire Explicit VR disagreed with the dictionary | The **on-wire** VR is used (Postel's Law: on the read path the sender's own declaration wins) and the deviation recorded; check the sender's encoding. | +| A `DICOM_VR_MISMATCH` warning | The on-wire Explicit VR disagreed with the dictionary | The **on-wire** VR is used (Postel's Law: on the read path the sender's own declaration wins) and the warning recorded. | | A `DICOM_PRIVATE_CREATOR_UNKNOWN` warning | A private tag's creator is not in the active profile | The element degrades to `UN`; add the creator via a [profile](./spec-notes-profiles) to resolve it. | | `ds.get(tag)?.value` is `{ kind: "binary" }` for Pixel Data | Pixel data is exposed raw, never decoded | Expected. Decoding pixels is out of scope (see below). | | A `DICOM_PIXEL_DATA_FRAGMENTS_NOT_DELIMITED` warning | Encapsulated Pixel Data `(7FE0,0010)` ran to the end of the input after a whole fragment, with no `(FFFE,E0DD)` Sequence Delimitation Item | The metadata is intact and every whole fragment read is on `readPixelDataFragments(ds)`, but **the fragment list may be short**: nothing in the stream says where the sender meant it to end, so do not treat the pixels as complete, and re-fetch the object. PS3.5 2026c section A.4 requires the delimiter, so a conformant file cannot raise this and `{ strict: true }` refuses it. The message carries no fragment byte, length or count; `position.byteOffset` is the Pixel Data element's own header. A fragment cut short mid-value is not this code: it is the fatal `INVALID_FILE_META`. | From 7ed53b3a31bf9ce470715efee98de5bfbadde9bd Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Wed, 7 Oct 2026 22:56:46 +0000 Subject: [PATCH 2/2] docs: word the serializer's throw rule without a count The docs-content structure test bans a literal count beside "codes". Claude-Session: https://claude.ai/code/session_011dC8vE9axM3hqC8rNEX3W9 --- docs-content/serialization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-content/serialization.md b/docs-content/serialization.md index e7e099c..91c5c4c 100644 --- a/docs-content/serialization.md +++ b/docs-content/serialization.md @@ -90,7 +90,7 @@ serializeDicom(parseDicom(out)).equals(out); // => true The writer has its own error taxonomy, separate from the parser's `FATAL_CODES` (which are locked to read-side structural corruption) and from the value layer's `DicomValueError`. It throws only with -one of the codes below. +a code listed below. | Export | What it is | | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |