Skip to content

Commit 67b2ce6

Browse files
Michael-AndersenAndersenwayfarer3130
authored
fix: unencapsulatedTransferSyntaxes mapping (#472)
* fix: unencapsulatedTransferSyntaxes mapping * Add a unit test that specifically checks for the incorrect uncompressed --------- Co-authored-by: Andersen <Michael.Andersen@uhn.ca> Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
1 parent a6764cb commit 67b2ce6

4 files changed

Lines changed: 324 additions & 10 deletions

File tree

src/Tag.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,10 @@ class Tag {
136136
const vr = ValueRepresentation.createByTypeString(vrType);
137137
const useSyntax = DicomMessage._normalizeSyntax(syntax);
138138

139-
const implicit = useSyntax == IMPLICIT_LITTLE_ENDIAN ? true : false;
139+
const implicit = useSyntax === IMPLICIT_LITTLE_ENDIAN;
140140
const isLittleEndian =
141-
useSyntax == IMPLICIT_LITTLE_ENDIAN ||
142-
useSyntax == EXPLICIT_LITTLE_ENDIAN
143-
? true
144-
: false;
141+
useSyntax === IMPLICIT_LITTLE_ENDIAN ||
142+
useSyntax === EXPLICIT_LITTLE_ENDIAN;
145143
const isEncapsulated =
146144
this.isPixelDataTag() && DicomMessage.isEncapsulated(syntax);
147145

src/ValueRepresentation.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ class BinaryRepresentation extends ValueRepresentation {
406406
this._storeRaw = false;
407407
}
408408

409-
writeBytes(stream, value, syntax, isEncapsulated, writeOptions = {}) {
409+
writeBytes(stream, value, _syntax, isEncapsulated, writeOptions = {}) {
410410
var i;
411411
var binaryStream;
412412
var { fragmentMultiframe = true } = writeOptions;

src/constants/dicom.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ export const SEQUENCE_DELIMITER_TAG = 0xfffee0dd;
3434
// Nearly all transfer syntaxes are encapsulated, so record those which are
3535
// unencapsulated as the exceptions.
3636
export const unencapsulatedTransferSyntaxes = {
37-
IMPLICIT_LITTLE_ENDIAN: true,
38-
EXPLICIT_BIG_ENDIAN: true,
39-
DEFLATED_EXPLICIT_LITTLE_ENDIAN: true,
40-
EXPLICIT_LITTLE_ENDIAN: true
37+
[IMPLICIT_LITTLE_ENDIAN]: true,
38+
[EXPLICIT_BIG_ENDIAN]: true,
39+
[DEFLATED_EXPLICIT_LITTLE_ENDIAN]: true,
40+
[EXPLICIT_LITTLE_ENDIAN]: true
4141
};
4242

4343
/**

test/lossless-read-write.test.js

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,254 @@
11
import fs from "fs";
2+
import crypto from "crypto";
23
import dcmjs from "../src/index.js";
34
import { deepEqual } from "../src/utilities/deepEqual";
5+
import {
6+
DEFLATED_EXPLICIT_LITTLE_ENDIAN,
7+
UNDEFINED_LENGTH,
8+
TagHex
9+
} from "../src/constants/dicom.js";
410

511
import { getTestDataset } from "./testUtils";
612
import { DicomMetaDictionary } from "../src/DicomMetaDictionary";
713

814
const { DicomDict, DicomMessage } = dcmjs.data;
915

16+
// Implicit VR Little Endian - dataset uses tag (4) + length (4) + value
17+
const IMPLICIT_LITTLE_ENDIAN_UID = "1.2.840.10008.1.2";
18+
// VRs that use 32-bit length in Explicit VR (reserved 2 + length 4 after VR)
19+
const EXPLICIT_VR_LENGTH32 = [
20+
"OB",
21+
"OW",
22+
"OF",
23+
"SQ",
24+
"UN",
25+
"UC",
26+
"UR",
27+
"UT",
28+
"OD"
29+
];
30+
31+
/**
32+
* Parses a raw DICOM buffer and returns the PixelData element's length and raw bytes.
33+
* Reads up to PixelData, then reads the tag's length field (checking it's not -1) and value.
34+
* Supports both Implicit and Explicit VR Little Endian for the dataset.
35+
*
36+
* @param {ArrayBuffer|Uint8Array} buffer - Raw DICOM file buffer
37+
* @param {string} [transferSyntaxUID] - Optional. If provided, used to decide Implicit vs Explicit VR (e.g. from meta); avoids parsing meta.
38+
* @returns {{ length: number, data: ArrayBuffer } | null} - PixelData length and bytes, or null if not found
39+
*/
40+
function readPixelDataFromRawBuffer(buffer, transferSyntaxUIDHint) {
41+
// Ensure we have a contiguous ArrayBuffer (getBuffer may return view with shared buffer)
42+
let arrayBuf;
43+
if (buffer instanceof ArrayBuffer) {
44+
arrayBuf = buffer;
45+
} else {
46+
const len = buffer.byteLength ?? buffer.length;
47+
arrayBuf = buffer.buffer.slice(
48+
buffer.byteOffset ?? 0,
49+
(buffer.byteOffset ?? 0) + len
50+
);
51+
}
52+
const view = new DataView(arrayBuf);
53+
const isLittleEndian = true;
54+
55+
let offset = 132; // Skip preamble (128) + "DICM" (4)
56+
if (offset + 4 > arrayBuf.byteLength) return null;
57+
58+
// Parse meta header - first element is FileMetaInformationGroupLength (Explicit VR)
59+
offset += 4; // tag
60+
offset += 2; // VR
61+
offset += 2; // elem length (2 for UL)
62+
const metaLength = view.getUint32(offset, isLittleEndian);
63+
offset += 4;
64+
const metaEnd = Math.min(offset + metaLength, arrayBuf.byteLength);
65+
66+
// Parse meta to find Transfer Syntax UID (0002,0010) for dataset VR format (unless hint provided)
67+
let transferSyntaxUID = transferSyntaxUIDHint ?? IMPLICIT_LITTLE_ENDIAN_UID;
68+
if (transferSyntaxUIDHint == null) {
69+
while (offset < metaEnd && offset + 8 <= arrayBuf.byteLength) {
70+
const group = view.getUint16(offset, isLittleEndian);
71+
const element = view.getUint16(offset + 2, isLittleEndian);
72+
offset += 4;
73+
const vr = String.fromCharCode(
74+
view.getUint8(offset),
75+
view.getUint8(offset + 1)
76+
);
77+
offset += 2;
78+
let elemLen;
79+
if (EXPLICIT_VR_LENGTH32.includes(vr)) {
80+
offset += 2; // reserved
81+
elemLen = view.getUint32(offset, isLittleEndian);
82+
offset += 4;
83+
} else {
84+
elemLen = view.getUint16(offset, isLittleEndian);
85+
offset += 2;
86+
}
87+
if (offset + elemLen > arrayBuf.byteLength) break;
88+
if (group === 0x0002 && element === 0x0010) {
89+
// Transfer Syntax UID - value is ASCII
90+
transferSyntaxUID = String.fromCharCode(
91+
...new Uint8Array(arrayBuf, offset, elemLen)
92+
)
93+
.replace(/\0/g, "")
94+
.trim();
95+
break;
96+
}
97+
offset += elemLen;
98+
}
99+
}
100+
offset = metaEnd;
101+
102+
// Detect Implicit vs Explicit from Transfer Syntax UID; fallback: Explicit if first dataset tag has 2-char VR (A-Z) after tag
103+
let implicitDataset = transferSyntaxUID === IMPLICIT_LITTLE_ENDIAN_UID;
104+
if (offset + 6 <= arrayBuf.byteLength) {
105+
const b0 = view.getUint8(offset + 4);
106+
const b1 = view.getUint8(offset + 5);
107+
const looksLikeExplicitVR =
108+
b0 >= 0x41 && b0 <= 0x5a && b1 >= 0x41 && b1 <= 0x5a;
109+
if (looksLikeExplicitVR) implicitDataset = false;
110+
}
111+
const PIXEL_DATA_TAG = 0x7fe00010;
112+
const SEQUENCE_ITEM_TAG = 0xfffee000;
113+
const SEQUENCE_DELIMITER_TAG = 0xfffee0dd;
114+
const ITEM_DELIMITATION_TAG = 0xfffee00d;
115+
116+
while (offset < arrayBuf.byteLength - 8) {
117+
const group = view.getUint16(offset, isLittleEndian);
118+
const element = view.getUint16(offset + 2, isLittleEndian);
119+
const tagValue = (group << 16) | element;
120+
offset += 4;
121+
122+
let length;
123+
if (implicitDataset) {
124+
length = view.getUint32(offset, isLittleEndian);
125+
offset += 4;
126+
} else {
127+
const vr = String.fromCharCode(
128+
view.getUint8(offset),
129+
view.getUint8(offset + 1)
130+
);
131+
offset += 2;
132+
if (EXPLICIT_VR_LENGTH32.includes(vr)) {
133+
offset += 2; // reserved
134+
length = view.getUint32(offset, isLittleEndian);
135+
offset += 4;
136+
} else {
137+
length = view.getUint16(offset, isLittleEndian);
138+
offset += 2;
139+
}
140+
}
141+
142+
if (tagValue === PIXEL_DATA_TAG) {
143+
if (length === UNDEFINED_LENGTH || length === -1) {
144+
return { length: -1, data: null };
145+
}
146+
if (offset + length > arrayBuf.byteLength) return null;
147+
const data = arrayBuf.slice(offset, offset + length);
148+
return { length, data };
149+
}
150+
151+
if (length === UNDEFINED_LENGTH) {
152+
// Skip undefined-length sequence (Item FFFE,E000 or Delimiter FFFE,E0DD)
153+
while (offset < arrayBuf.byteLength - 8) {
154+
const itemGroup = view.getUint16(offset, isLittleEndian);
155+
const itemElement = view.getUint16(offset + 2, isLittleEndian);
156+
const itemTagValue = (itemGroup << 16) | itemElement;
157+
offset += 4;
158+
const itemLength = view.getUint32(offset, isLittleEndian);
159+
offset += 4;
160+
if (itemTagValue === SEQUENCE_DELIMITER_TAG) break;
161+
if (itemTagValue === SEQUENCE_ITEM_TAG) {
162+
if (
163+
itemLength === UNDEFINED_LENGTH ||
164+
itemLength === 0xffffffff
165+
) {
166+
// Skip undefined-length item until Item Delimitation (FFFE,E00D); item uses same VR as dataset
167+
let itemOffset = offset;
168+
while (itemOffset < arrayBuf.byteLength - 8) {
169+
const delGroup = view.getUint16(
170+
itemOffset,
171+
isLittleEndian
172+
);
173+
const delElement = view.getUint16(
174+
itemOffset + 2,
175+
isLittleEndian
176+
);
177+
const delTag = (delGroup << 16) | delElement;
178+
itemOffset += 4;
179+
let delLen;
180+
if (implicitDataset) {
181+
delLen = view.getUint32(
182+
itemOffset,
183+
isLittleEndian
184+
);
185+
itemOffset += 4;
186+
} else {
187+
const vr = String.fromCharCode(
188+
view.getUint8(itemOffset),
189+
view.getUint8(itemOffset + 1)
190+
);
191+
itemOffset += 2;
192+
if (EXPLICIT_VR_LENGTH32.includes(vr)) {
193+
itemOffset += 2;
194+
delLen = view.getUint32(
195+
itemOffset,
196+
isLittleEndian
197+
);
198+
itemOffset += 4;
199+
} else {
200+
delLen = view.getUint16(
201+
itemOffset,
202+
isLittleEndian
203+
);
204+
itemOffset += 2;
205+
}
206+
}
207+
if (delTag === ITEM_DELIMITATION_TAG) break;
208+
itemOffset += delLen > 0 ? delLen : 0;
209+
}
210+
offset = itemOffset;
211+
} else {
212+
offset += itemLength;
213+
}
214+
}
215+
}
216+
} else if (length > 0) {
217+
offset += length;
218+
}
219+
}
220+
return null;
221+
}
222+
223+
function getPixelDataBytes(pixelDataValue) {
224+
const chunks = [];
225+
const flatten = arr => {
226+
for (const item of arr) {
227+
if (item instanceof ArrayBuffer) {
228+
chunks.push(new Uint8Array(item));
229+
} else if (Array.isArray(item)) {
230+
flatten(item);
231+
}
232+
}
233+
};
234+
flatten(pixelDataValue);
235+
const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
236+
const result = new Uint8Array(totalLength);
237+
let offset = 0;
238+
for (const chunk of chunks) {
239+
result.set(chunk, offset);
240+
offset += chunk.byteLength;
241+
}
242+
return result.buffer;
243+
}
244+
245+
function hashArrayBuffer(buffer) {
246+
return crypto
247+
.createHash("sha256")
248+
.update(new Uint8Array(buffer))
249+
.digest("hex");
250+
}
251+
10252
describe("lossless-read-write", () => {
11253
describe("storeRaw option", () => {
12254
const dataset = {
@@ -1034,6 +1276,80 @@ describe("lossless-read-write", () => {
10341276
}
10351277
});
10361278

1279+
test("uncompressed PixelData written with explicit length (524288) for streaming read", () => {
1280+
// test/sample-dicom.dcm is uncompressed data
1281+
const buffer = fs.readFileSync("test/sample-dicom.dcm");
1282+
const dicomDict = DicomMessage.readFile(buffer.buffer);
1283+
const { dict } = dicomDict;
1284+
1285+
// Get original pixel data and compute hash
1286+
const originalPixelBytes = getPixelDataBytes(
1287+
dict[TagHex.PixelData].Value
1288+
);
1289+
const originalHash = hashArrayBuffer(originalPixelBytes);
1290+
1291+
// Write to memory buffer
1292+
const natural = DicomMetaDictionary.naturalizeDataset(dict);
1293+
dicomDict.dict = DicomMetaDictionary.denaturalizeDataset(natural);
1294+
const outputBuffer = dicomDict.write();
1295+
const writtenDict = DicomMessage.readFile(outputBuffer);
1296+
1297+
// PixelData must be written with explicit length 524288 (512*512*2), NOT undefined length, so a streaming reader can read it
1298+
const writtenPixelBytes = getPixelDataBytes(
1299+
writtenDict.dict[TagHex.PixelData].Value
1300+
);
1301+
expect(writtenPixelBytes.byteLength).toBe(524288);
1302+
1303+
// Hash of pixel data must match original
1304+
const writtenHash = hashArrayBuffer(writtenPixelBytes);
1305+
expect(writtenHash).toBe(originalHash);
1306+
});
1307+
1308+
test("Deflated Explicit VR Little Endian: PixelData written with explicit length (unencapsulated)", () => {
1309+
// When Transfer Syntax is DEFLATED_EXPLICIT_LITTLE_ENDIAN, pixel data is unencapsulated
1310+
// (raw bytes). The fix ensures unencapsulatedTransferSyntaxes includes it so PixelData
1311+
// is written with explicit length, not as encapsulated (undefined length). This test
1312+
// fails on master (without the fix) and passes with the fix.
1313+
const pixelBytes = new ArrayBuffer(4);
1314+
new Uint8Array(pixelBytes).set([0x01, 0x02, 0x03, 0x04]);
1315+
const transferSyntaxUid = DEFLATED_EXPLICIT_LITTLE_ENDIAN;
1316+
const meta = {
1317+
[TagHex.FileMetaInformationGroupLength]: {
1318+
vr: "UL",
1319+
Value: [32]
1320+
},
1321+
[TagHex.TransferSyntaxUID]: {
1322+
vr: "UI",
1323+
Value: [transferSyntaxUid]
1324+
}
1325+
};
1326+
const dict = {
1327+
[TagHex.PixelData]: {
1328+
vr: "OW",
1329+
Value: [pixelBytes]
1330+
}
1331+
};
1332+
const dicomDict = new DicomDict(meta);
1333+
dicomDict.dict = dict;
1334+
1335+
const outputBuffer = dicomDict.write();
1336+
const arrayBuf =
1337+
outputBuffer instanceof ArrayBuffer
1338+
? outputBuffer
1339+
: outputBuffer.buffer.slice(
1340+
outputBuffer.byteOffset,
1341+
outputBuffer.byteOffset + outputBuffer.byteLength
1342+
);
1343+
const pixelInfo = readPixelDataFromRawBuffer(
1344+
arrayBuf,
1345+
transferSyntaxUid
1346+
);
1347+
expect(pixelInfo).not.toBeNull();
1348+
expect(pixelInfo.length).not.toBe(-1);
1349+
expect(pixelInfo.data).not.toBeNull();
1350+
expect(pixelInfo.data.byteLength).toBe(4);
1351+
});
1352+
10371353
test("compressed data should be read correctly as arraybuffer", () => {
10381354
const buffer = fs.readFileSync("test/sample-op.dcm");
10391355
const dicomDict = DicomMessage.readFile(buffer.buffer);

0 commit comments

Comments
 (0)