Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/first-minor-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@cosyte/dicom": minor
---

**This is 0.1.0, the first release whose public API we treat as settled.**

What is covered, and what you can build against:

- Reading DICOM Part 10 metadata: the four typed views (`patient`, `study`, `series`, `image`),
access to any element by tag with the Part 6 data dictionary compiled in, and lazy typed decode of
all 34 value representations, honoring Specific Character Set.
- The four native transfer syntaxes and every PS3.5 2026c section A.4 encapsulation syntax are read;
encapsulated pixel data comes back as raw fragments, and a DICOMDIR reads as a record tree.
- Lenient parsing: only four structural conditions are fatal, and every tolerated deviation is a
stable warning code with a byte offset. Vendor profiles (`ge`, `siemens`, `philips`, `strict`,
`lenient`, or your own through `defineProfile`) only ever tighten or annotate a parse.
- A spec-clean serializer that writes the source transfer syntax back, and metadata
de-identification with the PS3.15 Annex E Basic Profile and the metadata-affecting options,
returning a fresh dataset and an audit report.

What the version promises. The exported names, options, return shapes and warning codes are the
surface we keep stable. While the package is below 1.0, a breaking change bumps the minor version
(0.1 to 0.2) and is called out in this changelog with its migration; a fix that changes no public
value ships as a patch.

What is not covered yet. Pixel data is never decoded, in any transfer syntax. A de-identified output
is metadata-de-identified only: burned-in annotation is warned about, not removed, and the
residuals the README lists under known limitations are disclosed rather than closed. There is no
transcoding between transfer syntaxes, no DIMSE networking and no DICOMweb, and coded values are
surfaced but not validated against a terminology.
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,25 @@ jobs:
uses: cosyte/.github/.github/workflows/ci.yml@main
with:
run-phi-scan: true

# The runnable examples under examples/, checked the way a consumer meets them: each imports
# `@cosyte/dicom` by name, which resolves through package.json `exports` to the BUILT dist/, so
# the build runs first. Each example asserts its own output, so `pnpm examples` goes red when one
# drifts from the package. The PHI scan's default roots (test/, README.md, docs-content/) do not
# reach examples/, so the example files are named to it here. Actions are pinned to the commits
# the tags dictionary-regen.yml uses resolve to.
examples:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: "22"
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm typecheck:examples
- run: pnpm lint:examples
- run: pnpm examples
- run: pnpm phi-scan:examples
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,23 @@ pnpm add @cosyte/dicom
Useful output after install and parse. No DICOM spec knowledge required.

```ts runnable
import { readFile } from "node:fs/promises";
import { parseDicom } from "@cosyte/dicom";

// Here study.dcm is a synthetic CT object saved without its 128-byte preamble.
const ds = parseDicom(await readFile("study.dcm"));
// A synthetic CT object, saved without its 128-byte preamble: an invented patient and example-root
// UIDs, no real PHI. In your integration these are the bytes of the file you read.
const buf = Buffer.from(
"AgAAAFVMBAAcAAAAAgAQAFVJFAAxLjIuODQwLjEwMDA4LjEuMi4xAAgAFgBVSRoAMS4yLjg0MC4xMDAwOC41LjEuNC4xLjEuMgAIABgAVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMTExAAgAIABEQQgAMTkwMDAxMDEIAGAAQ1MCAENUEAAQAFBOCABEb2VeSmFuZRAAIABMTwYATVJOLTQyEAAhAExPDABTQU1QTEUtSE9TUCAgAA0AVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMS4xACAADgBVSR4AMS4yLjgyNi4wLjEuMzY4MDA0My44LjQ5OC4xLjIAIAARAElTAgAyICgAEABVUwIAAAIoABEAVVMCAAACKAAAAVVTAgAQACgAAwFVUwIAAQAoAFIQRFMGAC0xMDI0ICgAUxBEUwIAMSAoADAARFMIADAuNVwwLjUg",
"base64",
);
const ds = parseDicom(buf);

console.log("patient", ds.patient.id, "issuer", ds.patient.issuerOfId);
console.log("study", ds.study.instanceUid);
console.log("series", ds.series.modality, "image", ds.image.rows, "x", ds.image.columns);
console.log("rescale slope", ds.image.rescaleSlope, "intercept", ds.image.rescaleIntercept);
console.log("tolerated", ds.warnings.length, ds.warnings[0]?.code);

// The values printed above, which the test suite asserts on every run.
// NOT globally unique on its own: pair it with ds.patient.issuerOfId.
ds.patient.id; // => "MRN-42"
// The global study anchor.
Expand All @@ -76,6 +87,16 @@ ds.image.rescaleSlope; // => 1
ds.warnings.map((w) => w.code); // => ["DICOM_MISSING_PREAMBLE"]
```

It prints:

```text
patient MRN-42 issuer SAMPLE-HOSP
study 1.2.826.0.1.3680043.8.498.1.1
series CT image 512 x 512
rescale slope 1 intercept -1024
tolerated 1 DICOM_MISSING_PREAMBLE
```

The structural route, and emitting bytes back out:

```ts
Expand Down
29 changes: 29 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Examples

Small runnable programs, one per job the package does. Each one imports `@cosyte/dicom` by its
published name, so it runs against the built package exactly as a consumer installs it, prints what
it read or wrote, and checks its own output: a mismatch exits non-zero. The one object they read is
synthetic: an invented patient, example-root UIDs, header attributes only.

Build once, then run them all:

```bash
pnpm install
pnpm build
pnpm examples
```

| File | What it shows | Run |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- |
| [`read-metadata.ts`](read-metadata.ts) | Read the patient, study, series and image views, name the SOP class from the dictionary, and convert the study date. | `pnpm tsx examples/read-metadata.ts` |
| [`walk-elements.ts`](walk-elements.ts) | Walk every element with its dictionary name and its typed, decoded value. | `pnpm tsx examples/walk-elements.ts` |
| [`tolerate-and-reserialize.ts`](tolerate-and-reserialize.ts) | Read an object saved without its preamble, see the stable warning code, and write it back as spec-clean Part 10 that reads back with no warning. | `pnpm tsx examples/tolerate-and-reserialize.ts` |
| [`deidentify.ts`](deidentify.ts) | De-identify the header with the PS3.15 Annex E Basic Profile, print the audit report, and write the result out. | `pnpm tsx examples/deidentify.ts` |

`data/ct-object.ts` holds the synthetic CT object as base64, a copy of the first-use fixture the
README and the quickstart parse, kept in a module because this repository builds every fixture in
one rather than committing binary files.

CI runs `pnpm typecheck:examples`, `pnpm lint:examples`, `pnpm examples` and
`pnpm phi-scan:examples` after `pnpm build` on every pull request, so an example that drifts from
the package fails the build. The PHI scan decodes the base64 object and scans it as DICOM.
16 changes: 16 additions & 0 deletions examples/data/ct-object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* A synthetic CT object in DICOM Part 10 form: the declared-synthetic patient "Doe^Jane", an
* invented identifier and issuer, the placeholder study date 1900-01-01, and every UID under the
* `1.2.826.0.1.3680043.8.498` example root. It carries header attributes only, no pixel data.
*
* The bytes are a copy of the first-use fixture (`test/fixtures/first-use/ct-object.ts`), the same
* object the README and the quickstart parse, stored here as base64 because this repository builds
* every fixture in a module rather than committing binary files.
*/
import { Buffer } from "node:buffer";

/** The object with its 128-byte preamble and `DICM` magic, as a conformant writer saves it. */
export const SYNTHETIC_CT = Buffer.from(
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABESUNNAgAAAFVMBAAcAAAAAgAQAFVJFAAxLjIuODQwLjEwMDA4LjEuMi4xAAgAFgBVSRoAMS4yLjg0MC4xMDAwOC41LjEuNC4xLjEuMgAIABgAVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMTExAAgAIABEQQgAMTkwMDAxMDEIAGAAQ1MCAENUEAAQAFBOCABEb2VeSmFuZRAAIABMTwYATVJOLTQyEAAhAExPDABTQU1QTEUtSE9TUCAgAA0AVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMS4xACAADgBVSR4AMS4yLjgyNi4wLjEuMzY4MDA0My44LjQ5OC4xLjIAIAARAElTAgAyICgAEABVUwIAAAIoABEAVVMCAAACKAAAAVVTAgAQACgAAwFVUwIAAQAoAFIQRFMGAC0xMDI0ICgAUxBEUwIAMSAoADAARFMIADAuNVwwLjUg",
"base64",
);
44 changes: 44 additions & 0 deletions examples/deidentify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* De-identify an object's metadata, audit what was done, and write the result out.
*
* `deidentify` applies the PS3.15 Annex E Basic Profile to the header and returns a fresh dataset
* plus a report of every attribute it acted on (tag, keyword, action; never a value). The input
* dataset is not mutated. It does not touch pixels. The input is the synthetic CT object in
* `examples/data/ct-object.ts`.
*
* Run from the repository root after `pnpm build`:
*
* pnpm tsx examples/deidentify.ts
*/

import assert from "node:assert/strict";

import { deidentify, parseDicom, serializeDicom } from "@cosyte/dicom";

import { SYNTHETIC_CT } from "./data/ct-object.js";

const source = parseDicom(SYNTHETIC_CT);
const { dataset, report } = deidentify(source);

for (const a of report.attributes) {
console.log(`(${a.tag.slice(0, 4)},${a.tag.slice(4)}) ${a.keyword}: ${a.action}, ${a.applied}`);
}

const safe = parseDicom(serializeDicom(dataset));
const method = safe.get("00120063")?.value;
console.log("Patient ID after:", JSON.stringify(safe.patient.id));
console.log("Study UID after:", safe.study.instanceUid);
console.log("Method recorded:", method?.kind === "strings" ? method.values[0] : undefined);
console.log("Source untouched:", source.patient.id);

// The checks that make this file a test: `pnpm examples` fails if any of them does not hold.
const applied = new Map(report.attributes.map((a) => [a.keyword, a.applied]));
assert.equal(applied.get("Patient's Name"), "emptied");
assert.equal(applied.get("Patient ID"), "emptied");
assert.equal(applied.get("Issuer of Patient ID"), "removed");
assert.equal(applied.get("Study Instance UID"), "uid-remapped");
assert.equal(safe.patient.id, undefined);
assert.equal(safe.patient.name, undefined);
assert.notEqual(safe.study.instanceUid, source.study.instanceUid);
assert.equal(method?.kind, "strings");
assert.equal(source.patient.id, "MRN-42");
15 changes: 15 additions & 0 deletions examples/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import cosyte from "@cosyte/eslint-config";

// The examples are programs that print, not library code with a public API, so every type-safety
// rule applies to them and the library-only `no-console` and JSDoc gates do not.
export default [
...cosyte(import.meta.dirname, { files: ["**/*.ts"] }),
{
files: ["**/*.ts"],
rules: {
"no-console": "off",
"jsdoc/require-jsdoc": "off",
"jsdoc/require-example": "off",
},
},
];
47 changes: 47 additions & 0 deletions examples/read-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Read who, what and how out of a DICOM Part 10 object through the four typed views.
*
* `examples/data/ct-object.ts` holds a synthetic CT object (an invented patient, example-root
* UIDs). The views answer the safety-critical questions without a tag table: whose object, which
* study and series, and the image geometry you need before interpreting a pixel. A missing value
* stays `undefined`, never a substituted default.
*
* Run from the repository root after `pnpm build`:
*
* pnpm tsx examples/read-metadata.ts
*/

import assert from "node:assert/strict";

import { Dictionary, parseDicom, toISO } from "@cosyte/dicom";

import { SYNTHETIC_CT } from "./data/ct-object.js";

const ds = parseDicom(SYNTHETIC_CT);
const name = ds.patient.name?.alphabetic;
const sopClass = ds.get("00080016")?.value;
const sopClassUid = sopClass?.kind === "strings" ? sopClass.values[0] : undefined;

console.log(`Patient: ${String(name?.givenName)} ${String(name?.familyName)}`);
console.log(`Patient ID: ${String(ds.patient.id)} (issuer ${String(ds.patient.issuerOfId)})`);
console.log(`SOP class: ${String(Dictionary.uid(sopClassUid ?? "")?.name)}`);
console.log(`Study: ${String(ds.study.instanceUid)} on ${String(toISO(ds.study.date))}`);
console.log(`Series ${String(ds.series.number)}: ${String(ds.series.modality)}`);
console.log(
`Image: ${String(ds.image.rows)} x ${String(ds.image.columns)},`,
`signed ${String(ds.image.signed)}, rescale ${String(ds.image.rescaleSlope)} / ${String(ds.image.rescaleIntercept)},`,
`spacing ${JSON.stringify(ds.image.pixelSpacing)}`,
);
console.log("Warnings:", ds.warnings.length);

// The checks that make this file a test: `pnpm examples` fails if any of them does not hold.
assert.equal(name?.familyName, "Doe");
assert.equal(ds.patient.id, "MRN-42");
assert.equal(ds.patient.issuerOfId, "SAMPLE-HOSP");
assert.equal(Dictionary.uid(sopClassUid ?? "")?.name, "CT Image Storage");
assert.equal(toISO(ds.study.date), "1900-01-01");
assert.equal(ds.series.modality, "CT");
assert.deepEqual([ds.image.rows, ds.image.columns], [512, 512]);
assert.equal(ds.image.signed, true);
assert.deepEqual([ds.image.rescaleSlope, ds.image.rescaleIntercept], [1, -1024]);
assert.equal(ds.warnings.length, 0);
53 changes: 53 additions & 0 deletions examples/tolerate-and-reserialize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Read a vendor-quirky object leniently, then write it back out as spec-clean Part 10.
*
* The input is the synthetic CT object from `examples/data/ct-object.ts` with its 128-byte preamble
* and `DICM` magic cut off, as some writers save it. The parser reads it anyway and records the
* deviation as the stable code `DICOM_MISSING_PREAMBLE`. `serializeDicom` writes a conformant file
* in the same transfer syntax, with the preamble, File Meta rebuilt and elements in ascending tag
* order, and no value changed; reading that back raises no warning.
*
* Run from the repository root after `pnpm build`:
*
* pnpm tsx examples/tolerate-and-reserialize.ts
*/

import assert from "node:assert/strict";

import { parseDicom, serializeDicom } from "@cosyte/dicom";

import { SYNTHETIC_CT } from "./data/ct-object.js";

// Preamble (128 bytes) plus the "DICM" magic (4 bytes), removed.
const quirky = SYNTHETIC_CT.subarray(132);
const read = parseDicom(quirky);
console.log(
`Read ${String(quirky.length)} bytes, tolerated:`,
read.warnings.map((w) => w.code),
);

const written = serializeDicom(read);
const reread = parseDicom(written);
console.log(
`Wrote ${String(written.length)} bytes, magic "${written.toString("ascii", 128, 132)}"`,
);
console.log(
"Reading it back tolerated:",
reread.warnings.map((w) => w.code),
);
console.log("Transfer syntax kept:", reread.fileMeta?.transferSyntaxUID);

const tags = [...reread.elements()].map((e) => e.tag);
const values = (ds: typeof read): string[] =>
[...ds.elements()].map((e) => `${e.tag}=${JSON.stringify(e.value)}`).sort();

// The checks that make this file a test: `pnpm examples` fails if any of them does not hold.
assert.deepEqual(
read.warnings.map((w) => w.code),
["DICOM_MISSING_PREAMBLE"],
);
assert.equal(written.toString("ascii", 128, 132), "DICM");
assert.equal(reread.warnings.length, 0);
assert.equal(reread.fileMeta?.transferSyntaxUID, "1.2.840.10008.1.2.1");
assert.deepEqual(tags, [...tags].sort());
assert.deepEqual(values(reread), values(read));
7 changes: 7 additions & 0 deletions examples/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "@cosyte/tsconfig/base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./**/*.ts"]
}
63 changes: 63 additions & 0 deletions examples/walk-elements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Walk every element of an object with its dictionary name and its typed value.
*
* `ds.elements()` visits every data element; `Dictionary.lookup` names a tag from the Part 6 data
* dictionary compiled into the package; `element.value` decodes the raw bytes into a discriminated
* `DicomValue` on first read. The input is the synthetic CT object in `examples/data/ct-object.ts`.
*
* Run from the repository root after `pnpm build`:
*
* pnpm tsx examples/walk-elements.ts
*/

import assert from "node:assert/strict";

import { Dictionary, parseDicom, type DicomValue } from "@cosyte/dicom";

import { SYNTHETIC_CT } from "./data/ct-object.js";

/** One readable line for a decoded value; every kind this object carries is spelled out. */
function show(value: DicomValue): string {
switch (value.kind) {
case "strings":
return value.values.join(" | ");
case "numbers":
case "integerString":
case "decimalString":
return value.values.join(" \\ ");
case "personName":
return value.values
.map((pn) => `${String(pn.alphabetic?.familyName)}^${String(pn.alphabetic?.givenName)}`)
.join(" | ");
case "dates":
return value.values.map((d) => d.raw).join(" | ");
// Kinds this object does not carry: named rather than decoded here.
case "text":
case "bigints":
case "attributeTags":
case "times":
case "dateTimes":
case "binary":
case "sequence":
case "empty":
return `(${value.kind})`;
}
}

const ds = parseDicom(SYNTHETIC_CT);
const seen: string[] = [];
for (const element of ds.elements()) {
const entry = Dictionary.lookup(element.tag);
seen.push(entry?.keyword ?? element.tag);
console.log(
`(${element.tag.slice(0, 4)},${element.tag.slice(4)}) ${element.vr}`,
`${String(entry?.name).padEnd(28)} ${show(element.value)}`,
);
}

// The checks that make this file a test: `pnpm examples` fails if any of them does not hold.
assert.equal(seen.length, 17);
assert.ok(seen.includes("PatientName") && seen.includes("RescaleIntercept"));
const spacing = ds.get("00280030")?.value;
assert.ok(spacing?.kind === "decimalString");
assert.deepEqual(spacing.values, [0.5, 0.5]);
Loading
Loading