|
1 | 1 | import fs from "fs"; |
| 2 | +import crypto from "crypto"; |
2 | 3 | import dcmjs from "../src/index.js"; |
3 | 4 | import { deepEqual } from "../src/utilities/deepEqual"; |
| 5 | +import { |
| 6 | + DEFLATED_EXPLICIT_LITTLE_ENDIAN, |
| 7 | + UNDEFINED_LENGTH, |
| 8 | + TagHex |
| 9 | +} from "../src/constants/dicom.js"; |
4 | 10 |
|
5 | 11 | import { getTestDataset } from "./testUtils"; |
6 | 12 | import { DicomMetaDictionary } from "../src/DicomMetaDictionary"; |
7 | 13 |
|
8 | 14 | const { DicomDict, DicomMessage } = dcmjs.data; |
9 | 15 |
|
| 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 | + |
10 | 252 | describe("lossless-read-write", () => { |
11 | 253 | describe("storeRaw option", () => { |
12 | 254 | const dataset = { |
@@ -1034,6 +1276,80 @@ describe("lossless-read-write", () => { |
1034 | 1276 | } |
1035 | 1277 | }); |
1036 | 1278 |
|
| 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 | + |
1037 | 1353 | test("compressed data should be read correctly as arraybuffer", () => { |
1038 | 1354 | const buffer = fs.readFileSync("test/sample-op.dcm"); |
1039 | 1355 | const dicomDict = DicomMessage.readFile(buffer.buffer); |
|
0 commit comments