From 280c271a199c7c6e25d5ddc7655dbb6e8a6f0441 Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Fri, 9 Aug 2024 16:42:09 -0400 Subject: [PATCH 01/16] Extract formatting logic from initial value read from data view --- src/ValueRepresentation.js | 150 +++++++++++++++++++++++++++++-------- 1 file changed, 118 insertions(+), 32 deletions(-) diff --git a/src/ValueRepresentation.js b/src/ValueRepresentation.js index 5b365f0c..737e0ed3 100644 --- a/src/ValueRepresentation.js +++ b/src/ValueRepresentation.js @@ -137,7 +137,11 @@ class ValueRepresentation { length ); } - return this.readBytes(stream, length, syntax); + return this.applyFormatting(this.readBytes(stream, length, syntax)); + } + + applyFormatting(value) { + return value; } readBytes(stream, length) { @@ -562,7 +566,11 @@ class ApplicationEntity extends AsciiStringRepresentation { } readBytes(stream, length) { - return stream.readAsciiString(length).trim(); + return stream.readAsciiString(length); + } + + applyFormatting(value) { + return value.trim(); } } @@ -574,7 +582,11 @@ class CodeString extends AsciiStringRepresentation { } readBytes(stream, length) { - return stream.readAsciiString(length).trim(); + return stream.readAsciiString(length); + } + + applyFormatting(value) { + return value.trim(); } } @@ -631,19 +643,32 @@ class DecimalString extends AsciiStringRepresentation { readBytes(stream, length) { const BACKSLASH = String.fromCharCode(VM_DELIMITER); let ds = stream.readAsciiString(length); - ds = ds.replace(/[^0-9.\\\-+e]/gi, ""); + if (ds.indexOf(BACKSLASH) !== -1) { // handle decimal string with multiplicity const dsArray = ds.split(BACKSLASH); - ds = dsArray.map(ds => (ds === "" ? null : Number(ds))); + ds = dsArray.map(ds => (ds === "" ? null : ds)); } else { - ds = [ds === "" ? null : Number(ds)]; + ds = [ds === "" ? null : ds]; } return ds; } - formatValue(value) { + applyFormatting(value) { + const formatNumber = (numberStr) => { + let returnVal = numberStr.replace(/[^0-9.\\\-+e]/gi, ""); + return returnVal === "" ? null : Number(returnVal); + } + + if (Array.isArray(value)) { + return value.map(formatNumber); + } + + return formatNumber(value); + } + + convertToString(value) { if (value === null) return ""; let str = String(value); @@ -681,8 +706,8 @@ class DecimalString extends AsciiStringRepresentation { writeBytes(stream, value, writeOptions) { const val = Array.isArray(value) - ? value.map(ds => this.formatValue(ds)) - : [this.formatValue(value)]; + ? value.map(ds => this.convertToString(ds)) + : [this.convertToString(value)]; return super.writeBytes(stream, val, writeOptions); } } @@ -705,7 +730,11 @@ class FloatingPointSingle extends ValueRepresentation { } readBytes(stream) { - return Number(stream.readFloat()); + return stream.readFloat(); + } + + applyFormatting(value) { + return Number(value); } writeBytes(stream, value, writeOptions) { @@ -728,7 +757,11 @@ class FloatingPointDouble extends ValueRepresentation { } readBytes(stream) { - return Number(stream.readDouble()); + return stream.readDouble(); + } + + applyFormatting(value) { + return Number(value); } writeBytes(stream, value, writeOptions) { @@ -752,27 +785,38 @@ class IntegerString extends AsciiStringRepresentation { const BACKSLASH = String.fromCharCode(VM_DELIMITER); let is = stream.readAsciiString(length).trim(); - is = is.replace(/[^0-9.\\\-+e]/gi, ""); - if (is.indexOf(BACKSLASH) !== -1) { // handle integer string with multiplicity const integerStringArray = is.split(BACKSLASH); - is = integerStringArray.map(is => (is === "" ? null : Number(is))); + is = integerStringArray.map(is => (is === "" ? null : is)); } else { - is = [is === "" ? null : Number(is)]; + is = [is === "" ? null : is]; } return is; } - formatValue(value) { + applyFormatting(value) { + const formatNumber = (numberStr) => { + let returnVal = numberStr.replace(/[^0-9.\\\-+e]/gi, ""); + return returnVal === "" ? null : Number(returnVal); + } + + if (Array.isArray(value)) { + return value.map(formatNumber); + } + + return formatNumber(value); + } + + convertToString(value) { return value === null ? "" : String(value); } writeBytes(stream, value, writeOptions) { const val = Array.isArray(value) - ? value.map(is => this.formatValue(is)) - : [this.formatValue(value)]; + ? value.map(is => this.convertToString(is)) + : [this.convertToString(value)]; return super.writeBytes(stream, val, writeOptions); } } @@ -785,7 +829,11 @@ class LongString extends EncodedStringRepresentation { } readBytes(stream, length) { - return stream.readEncodedString(length).trim(); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return value.trim(); } } @@ -797,7 +845,11 @@ class LongText extends EncodedStringRepresentation { } readBytes(stream, length) { - return rtrim(stream.readEncodedString(length)); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return rtrim(value); } } @@ -870,8 +922,11 @@ class PersonName extends EncodedStringRepresentation { } readBytes(stream, length) { - const result = this.readPaddedEncodedString(stream, length); - return dicomJson.pnConvertToJsonObject(result); + return this.readPaddedEncodedString(stream, length); + } + + applyFormatting(value) { + return dicomJson.pnConvertToJsonObject(value); } writeBytes(stream, value, writeOptions) { @@ -891,7 +946,11 @@ class ShortString extends EncodedStringRepresentation { } readBytes(stream, length) { - return stream.readEncodedString(length).trim(); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return value.trim(); } } @@ -926,6 +985,7 @@ class SequenceOfItems extends ValueRepresentation { this.noMultiple = true; } + // TODO Craig: potentially need special logic for sequences when writing readBytes(stream, sqlength, syntax) { if (sqlength == 0x0) { return []; //contains no dataset @@ -1086,7 +1146,11 @@ class ShortText extends EncodedStringRepresentation { } readBytes(stream, length) { - return rtrim(stream.readEncodedString(length)); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return rtrim(value); } } @@ -1098,7 +1162,11 @@ class TimeValue extends AsciiStringRepresentation { } readBytes(stream, length) { - return rtrim(stream.readAsciiString(length)); + return stream.readAsciiString(length); + } + + applyFormatting(value) { + return rtrim(value); } } @@ -1111,7 +1179,11 @@ class UnlimitedCharacters extends EncodedStringRepresentation { } readBytes(stream, length) { - return rtrim(stream.readEncodedString(length)); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return rtrim(value); } } @@ -1123,7 +1195,11 @@ class UnlimitedText extends EncodedStringRepresentation { } readBytes(stream, length) { - return rtrim(stream.readEncodedString(length)); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return rtrim(value); } } @@ -1184,7 +1260,6 @@ class UniqueIdentifier extends AsciiStringRepresentation { const result = this.readPaddedAsciiString(stream, length); const BACKSLASH = String.fromCharCode(VM_DELIMITER); - const uidRegExp = /[^0-9.]/g; // Treat backslashes as a delimiter for multiple UIDs, in which case an // array of UIDs is returned. This is used by DICOM Q&R to support @@ -1195,13 +1270,23 @@ class UniqueIdentifier extends AsciiStringRepresentation { // https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.4.html if (result.indexOf(BACKSLASH) === -1) { - return result.replace(uidRegExp, ""); - } else { return result - .split(BACKSLASH) - .map(uid => uid.replace(uidRegExp, "")); + } else { + return result.split(BACKSLASH) } } + + applyFormatting(value) { + const removeInvalidUidChars = (uidStr) => { + return uidStr.replace(/[^0-9.]/g, ""); + } + + if (Array.isArray(value)) { + return value.map(removeInvalidUidChars); + } + + return removeInvalidUidChars(value); + } } class UniversalResource extends AsciiStringRepresentation { @@ -1225,6 +1310,7 @@ class UnknownValue extends BinaryRepresentation { } } +// TODO Craig: Need to come back and analyze this class ParsedUnknownValue extends BinaryRepresentation { constructor(vr) { super(vr); From d0d3ec34a411b7368566c8dea9ed5f6f1b02bcd0 Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Fri, 9 Aug 2024 17:03:16 -0400 Subject: [PATCH 02/16] Leave empty string in base numeric string read, update tests with new function name. --- src/ValueRepresentation.js | 13 ++++--------- test/data.test.js | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/ValueRepresentation.js b/src/ValueRepresentation.js index 737e0ed3..e5c4a27e 100644 --- a/src/ValueRepresentation.js +++ b/src/ValueRepresentation.js @@ -646,13 +646,10 @@ class DecimalString extends AsciiStringRepresentation { if (ds.indexOf(BACKSLASH) !== -1) { // handle decimal string with multiplicity - const dsArray = ds.split(BACKSLASH); - ds = dsArray.map(ds => (ds === "" ? null : ds)); + return ds.split(BACKSLASH); } else { - ds = [ds === "" ? null : ds]; + return [ds]; } - - return ds; } applyFormatting(value) { @@ -787,13 +784,11 @@ class IntegerString extends AsciiStringRepresentation { if (is.indexOf(BACKSLASH) !== -1) { // handle integer string with multiplicity - const integerStringArray = is.split(BACKSLASH); - is = integerStringArray.map(is => (is === "" ? null : is)); + return is.split(BACKSLASH); } else { - is = [is === "" ? null : is]; + return [is]; } - return is; } applyFormatting(value) { diff --git a/test/data.test.js b/test/data.test.js index c598d181..ca34ba26 100644 --- a/test/data.test.js +++ b/test/data.test.js @@ -1149,7 +1149,7 @@ it.each([ "A converted decimal string should not exceed 16 bytes in length", (a, expected) => { const decimalString = ValueRepresentation.createByTypeString("DS"); - let value = decimalString.formatValue(a); + let value = decimalString.convertToString(a); expect(value.length).toBeLessThanOrEqual(16); expect(value).toBe(expected); } From f107adb9d4c2af96bc6ce36a6295551c388ac374 Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Mon, 12 Aug 2024 14:17:10 -0400 Subject: [PATCH 03/16] Save original rawValue of data element as private property returned from readTag, manually apply formatting on returned Value property --- src/DicomMessage.js | 27 +++++++++++------ src/ValueRepresentation.js | 59 ++++++++++++++------------------------ 2 files changed, 40 insertions(+), 46 deletions(-) diff --git a/src/DicomMessage.js b/src/DicomMessage.js index 6d31d77a..100c1955 100644 --- a/src/DicomMessage.js +++ b/src/DicomMessage.js @@ -156,6 +156,7 @@ class DicomMessage { vr: readInfo.vr.type }); dict[cleanTagString].Value = readInfo.values; + dict[cleanTagString]._rawValue = readInfo.rawValues; if (untilTag && untilTag === cleanTagString) { break; @@ -340,25 +341,34 @@ class DicomMessage { } var values = []; + var rawValues = []; if (vr.isBinary() && length > vr.maxLength && !vr.noMultiple) { var times = length / vr.maxLength, i = 0; while (i++ < times) { - values.push(vr.read(stream, vr.maxLength, syntax)); + const rawValue = vr.read(stream, vr.maxLength, syntax); + rawValues.push(rawValue); + values.push(vr.applyFormatting(rawValue)); } } else { - var val = vr.read(stream, length, syntax); + var rawVal = vr.read(stream, length, syntax); if (!vr.isBinary() && singleVRs.indexOf(vr.type) == -1) { - values = val; - if (typeof val === "string") { - values = val.split(String.fromCharCode(VM_DELIMITER)); + rawValues = rawVal; + if (vr.type == 'PN') { + values = vr.applyFormatting(rawVal); + } else if (typeof rawVal === "string") { + rawValues = rawVal.split(String.fromCharCode(VM_DELIMITER)); + values = rawValues.map(str => vr.applyFormatting(str)); } } else if (vr.type == "SQ") { - values = val; + rawValues = rawVal; + values = vr.applyFormatting(rawVal); } else if (vr.type == "OW" || vr.type == "OB") { - values = val; + rawValues = rawVal; + values = vr.applyFormatting(rawVal); } else { - Array.isArray(val) ? (values = val) : values.push(val); + Array.isArray(rawVal) ? (values = rawVal.map(str => vr.applyFormatting(str))) : values.push(rawVal); + Array.isArray(rawVal) ? (rawValues = rawVal) : rawValues.push(vr.applyFormatting(rawVal)); } } stream.setEndian(oldEndian); @@ -368,6 +378,7 @@ class DicomMessage { vr: vr }); retObj.values = values; + retObj.rawValues = rawValues; return retObj; } diff --git a/src/ValueRepresentation.js b/src/ValueRepresentation.js index e5c4a27e..4829700f 100644 --- a/src/ValueRepresentation.js +++ b/src/ValueRepresentation.js @@ -137,7 +137,7 @@ class ValueRepresentation { length ); } - return this.applyFormatting(this.readBytes(stream, length, syntax)); + return this.readBytes(stream, length, syntax); } applyFormatting(value) { @@ -641,20 +641,12 @@ class DecimalString extends AsciiStringRepresentation { } readBytes(stream, length) { - const BACKSLASH = String.fromCharCode(VM_DELIMITER); - let ds = stream.readAsciiString(length); - - if (ds.indexOf(BACKSLASH) !== -1) { - // handle decimal string with multiplicity - return ds.split(BACKSLASH); - } else { - return [ds]; - } + return stream.readAsciiString(length); } applyFormatting(value) { const formatNumber = (numberStr) => { - let returnVal = numberStr.replace(/[^0-9.\\\-+e]/gi, ""); + let returnVal = numberStr.trim().replace(/[^0-9.\\\-+e]/gi, ""); return returnVal === "" ? null : Number(returnVal); } @@ -779,21 +771,12 @@ class IntegerString extends AsciiStringRepresentation { } readBytes(stream, length) { - const BACKSLASH = String.fromCharCode(VM_DELIMITER); - let is = stream.readAsciiString(length).trim(); - - if (is.indexOf(BACKSLASH) !== -1) { - // handle integer string with multiplicity - return is.split(BACKSLASH); - } else { - return [is]; - } - + return stream.readAsciiString(length); } applyFormatting(value) { const formatNumber = (numberStr) => { - let returnVal = numberStr.replace(/[^0-9.\\\-+e]/gi, ""); + let returnVal = numberStr.trim().replace(/[^0-9.\\\-+e]/gi, ""); return returnVal === "" ? null : Number(returnVal); } @@ -1252,23 +1235,23 @@ class UniqueIdentifier extends AsciiStringRepresentation { } readBytes(stream, length) { - const result = this.readPaddedAsciiString(stream, length); - - const BACKSLASH = String.fromCharCode(VM_DELIMITER); - - // Treat backslashes as a delimiter for multiple UIDs, in which case an - // array of UIDs is returned. This is used by DICOM Q&R to support - // querying and matching multiple items on a UID field in a single - // query. For more details see: + return this.readPaddedAsciiString(stream, length); // - // https://dicom.nema.org/medical/dicom/current/output/chtml/part04/sect_C.2.2.2.2.html - // https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.4.html - - if (result.indexOf(BACKSLASH) === -1) { - return result - } else { - return result.split(BACKSLASH) - } + // const BACKSLASH = String.fromCharCode(VM_DELIMITER); + // + // // Treat backslashes as a delimiter for multiple UIDs, in which case an + // // array of UIDs is returned. This is used by DICOM Q&R to support + // // querying and matching multiple items on a UID field in a single + // // query. For more details see: + // // + // // https://dicom.nema.org/medical/dicom/current/output/chtml/part04/sect_C.2.2.2.2.html + // // https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.4.html + // + // if (result.indexOf(BACKSLASH) === -1) { + // return result + // } else { + // return result.split(BACKSLASH) + // } } applyFormatting(value) { From b691b53613e82ea90880b05e217b02eeac895d5e Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Tue, 13 Aug 2024 09:59:41 -0400 Subject: [PATCH 04/16] Refactor to calculate raw and value inside value representation --- src/DicomMessage.js | 29 +++++++++-------- src/ValueRepresentation.js | 64 +++++++++++++++++++++++++------------- test/data.test.js | 2 ++ 3 files changed, 59 insertions(+), 36 deletions(-) diff --git a/src/DicomMessage.js b/src/DicomMessage.js index 100c1955..d4297980 100644 --- a/src/DicomMessage.js +++ b/src/DicomMessage.js @@ -346,29 +346,28 @@ class DicomMessage { var times = length / vr.maxLength, i = 0; while (i++ < times) { - const rawValue = vr.read(stream, vr.maxLength, syntax); + const { rawValue, value } = vr.read(stream, vr.maxLength, syntax); rawValues.push(rawValue); - values.push(vr.applyFormatting(rawValue)); + values.push(value); } } else { - var rawVal = vr.read(stream, length, syntax); + const { rawValue, value } = vr.read(stream, length, syntax); if (!vr.isBinary() && singleVRs.indexOf(vr.type) == -1) { - rawValues = rawVal; - if (vr.type == 'PN') { - values = vr.applyFormatting(rawVal); - } else if (typeof rawVal === "string") { - rawValues = rawVal.split(String.fromCharCode(VM_DELIMITER)); - values = rawValues.map(str => vr.applyFormatting(str)); + rawValues = rawValue; + values = value + if (typeof value === "string") { + rawValues = rawValue.split(String.fromCharCode(VM_DELIMITER)); + values = value.split(String.fromCharCode(VM_DELIMITER)); } } else if (vr.type == "SQ") { - rawValues = rawVal; - values = vr.applyFormatting(rawVal); + rawValues = rawValue; + values = value; } else if (vr.type == "OW" || vr.type == "OB") { - rawValues = rawVal; - values = vr.applyFormatting(rawVal); + rawValues = rawValue; + values = value; } else { - Array.isArray(rawVal) ? (values = rawVal.map(str => vr.applyFormatting(str))) : values.push(rawVal); - Array.isArray(rawVal) ? (rawValues = rawVal) : rawValues.push(vr.applyFormatting(rawVal)); + Array.isArray(value) ? (values = value) : values.push(value); + Array.isArray(rawValue) ? (rawValues = rawValue) : rawValues.push(rawValue); } } stream.setEndian(oldEndian); diff --git a/src/ValueRepresentation.js b/src/ValueRepresentation.js index 04df1909..954c81c0 100644 --- a/src/ValueRepresentation.js +++ b/src/ValueRepresentation.js @@ -137,7 +137,8 @@ class ValueRepresentation { length ); } - return this.readBytes(stream, length, syntax); + const rawValue = this.readBytes(stream, length, syntax); + return { rawValue, value: this.applyFormatting(rawValue) }; } applyFormatting(value) { @@ -641,7 +642,14 @@ class DecimalString extends AsciiStringRepresentation { } readBytes(stream, length) { - return stream.readAsciiString(length); + const BACKSLASH = String.fromCharCode(VM_DELIMITER); + const ds = stream.readAsciiString(length); + if (ds.indexOf(BACKSLASH) !== -1) { + // handle decimal string with multiplicity + return ds.split(BACKSLASH); + } + + return [ds]; } applyFormatting(value) { @@ -771,7 +779,15 @@ class IntegerString extends AsciiStringRepresentation { } readBytes(stream, length) { - return stream.readAsciiString(length); + const BACKSLASH = String.fromCharCode(VM_DELIMITER); + const is = stream.readAsciiString(length); + + if (is.indexOf(BACKSLASH) !== -1) { + // handle integer string with multiplicity + return is.split(BACKSLASH); + } + + return [is]; } applyFormatting(value) { @@ -900,11 +916,17 @@ class PersonName extends EncodedStringRepresentation { } readBytes(stream, length) { - return this.readPaddedEncodedString(stream, length); + return this.readPaddedEncodedString(stream, length).split(String.fromCharCode(VM_DELIMITER)); } applyFormatting(value) { - return dicomJson.pnConvertToJsonObject(value); + const parsePersonName = (valueStr) => dicomJson.pnConvertToJsonObject(valueStr, false); + + if (Array.isArray(value)) { + return value.map((valueStr) => parsePersonName(valueStr)); + } + + return parsePersonName(value); } writeBytes(stream, value, writeOptions) { @@ -1235,25 +1257,25 @@ class UniqueIdentifier extends AsciiStringRepresentation { } readBytes(stream, length) { - return this.readPaddedAsciiString(stream, length); - // - // const BACKSLASH = String.fromCharCode(VM_DELIMITER); - // - // // Treat backslashes as a delimiter for multiple UIDs, in which case an - // // array of UIDs is returned. This is used by DICOM Q&R to support - // // querying and matching multiple items on a UID field in a single - // // query. For more details see: - // // - // // https://dicom.nema.org/medical/dicom/current/output/chtml/part04/sect_C.2.2.2.2.html - // // https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.4.html + const result = this.readPaddedAsciiString(stream, length); + const BACKSLASH = String.fromCharCode(VM_DELIMITER); + + // Treat backslashes as a delimiter for multiple UIDs, in which case an + // array of UIDs is returned. This is used by DICOM Q&R to support + // querying and matching multiple items on a UID field in a single + // query. For more details see: // - // if (result.indexOf(BACKSLASH) === -1) { - // return result - // } else { - // return result.split(BACKSLASH) - // } + // https://dicom.nema.org/medical/dicom/current/output/chtml/part04/sect_C.2.2.2.2.html + // https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.4.html + + if (result.indexOf(BACKSLASH) === -1) { + return result + } else { + return result.split(BACKSLASH) + } } + // TODO: Can we make the array formatting generic in value representaiton base applyFormatting(value) { const removeInvalidUidChars = (uidStr) => { return uidStr.replace(/[^0-9.]/g, ""); diff --git a/test/data.test.js b/test/data.test.js index f29a7843..96ce038b 100644 --- a/test/data.test.js +++ b/test/data.test.js @@ -348,6 +348,7 @@ it("test_null_number_vrs", () => { expect(dataset.InstanceNumber).toEqual(null); }); +// TODO Craig: Fix writing logic then compare actual differences (need to look at specific character set) it("test_exponential_notation", () => { const file = fs.readFileSync("test/sample-dicom.dcm"); const data = dcmjs.data.DicomMessage.readFile(file.buffer, { @@ -1106,6 +1107,7 @@ describe("The same DICOM file loaded from both DCM and JSON", () => { }); }); +// TODO Craig: Need to add more tests here describe("test_un_vr", () => { it("Tag with UN vr should be parsed according VR in dictionary", async () => { const expectedExposureIndex = 662; From 9758ebe6b6ca94f0466099fa0b771cf8299c1054 Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Thu, 15 Aug 2024 17:41:58 -0400 Subject: [PATCH 05/16] Implement equality between original and formatted value on write, add deep equals implementation and tests --- src/DicomMessage.js | 22 ++++++++- src/ValueRepresentation.js | 11 ++++- src/utilities/deepEqual.js | 26 +++++++++++ test/utilities/deepEqual.test.js | 78 ++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/utilities/deepEqual.js create mode 100644 test/utilities/deepEqual.test.js diff --git a/src/DicomMessage.js b/src/DicomMessage.js index d4297980..67f878b6 100644 --- a/src/DicomMessage.js +++ b/src/DicomMessage.js @@ -10,6 +10,7 @@ import { DicomDict } from "./DicomDict.js"; import { DicomMetaDictionary } from "./DicomMetaDictionary.js"; import { Tag } from "./Tag.js"; import { log } from "./log.js"; +import { deepEqual } from "./utilities/deepEqual"; import { ValueRepresentation } from "./ValueRepresentation.js"; const singleVRs = ["SQ", "OF", "OW", "OB", "UN", "LT"]; @@ -252,8 +253,9 @@ class DicomMessage { sortedTags.forEach(function (tagString) { var tag = Tag.fromString(tagString), tagObject = jsonObjects[tagString], - vrType = tagObject.vr, - values = tagObject.Value; + vrType = tagObject.vr; + + var values = DicomMessage._getTagWriteValue(vrType, tagObject); written += tag.write( useStream, @@ -267,6 +269,22 @@ class DicomMessage { return written; } + static _getTagWriteValue(vrType, tagObject) { + const vr = ValueRepresentation.createByTypeString(vrType); + + if (!tagObject._rawValue) { + return tagObject.Value; + } + const compareValue = tagObject._rawValue.map((val) => vr.applyFormatting(val)) + + // if the _rawValue is unchanged, write it unformatted back to the file + if (deepEqual(compareValue, tagObject.Value)) { + return tagObject._rawValue; + } else { + return tagObject.Value; + } + } + static _readTag( stream, syntax, diff --git a/src/ValueRepresentation.js b/src/ValueRepresentation.js index 954c81c0..8bbcb7bf 100644 --- a/src/ValueRepresentation.js +++ b/src/ValueRepresentation.js @@ -583,11 +583,18 @@ class CodeString extends AsciiStringRepresentation { } readBytes(stream, length) { - return stream.readAsciiString(length); + const BACKSLASH = String.fromCharCode(VM_DELIMITER); + return stream.readAsciiString(length).split(BACKSLASH); } applyFormatting(value) { - return value.trim(); + const trim = (str) => str.trim(); + + if (Array.isArray(value)) { + return value.map((str) => trim(str)); + } + + return trim(value); } } diff --git a/src/utilities/deepEqual.js b/src/utilities/deepEqual.js new file mode 100644 index 00000000..28650081 --- /dev/null +++ b/src/utilities/deepEqual.js @@ -0,0 +1,26 @@ + +export function deepEqual(obj1, obj2) { + // Base case: If both objects are identical, return true. + if (Object.is(obj1, obj2)) { + return true; + } + // Check if both objects are objects and not null. + if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) { + return false; + } + // Get the keys of both objects. + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + // Check if the number of keys is the same. + if (keys1.length !== keys2.length) { + return false; + } + // Iterate through the keys and compare their values recursively. + for (const key of keys1) { + if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) { + return false; + } + } + // If all checks pass, the objects are deep equal. + return true; +} \ No newline at end of file diff --git a/test/utilities/deepEqual.test.js b/test/utilities/deepEqual.test.js new file mode 100644 index 00000000..99c4f543 --- /dev/null +++ b/test/utilities/deepEqual.test.js @@ -0,0 +1,78 @@ +import { deepEqual } from "../../src/utilities/deepEqual"; + +describe('deepEqual', () => { + test('returns true for identical primitives', () => { + expect(deepEqual(42, 42)).toBe(true); + expect(deepEqual('hello', 'hello')).toBe(true); + expect(deepEqual(true, true)).toBe(true); + expect(deepEqual(null, null)).toBe(true); + }); + + test('returns false for different primitives', () => { + expect(deepEqual(42, 43)).toBe(false); + expect(deepEqual('hello', 'world')).toBe(false); + expect(deepEqual(true, false)).toBe(false); + expect(deepEqual(null, undefined)).toBe(false); + }); + + test('verify special mathematical numbers', () => { + expect(deepEqual(Math.NaN, Math.NaN)).toBe(true); + expect(deepEqual(-0, 0)).toBe(false); + expect(deepEqual(-0, +0)).toBe(false); + }) + + test('returns true for deeply equal objects', () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { a: 1, b: { c: 2 } }; + expect(deepEqual(obj1, obj2)).toBe(true); + }); + + test('returns false for objects with different structures', () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { a: 1, b: { d: 2 } }; + expect(deepEqual(obj1, obj2)).toBe(false); + }); + + test('returns false for objects with different values', () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { a: 1, b: { c: 3 } }; + expect(deepEqual(obj1, obj2)).toBe(false); + }); + + test('returns true for deeply equal arrays', () => { + const arr1 = [1, 2, { a: 3 }]; + const arr2 = [1, 2, { a: 3 }]; + expect(deepEqual(arr1, arr2)).toBe(true); + }); + + test('returns false for arrays with different values', () => { + const arr1 = [1, 2, { a: 3 }]; + const arr2 = [1, 2, { a: 4 }]; + expect(deepEqual(arr1, arr2)).toBe(false); + }); + + test('returns false for objects compared with arrays', () => { + const obj = { a: 1, b: 2 }; + const arr = [1, 2]; + expect(deepEqual(obj, arr)).toBe(false); + }); + + test('returns false for different object types', () => { + const date1 = new Date(2024, 0, 1); + const date2 = new Date(2024, 0, 1); + const obj1 = { a: 1, b: 2 }; + expect(deepEqual(date1, obj1)).toBe(false); + }); + + test('returns true for nested objects with arrays', () => { + const obj1 = { a: 1, b: [1, 2, { c: 3 }] }; + const obj2 = { a: 1, b: [1, 2, { c: 3 }] }; + expect(deepEqual(obj1, obj2)).toBe(true); + }); + + test('returns false for functions, as they should not be equal', () => { + const obj1 = { a: 1, b: function() { return 2; } }; + const obj2 = { a: 1, b: function() { return 2; } }; + expect(deepEqual(obj1, obj2)).toBe(false); + }); +}); \ No newline at end of file From 20d36390d29e8b6fb9bb3eee2366e5e85f70ea54 Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Thu, 15 Aug 2024 17:42:23 -0400 Subject: [PATCH 06/16] Add POC lossless-round-trip test with sample file from data repo --- test/lossless-read-write.test.js | 88 ++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 test/lossless-read-write.test.js diff --git a/test/lossless-read-write.test.js b/test/lossless-read-write.test.js new file mode 100644 index 00000000..77a59fbc --- /dev/null +++ b/test/lossless-read-write.test.js @@ -0,0 +1,88 @@ +import "regenerator-runtime/runtime.js"; + +import fs from "fs"; +import path from "path"; +import dcmjs from "../src/index.js"; +import {deepEqual} from "../src/utilities/deepEqual"; + +import {getTestDataset, getZippedTestDataset} from "./testUtils"; + +const { DicomMetaDictionary, DicomDict, DicomMessage, ReadBufferStream } = + dcmjs.data; + +// export const PIXEL_DATA_HEX_TAG = '7FE00010'; +// +// // compare files at binary level +// const compareArrayBuffers = (buf1, buf2) => { +// const dv1 = new Int8Array(buf1); +// const dv2 = new Int8Array(buf2); +// for (let i = 0; i < buf1.byteLength; i += 1) { +// if (dv1[i] !== dv2[i]) { +// return false; +// } +// } +// if (buf1.byteLength !== buf2.byteLength) return false; +// return true; +// }; + +describe('lossless-read-write', () => { + // test('reading and writing a file should not change the underlying data', async () => { + // // const url = "https://github.com/dcmjs-org/data/releases/download/encapsulation/encapsulation.dcm"; + // // const dcmPath = await getTestDataset(url, "encapsulation.dcm"); + // + // const inputBuffer = fs.readFileSync("test/terumo.dcm").buffer; + // + // // given + // // const inputBuffer = fs.readFileSync(dcmPath).buffer; + // const dicomDict = DicomMessage.readFile(inputBuffer); + // + // const outputBuffer = dicomDict.write({ fragmentMultiframe: true, allowInvalidVRLength: false }); + // const outputDicomDict = DicomMessage.readFile(outputBuffer); + // + // + // + // fs.writeFile(`terumo-dcmjs-multifragment.dcm`, Buffer.from(outputBuffer), function (err) { + // if (err) { + // return console.log(err); + // } + // console.log("The file was saved!"); + // }); + // + // expect( + // compareArrayBuffers( + // dicomDict.dict[PIXEL_DATA_HEX_TAG].Value[0], + // outputDicomDict.dict[PIXEL_DATA_HEX_TAG].Value[0] + // ) + // ).toEqual(true); + // + // expect(compareArrayBuffers(inputBuffer, outputBuffer)).toEqual(true); + // }); + + test('test', async () => { + const inputBuffer = await getDcmjsDataFile("unknown-VR", "sample-dicom-with-un-vr.dcm"); + const dicomDict = DicomMessage.readFile(inputBuffer); + + // confirm raw string representation of DS contains extra additional metadata + // represented by bytes [30 2E 31 34 30 5C 30 2E 31 34 30 20] + expect(dicomDict.dict['00280030']._rawValue).toEqual(["0.140", "0.140 "]) + expect(dicomDict.dict['00280030'].Value).toEqual([0.14, 0.14]) + + // confirm after write raw values are re-encoded + const outputBuffer = dicomDict.write(); + const outputDicomDict = DicomMessage.readFile(outputBuffer); + + // explicitly verify for DS for clarity + expect(outputDicomDict.dict['00280030']._rawValue).toEqual(["0.140", "0.140 "]) + expect(outputDicomDict.dict['00280030'].Value).toEqual([0.14, 0.14]) + + // lossless read/write should match entire data set + deepEqual(dicomDict.dict, outputDicomDict.dict) + }); +}); + +const getDcmjsDataFile = async (release, fileName) => { + const url = "https://github.com/dcmjs-org/data/releases/download/" + release + "/" + fileName; + const dcmPath = await getTestDataset(url, fileName); + + return fs.readFileSync(dcmPath).buffer; +} From c503c9b73fa02b1fb547b784f31187abe360e64b Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Thu, 15 Aug 2024 22:22:17 -0400 Subject: [PATCH 07/16] Add specific DS tests and first round of general VR tests --- test/lossless-read-write.test.js | 160 ++++++++++++++++++++++++------- 1 file changed, 127 insertions(+), 33 deletions(-) diff --git a/test/lossless-read-write.test.js b/test/lossless-read-write.test.js index 77a59fbc..98d8eade 100644 --- a/test/lossless-read-write.test.js +++ b/test/lossless-read-write.test.js @@ -26,39 +26,133 @@ const { DicomMetaDictionary, DicomDict, DicomMessage, ReadBufferStream } = // }; describe('lossless-read-write', () => { - // test('reading and writing a file should not change the underlying data', async () => { - // // const url = "https://github.com/dcmjs-org/data/releases/download/encapsulation/encapsulation.dcm"; - // // const dcmPath = await getTestDataset(url, "encapsulation.dcm"); - // - // const inputBuffer = fs.readFileSync("test/terumo.dcm").buffer; - // - // // given - // // const inputBuffer = fs.readFileSync(dcmPath).buffer; - // const dicomDict = DicomMessage.readFile(inputBuffer); - // - // const outputBuffer = dicomDict.write({ fragmentMultiframe: true, allowInvalidVRLength: false }); - // const outputDicomDict = DicomMessage.readFile(outputBuffer); - // - // - // - // fs.writeFile(`terumo-dcmjs-multifragment.dcm`, Buffer.from(outputBuffer), function (err) { - // if (err) { - // return console.log(err); - // } - // console.log("The file was saved!"); - // }); - // - // expect( - // compareArrayBuffers( - // dicomDict.dict[PIXEL_DATA_HEX_TAG].Value[0], - // outputDicomDict.dict[PIXEL_DATA_HEX_TAG].Value[0] - // ) - // ).toEqual(true); - // - // expect(compareArrayBuffers(inputBuffer, outputBuffer)).toEqual(true); - // }); - - test('test', async () => { + + test('test DS value with additional allowed characters is written to file', () => { + const dataset = { + '00181041': { + _rawValue: [" +1.4000 ", "-0.00", "1.2345e2", "1E34"], + Value: [1.4, -0, 123.45, 1e+34], + vr: 'DS' + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + + // expect raw value to be unchanged, and Value parsed as Number to lose precision + expect(outputDicomDict.dict['00181041']._rawValue).toEqual([" +1.4000 ", "-0.00", "1.2345e2", "1E34"]) + expect(outputDicomDict.dict['00181041'].Value).toEqual([1.4, -0, 123.45, 1e+34]) + }); + + test('test DS value that exceeds Number.MAX_SAFE_INTEGER is written to file', () => { + const dataset = { + '00181041': { + _rawValue: ["9007199254740993"], + Value: [9007199254740993], + vr: 'DS' + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + + // expect raw value to be unchanged, and Value parsed as Number to lose precision + expect(outputDicomDict.dict['00181041']._rawValue).toEqual(["9007199254740993"]) + expect(outputDicomDict.dict['00181041'].Value).toEqual([9007199254740992]) + }); + + const unchangedTestCases = [ + { + vr: "AE", + _rawValue: [" TEST_AE "], // spaces non-significant for interpretation but allowed + Value: ["TEST_AE"], + }, + { + vr: "AS", + _rawValue: ["045Y"], + Value: ["045Y"], + }, + { + vr: "AT", + _rawValue: [0x00207E14], + Value: [0x00207E14], + }, + { + vr: "CS", + _rawValue: ["ORIGINAL ", " PRIMARY "], // spaces non-significant for interpretation but allowed + Value: ["ORIGINAL", "PRIMARY"], + }, + { + vr: "DA", + _rawValue: ["20240101"], + Value: ["20240101"], + }, + { + vr: "DS", + _rawValue: ["0000123.45"], // leading zeros allowed + Value: [123.45], + }, + { + vr: 'DT', + _rawValue: ["20240101123045.1 "], // trailing spaces allowed + Value: ["20240101123045.1 "], + }, + { + vr: 'FL', + _rawValue: [3.125], + Value: [3.125], + }, + { + vr: 'FD', + _rawValue: [3.14159265358979], // trailing spaces allowed + Value: [3.14159265358979], + }, + { + vr: 'IS', + _rawValue: [" -123 "], // leading/trailing spaces & sign allowed + Value: [-123], + }, + { + vr: 'LO', + _rawValue: [" A long string with spaces "], // leading/trailing spaces allowed + Value: ["A long string with spaces"], + }, + // { + // vr: 'LT', + // _rawValue: ["It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b."], + // Value: ["It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b."], + // }, + ]; + + + test.each(unchangedTestCases)( + "Test unchanged value is retained following read and write", + (dataElement) => { + const dataset = { + '00181041': { + ...dataElement + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + + // expect raw value to be unchanged, and Value parsed as Number to lose precision + expect(outputDicomDict.dict['00181041']._rawValue).toEqual(dataElement._rawValue) + expect(outputDicomDict.dict['00181041'].Value).toEqual(dataElement.Value) + } + ) + + test('File dataset should be equal after read and write', async () => { const inputBuffer = await getDcmjsDataFile("unknown-VR", "sample-dicom-with-un-vr.dcm"); const dicomDict = DicomMessage.readFile(inputBuffer); From 7b9f5fa88587634790c1089aae4ec2258b614df4 Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Fri, 16 Aug 2024 08:23:44 -0400 Subject: [PATCH 08/16] Cover all VRs with retain test --- test/lossless-read-write.test.js | 112 +++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/test/lossless-read-write.test.js b/test/lossless-read-write.test.js index 98d8eade..7ad67485 100644 --- a/test/lossless-read-write.test.js +++ b/test/lossless-read-write.test.js @@ -123,16 +123,118 @@ describe('lossless-read-write', () => { _rawValue: [" A long string with spaces "], // leading/trailing spaces allowed Value: ["A long string with spaces"], }, + { + vr: 'LT', + _rawValue: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b. "], // leading spaces significant, trailing spaces allowed + Value: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b."], + }, + { + vr: 'OB', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + }, + { + vr: 'OD', + _rawValue: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], + }, + { + vr: 'OF', + _rawValue: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], + }, + // TODO: VRs currently unimplemented // { - // vr: 'LT', - // _rawValue: ["It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b."], - // Value: ["It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b."], + // vr: 'OL', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], + // Value: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + // { + // vr: 'OV', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], + // Value: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + { + vr: 'OW', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + }, + { + vr: 'PN', + _rawValue: ["Doe^John^A^Jr.^MD "], // trailing spaces allowed + Value: [{"Alphabetic": "Doe^John^A^Jr.^MD "}], + }, + { + vr: 'SH', + _rawValue: [" CT_SCAN_01 "], // leading/trailing spaces allowed + Value: ["CT_SCAN_01"], + }, + { + vr: 'SL', + _rawValue: [-2147483648], + Value: [-2147483648], + }, + { + vr: 'SS', + _rawValue: [-32768], + Value: [-32768], + }, + { + vr: 'ST', + _rawValue: ["Patient complains of headaches over the last week. "], // trailing spaces allowed + Value: ["Patient complains of headaches over the last week."], + }, + // TODO: VR currently unimplemented + // { + // vr: 'SV', + // _rawValue: [9007199254740993], // trailing spaces allowed + // Value: [9007199254740993], + // }, + { + vr: 'TM', + _rawValue: ["42530.123456 "], // trailing spaces allowed + Value: ["42530.123456"], + }, + { + vr: 'UC', + _rawValue: ["Detailed description of procedure or clinical notes that could be very long. "], // trailing spaces allowed + Value: ["Detailed description of procedure or clinical notes that could be very long."], + }, + { + vr: 'UI', + _rawValue: ["1.2.840.10008.1.2.1"], + Value: ["1.2.840.10008.1.2.1"], + }, + { + vr: 'UL', + _rawValue: [4294967295], + Value: [4294967295], + }, + { + vr: 'UR', + _rawValue: ["http://dicom.nema.org "], // trailing spaces ignored but allowed + Value: ["http://dicom.nema.org "], + }, + { + vr: 'US', + _rawValue: [65535], + Value: [65535], + }, + { + vr: 'UT', + _rawValue: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset. "], // leading spaces significant, trailing spaces allowed + Value: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset."], + }, + // TODO: VR currently unimplemented + // { + // vr: 'UV', + // _rawValue: [18446744073709551616], // 2^64 + // Value: [18446744073709551616], // }, ]; - test.each(unchangedTestCases)( - "Test unchanged value is retained following read and write", + `Test unchanged value is retained following read and write - $vr`, (dataElement) => { const dataset = { '00181041': { From 5775ff6fc9cac2aa908df97d8ceba04c99d2d57f Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Fri, 16 Aug 2024 11:15:19 -0400 Subject: [PATCH 09/16] Fix exponential notation unit test expect --- test/data.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/data.test.js b/test/data.test.js index 96ce038b..cba81fc5 100644 --- a/test/data.test.js +++ b/test/data.test.js @@ -348,7 +348,6 @@ it("test_null_number_vrs", () => { expect(dataset.InstanceNumber).toEqual(null); }); -// TODO Craig: Fix writing logic then compare actual differences (need to look at specific character set) it("test_exponential_notation", () => { const file = fs.readFileSync("test/sample-dicom.dcm"); const data = dcmjs.data.DicomMessage.readFile(file.buffer, { @@ -358,7 +357,9 @@ it("test_exponential_notation", () => { dataset.ImagePositionPatient[2] = 7.1945578383e-5; const buffer = data.write(); const copy = dcmjs.data.DicomMessage.readFile(buffer); - expect(JSON.stringify(data)).toEqual(JSON.stringify(copy)); + const datasetCopy = dcmjs.data.DicomMetaDictionary.naturalizeDataset(copy.dict); + + expect(dataset.ImagePositionPatient).toEqual(datasetCopy.ImagePositionPatient); }); it("test_output_equality", () => { From 1f7a641b85a7bee8196627e1424ed70db3941771 Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Fri, 16 Aug 2024 13:39:41 -0400 Subject: [PATCH 10/16] Update ParsedUnknownValue read logic and add different VR test coverage --- src/ValueRepresentation.js | 27 ++--- test/data.test.js | 175 +++++++++++++++++++++++++++++++ test/lossless-read-write.test.js | 8 +- 3 files changed, 188 insertions(+), 22 deletions(-) diff --git a/src/ValueRepresentation.js b/src/ValueRepresentation.js index 8bbcb7bf..0e62a6d6 100644 --- a/src/ValueRepresentation.js +++ b/src/ValueRepresentation.js @@ -1334,30 +1334,21 @@ class ParsedUnknownValue extends BinaryRepresentation { const streamFromBuffer = new ReadBufferStream(arrayBuffer, true); const vr = ValueRepresentation.createByTypeString(this.type); - var values = []; if (vr.isBinary() && length > vr.maxLength && !vr.noMultiple) { + var values = []; + var rawValues = []; var times = length / vr.maxLength, i = 0; + while (i++ < times) { - values.push(vr.read(streamFromBuffer, vr.maxLength, syntax)); + const { rawValue, value } = vr.read(streamFromBuffer, vr.maxLength, syntax); + rawValues.push(rawValue); + values.push(value); } + return { rawValue: rawValues, value: values }; } else { - var val = vr.read(streamFromBuffer, length, syntax); - if (!vr.isBinary() && singleVRs.indexOf(vr.type) == -1) { - values = val; - if (typeof val === "string") { - values = val.split(String.fromCharCode(VM_DELIMITER)); - } - } else if (vr.type == "SQ") { - values = val; - } else if (vr.type == "OW" || vr.type == "OB") { - values = val; - } else { - Array.isArray(val) ? (values = val) : values.push(val); - } + return vr.read(streamFromBuffer, length, syntax); } - - return values; } } @@ -1431,4 +1422,4 @@ let VRinstances = { UT: new UnlimitedText() }; -export { ValueRepresentation }; +export {ValueRepresentation}; diff --git a/test/data.test.js b/test/data.test.js index cba81fc5..18a26eb7 100644 --- a/test/data.test.js +++ b/test/data.test.js @@ -1131,6 +1131,181 @@ describe("test_un_vr", () => { expect(dataset.ExposureIndex).toEqual(expectedExposureIndex); expect(dataset.DeviationIndex).toEqual(expectedDeviationIndex); }); + + describe("Test other VRs encoded as UN", () => { + test.each([ + [ + '00000600', + 'AE', + new Uint8Array([0x20, 0x20, 0x54, 0x45, 0x53, 0x54, 0x5F, 0x41, 0x45, 0x20]).buffer, + [" TEST_AE "], + ["TEST_AE"] + ], + [ + '00101010', + 'AS', + new Uint8Array([0x30, 0x34, 0x35, 0x59]).buffer, + ["045Y"], + ["045Y"] + ], + [ + '00280009', + 'AT', + new Uint8Array([0x63, 0x10, 0x18, 0x00]).buffer, + [0x10630018], + [0x10630018] + ], + [ + '00041130', + 'CS', + new Uint8Array([0x4F, 0x52, 0x49, 0x47, 0x49, 0x4E, 0x41, 0x4C, 0x20, 0x20, 0x5C, 0x20, 0x50, 0x52, 0x49, 0x4D, 0x41, 0x52, 0x59, 0x20]).buffer, + ["ORIGINAL ", " PRIMARY "], + ["ORIGINAL", "PRIMARY"] + ], + [ + '00181012', + 'DA', + new Uint8Array([0x32, 0x30, 0x32, 0x34, 0x30, 0x31, 0x30, 0x31]).buffer, + ["20240101"], + ["20240101"] + ], + [ + '00181041', + 'DS', + new Uint8Array([0x30, 0x30, 0x30, 0x30, 0x31, 0x32, 0x33, 0x2E, 0x34, 0x35]).buffer, + ["0000123.45"], + [123.45] + ], + [ + '00181078', + 'DT', + new Uint8Array([0x32, 0x30, 0x32, 0x34, 0x30, 0x31, 0x30, 0x31, 0x31, 0x32, 0x33, 0x30, 0x34, 0x35, 0x2E, 0x31, 0x20, 0x20]).buffer, + ["20240101123045.1 "], + ["20240101123045.1 "] + ], + [ + '00182043', + 'FL', + new Uint8Array([0x66, 0x66, 0xA6, 0x3F, 0x66, 0x66, 0xA6, 0x3F]).buffer, + [1.2999999523162842, 1.2999999523162842], + [1.2999999523162842, 1.2999999523162842] + ], + [ + '00186028', + 'FD', + new Uint8Array([0x11, 0x2D, 0x44, 0x54, 0xFB, 0x21, 0x09, 0x40]).buffer, + [3.14159265358979], + [3.14159265358979] + ], + [ + '00200012', + 'IS', + new Uint8Array([0x20,0x2B,0x32,0x37,0x38,0x39,0x33,0x20]).buffer, + [" +27893 "], + [27893] + ], + [ + '0018702A', + 'LO', + new Uint8Array([0x20,0x20,0x46,0x65,0x65,0x6C,0x69,0x6E,0x67,0x20,0x6E,0x61,0x75,0x73,0x65,0x6F,0x75,0x73,0x20,0x20]).buffer, + [" Feeling nauseous "], + ["Feeling nauseous"] + ], + [ + '00187040', + 'LT', + new Uint8Array([0x20,0x20,0x46,0x65,0x65,0x6C,0x69,0x6E,0x67,0x20,0x6E,0x61,0x75,0x73,0x65,0x6F,0x75,0x73,0x20,0x20]).buffer, + [" Feeling nauseous "], + [" Feeling nauseous"] + ], + [ + '00282000', + 'OB', + new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer, + [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer] + ], + [ + '00701A07', + 'OD', + new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer, + [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], + [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer] + ], + [ + '00720067', + 'OF', + new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer, + [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], + [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer] + ], + [ + '00281224', + 'OW', + new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer, + [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer] + ], + [ + '00080090', + 'PN', + new Uint8Array([0x44, 0x6F, 0x65, 0x5E, 0x4A, 0x6F, 0x68, 0x6E, 0x5E, 0x41, 0x5E, 0x4A, 0x72, 0x2E, 0x5E, 0x4D, 0x44, 0x3D, 0x44, 0x6F, 0x65, 0x5E, 0x4A, 0x61, 0x79, 0x5E, 0x41, 0x5E, 0x4A, 0x72, 0x2E, 0x20]).buffer, + ["Doe^John^A^Jr.^MD=Doe^Jay^A^Jr."], + [{"Alphabetic": "Doe^John^A^Jr.^MD", "Ideographic": "Doe^Jay^A^Jr."}] + ], + [ + '00080094', + 'SH', + new Uint8Array([0x43,0x54,0x5F,0x53,0x43,0x41,0x4E,0x5F,0x30,0x31]).buffer, + ["CT_SCAN_01"], + ["CT_SCAN_01"] + ], + [ + '00186020', + 'SL', + new Uint8Array([0x40, 0xE2, 0x01, 0x00, 0x40, 0xE2, 0x01, 0x00]).buffer, + [123456, 123456], + [123456, 123456] + ], + [ + '00189219', + 'SS', + new Uint8Array([0xD2, 0x04, 0xD2, 0x04, 0xD2, 0x04]).buffer, + [1234, 1234, 1234], + [1234, 1234, 1234] + ], + [ + '00189373', + 'ST', + new Uint8Array([0x20,0x20,0x46,0x65,0x65,0x6C,0x69,0x6E,0x67,0x20,0x6E,0x61,0x75,0x73,0x65,0x6F,0x75,0x73,0x20,0x20]).buffer, + [" Feeling nauseous "], + [" Feeling nauseous"] + ], + ])( + "for tag %s with expected VR %p", + (tag, vr, byteArray, expectedRawValue, expectedValue) => { + // setup input tag as UN + const dataset = { + [tag]: { + vr: "UN", + _rawValue: [byteArray], + Value: [byteArray], + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // Write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + + // Expect tag to be parsed correctly based on meta dictionary vr lookup + expect(outputDicomDict.dict[tag].vr).toEqual(vr); + expect(outputDicomDict.dict[tag]._rawValue).toEqual(expectedRawValue); + expect(outputDicomDict.dict[tag].Value).toEqual(expectedValue); + } + ); + }); }); it.each([ diff --git a/test/lossless-read-write.test.js b/test/lossless-read-write.test.js index 7ad67485..2c9634bf 100644 --- a/test/lossless-read-write.test.js +++ b/test/lossless-read-write.test.js @@ -80,8 +80,8 @@ describe('lossless-read-write', () => { }, { vr: "AT", - _rawValue: [0x00207E14], - Value: [0x00207E14], + _rawValue: [0x00207E14, 0x0012839A], + Value: [0x00207E14, 0x0012839A], }, { vr: "CS", @@ -176,8 +176,8 @@ describe('lossless-read-write', () => { }, { vr: 'SS', - _rawValue: [-32768], - Value: [-32768], + _rawValue: [-32768, 1234, 832], + Value: [-32768, 1234, 832], }, { vr: 'ST', From c6ce6561663f3e9521115b15dbda4c826be3f853 Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Fri, 16 Aug 2024 14:22:23 -0400 Subject: [PATCH 11/16] Add remaining VRs for UN parsing --- test/data.test.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/data.test.js b/test/data.test.js index 18a26eb7..93681808 100644 --- a/test/data.test.js +++ b/test/data.test.js @@ -1281,6 +1281,55 @@ describe("test_un_vr", () => { [" Feeling nauseous "], [" Feeling nauseous"] ], + [ + '21000050', + 'TM', + new Uint8Array([0x34,0x32,0x35,0x33,0x30,0x2E,0x31,0x32,0x33,0x34,0x35,0x36]).buffer, + ["42530.123456"], + ["42530.123456"] + ], + [ + '3010001B', + 'UC', + new Uint8Array([0x54, 0x72, 0x61, 0x69, 0x6C, 0x69, 0x6E, 0x67, 0x20, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x20, 0x61, 0x6C, 0x6C, 0x6F, 0x77, 0x65, 0x64, 0x20, 0x20, 0x20]).buffer, + ["Trailing spaces allowed "], + ["Trailing spaces allowed"] + ], + [ + '00041510', + 'UI', + new Uint8Array([0x31,0x2E,0x32,0x2E,0x38,0x34,0x30,0x2E,0x31,0x30,0x30,0x30,0x38,0x2E,0x31,0x2E,0x32,0x2E,0x31]).buffer, + ["1.2.840.10008.1.2.1"], + ["1.2.840.10008.1.2.1"] + ], + [ + '30100092', + 'UL', + new Uint8Array([0x40, 0xE2, 0x01, 0x00]).buffer, + [123456], + [123456] + ], + [ + '0008010E', + 'UR', + new Uint8Array([0x68,0x74,0x74,0x70,0x3A,0x2F,0x2F,0x64,0x69,0x63,0x6F,0x6D,0x2E,0x6E,0x65,0x6D,0x61,0x2E,0x6F,0x72,0x67, 0x20]).buffer, + ["http://dicom.nema.org "], + ["http://dicom.nema.org "], + ], + [ + '00080301', + 'US', + new Uint8Array([0xD2, 0x04]).buffer, + [1234], + [1234], + ], + [ + '0008030E', + 'UT', + new Uint8Array([0x20,0x20,0x46,0x65,0x65,0x6C,0x69,0x6E,0x67,0x20,0x6E,0x61,0x75,0x73,0x65,0x6F,0x75,0x73,0x20,0x20]).buffer, + [" Feeling nauseous "], + [" Feeling nauseous"] + ], ])( "for tag %s with expected VR %p", (tag, vr, byteArray, expectedRawValue, expectedValue) => { From 11d6dfb8a4833aa4db16f0ffaf48b943ec68115d Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Fri, 16 Aug 2024 14:38:16 -0400 Subject: [PATCH 12/16] Formatting and cleanup --- src/ValueRepresentation.js | 1 - src/utilities/deepEqual.js | 23 ++++++++++++++++------- test/data.test.js | 1 - test/utilities/deepEqual.test.js | 2 +- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/ValueRepresentation.js b/src/ValueRepresentation.js index 0e62a6d6..d465ae86 100644 --- a/src/ValueRepresentation.js +++ b/src/ValueRepresentation.js @@ -1317,7 +1317,6 @@ class UnknownValue extends BinaryRepresentation { } } -// TODO Craig: Need to come back and analyze this class ParsedUnknownValue extends BinaryRepresentation { constructor(vr) { super(vr); diff --git a/src/utilities/deepEqual.js b/src/utilities/deepEqual.js index 28650081..3537f70b 100644 --- a/src/utilities/deepEqual.js +++ b/src/utilities/deepEqual.js @@ -1,26 +1,35 @@ - +/** + * Performs a deep equality check between two objects. Used primarily during DICOM write operations + * to determine whether a data element underlying value has changed since it was initially read. + * + * @param {Object} obj1 - The first object to compare. + * @param {Object} obj2 - The second object to compare. + * @returns {boolean} - Returns `true` if the structures and values of the objects are deeply equal, `false` otherwise. + */ export function deepEqual(obj1, obj2) { - // Base case: If both objects are identical, return true. + // Use Object.is to consider for treatment of `NaN` and signed 0's i.e. `+0` or `-0` in IS/DS if (Object.is(obj1, obj2)) { return true; } - // Check if both objects are objects and not null. + + // expect objects or a null instance if initial check failed if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) { return false; } - // Get the keys of both objects. + + // all keys should match a deep equality check const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); - // Check if the number of keys is the same. + if (keys1.length !== keys2.length) { return false; } - // Iterate through the keys and compare their values recursively. + for (const key of keys1) { if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) { return false; } } - // If all checks pass, the objects are deep equal. + return true; } \ No newline at end of file diff --git a/test/data.test.js b/test/data.test.js index 93681808..c38bad49 100644 --- a/test/data.test.js +++ b/test/data.test.js @@ -1108,7 +1108,6 @@ describe("The same DICOM file loaded from both DCM and JSON", () => { }); }); -// TODO Craig: Need to add more tests here describe("test_un_vr", () => { it("Tag with UN vr should be parsed according VR in dictionary", async () => { const expectedExposureIndex = 662; diff --git a/test/utilities/deepEqual.test.js b/test/utilities/deepEqual.test.js index 99c4f543..700000d0 100644 --- a/test/utilities/deepEqual.test.js +++ b/test/utilities/deepEqual.test.js @@ -15,7 +15,7 @@ describe('deepEqual', () => { expect(deepEqual(null, undefined)).toBe(false); }); - test('verify special mathematical numbers', () => { + test('returns same value check for signed zeros and special numbers', () => { expect(deepEqual(Math.NaN, Math.NaN)).toBe(true); expect(deepEqual(-0, 0)).toBe(false); expect(deepEqual(-0, +0)).toBe(false); From cf43aaacd8ffa895ba443f1c52bb95ca31f3c45d Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Fri, 16 Aug 2024 15:06:39 -0400 Subject: [PATCH 13/16] Verify changed value is respected on write --- test/lossless-read-write.test.js | 201 ++++++++++++++++++++++++++++--- 1 file changed, 187 insertions(+), 14 deletions(-) diff --git a/test/lossless-read-write.test.js b/test/lossless-read-write.test.js index 2c9634bf..41afa7a3 100644 --- a/test/lossless-read-write.test.js +++ b/test/lossless-read-write.test.js @@ -10,20 +10,6 @@ import {getTestDataset, getZippedTestDataset} from "./testUtils"; const { DicomMetaDictionary, DicomDict, DicomMessage, ReadBufferStream } = dcmjs.data; -// export const PIXEL_DATA_HEX_TAG = '7FE00010'; -// -// // compare files at binary level -// const compareArrayBuffers = (buf1, buf2) => { -// const dv1 = new Int8Array(buf1); -// const dv2 = new Int8Array(buf2); -// for (let i = 0; i < buf1.byteLength; i += 1) { -// if (dv1[i] !== dv2[i]) { -// return false; -// } -// } -// if (buf1.byteLength !== buf2.byteLength) return false; -// return true; -// }; describe('lossless-read-write', () => { @@ -254,6 +240,193 @@ describe('lossless-read-write', () => { } ) + const changedTestCases = [ + { + vr: "AE", + _rawValue: [" TEST_AE "], // spaces non-significant for interpretation but allowed + Value: ["NEW_AE"], + }, + { + vr: "AS", + _rawValue: ["045Y"], + Value: ["999Y"], + }, + { + vr: "AT", + _rawValue: [0x00207E14, 0x0012839A], + Value: [0x00200010], + }, + { + vr: "CS", + _rawValue: ["ORIGINAL ", " PRIMARY "], // spaces non-significant for interpretation but allowed + Value: ["ORIGINAL", "PRIMARY", "SECONDARY"], + }, + { + vr: "DA", + _rawValue: ["20240101"], + Value: ["20231225"], + }, + { + vr: "DS", + _rawValue: ["0000123.45"], // leading zeros allowed + Value: [123.456], + newRawValue: ["123.456 "] + }, + { + vr: 'DT', + _rawValue: ["20240101123045.1 "], // trailing spaces allowed + Value: ["20240101123045.3"], + }, + { + vr: 'FL', + _rawValue: [3.125], + Value: [22], + }, + { + vr: 'FD', + _rawValue: [3.14159265358979], // trailing spaces allowed + Value: [50.1242], + }, + { + vr: 'IS', + _rawValue: [" -123 "], // leading/trailing spaces & sign allowed + Value: [0], + newRawValue: ["0 "] + }, + { + vr: 'LO', + _rawValue: [" A long string with spaces "], // leading/trailing spaces allowed + Value: ["A changed string that is still long."], + }, + { + vr: 'LT', + _rawValue: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b. "], // leading spaces significant, trailing spaces allowed + Value: [" A modified string of text"], + }, + { + vr: 'OB', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + Value: [new Uint8Array([0x01, 0x02]).buffer], + }, + { + vr: 'OD', + _rawValue: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x35, 0x6E, 0x9E, 0x42]).buffer], + }, + { + vr: 'OF', + _rawValue: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x43]).buffer], + }, + // TODO: VRs currently unimplemented + // { + // vr: 'OL', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + // { + // vr: 'OV', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + { + vr: 'OW', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x89, 0x91, 0x89, 0x89]).buffer], + Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + }, + { + vr: 'PN', + _rawValue: ["Doe^John^A^Jr.^MD "], // trailing spaces allowed + Value: [{"Alphabetic": "Doe^Jane^A^Jr.^MD"}], + newRawValue: ["Doe^Jane^A^Jr.^MD"] + }, + { + vr: 'SH', + _rawValue: [" CT_SCAN_01 "], // leading/trailing spaces allowed + Value: ["MR_SCAN_91"], + }, + { + vr: 'SL', + _rawValue: [-2147483648], + Value: [-2147481234], + }, + { + vr: 'SS', + _rawValue: [-32768, 1234, 832], + Value: [1234], + }, + { + vr: 'ST', + _rawValue: ["Patient complains of headaches over the last week. "], // trailing spaces allowed + Value: ["Patient complains of headaches"], + }, + // TODO: VR currently unimplemented + // { + // vr: 'SV', + // _rawValue: [9007199254740993], // trailing spaces allowed + // }, + { + vr: 'TM', + _rawValue: ["42530.123456 "], // trailing spaces allowed + Value: ["42530"], + newRawValue: ["42530 "] + }, + { + vr: 'UC', + _rawValue: ["Detailed description of procedure or clinical notes that could be very long. "], // trailing spaces allowed + Value: ["Detailed description of procedure and other things"], + }, + { + vr: 'UI', + _rawValue: ["1.2.840.10008.1.2.1"], + Value: ["1.2.840.10008.1.2.2"], + }, + { + vr: 'UL', + _rawValue: [4294967295], + Value: [1], + }, + { + vr: 'UR', + _rawValue: ["http://dicom.nema.org "], // trailing spaces ignored but allowed + Value: ["https://github.com/dcmjs-org"], + }, + { + vr: 'US', + _rawValue: [65535], + Value: [1], + }, + { + vr: 'UT', + _rawValue: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset. "], // leading spaces significant, trailing spaces allowed + Value: [""], + }, + // TODO: VR currently unimplemented + // { + // vr: 'UV', + // _rawValue: [18446744073709551616], // 2^64 + // }, + ]; + + test.each(changedTestCases)( + `Test changed value overwrites original value following read and write - $vr`, + (dataElement) => { + const dataset = { + '00181041': { + ...dataElement + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + + // expect raw value to be updated to match new Value parsed as Number to lose precision + expect(outputDicomDict.dict['00181041']._rawValue).toEqual(dataElement.newRawValue ?? dataElement.Value) + expect(outputDicomDict.dict['00181041'].Value).toEqual(dataElement.Value) + } + ) + test('File dataset should be equal after read and write', async () => { const inputBuffer = await getDcmjsDataFile("unknown-VR", "sample-dicom-with-un-vr.dcm"); const dicomDict = DicomMessage.readFile(inputBuffer); From 36908870e6946d1dfa14eb33650dfbd8b506935a Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Fri, 16 Aug 2024 16:13:08 -0400 Subject: [PATCH 14/16] Add flag opt in/out of raw storage for things like pixel data or sequences --- src/DicomMessage.js | 11 +++-- src/ValueRepresentation.js | 34 ++++++++++--- test/data.test.js | 2 +- test/lossless-read-write.test.js | 82 +++++++++++++++++++++++++++++++- 4 files changed, 115 insertions(+), 14 deletions(-) diff --git a/src/DicomMessage.js b/src/DicomMessage.js index 67f878b6..178ddcf0 100644 --- a/src/DicomMessage.js +++ b/src/DicomMessage.js @@ -195,7 +195,8 @@ class DicomMessage { ignoreErrors: false, untilTag: null, includeUntilTagValue: false, - noCopy: false + noCopy: false, + forceStoreRaw: false } ) { var stream = new ReadBufferStream(buffer, null, { @@ -270,11 +271,11 @@ class DicomMessage { } static _getTagWriteValue(vrType, tagObject) { - const vr = ValueRepresentation.createByTypeString(vrType); - if (!tagObject._rawValue) { return tagObject.Value; } + + const vr = ValueRepresentation.createByTypeString(vrType); const compareValue = tagObject._rawValue.map((val) => vr.applyFormatting(val)) // if the _rawValue is unchanged, write it unformatted back to the file @@ -364,12 +365,12 @@ class DicomMessage { var times = length / vr.maxLength, i = 0; while (i++ < times) { - const { rawValue, value } = vr.read(stream, vr.maxLength, syntax); + const { rawValue, value } = vr.read(stream, vr.maxLength, syntax, options); rawValues.push(rawValue); values.push(value); } } else { - const { rawValue, value } = vr.read(stream, length, syntax); + const { rawValue, value } = vr.read(stream, length, syntax, options); if (!vr.isBinary() && singleVRs.indexOf(vr.type) == -1) { rawValues = rawValue; values = value diff --git a/src/ValueRepresentation.js b/src/ValueRepresentation.js index d465ae86..c110a4f3 100644 --- a/src/ValueRepresentation.js +++ b/src/ValueRepresentation.js @@ -79,6 +79,7 @@ class ValueRepresentation { this._allowMultiple = !this._isBinary && singleVRs.indexOf(this.type) == -1; this._isExplicit = explicitVRs.indexOf(this.type) != -1; + this._storeRaw = true; } static setDicomMessageClass(dicomMessageClass) { @@ -101,6 +102,17 @@ class ValueRepresentation { return this._isExplicit; } + /** + * Flag that specifies whether to store the original unformatted value that is read from the dicom input buffer. + * The `_rawValue` is used for lossless round trip processing, which preserves data (whitespace, special chars) on write + * that may be lost after casting to other data structures like Number, or applying formatting for readability. + * + * Example: DecimalString: _rawValue: ["-0.000"], Value: [0] + */ + storeRaw() { + return this._storeRaw; + } + addValueAccessors(value) { return value; } @@ -124,7 +136,7 @@ class ValueRepresentation { return tag; } - read(stream, length, syntax) { + read(stream, length, syntax, readOptions = { forceStoreRaw: false }) { if (this.fixed && this.maxLength) { if (!length) return this.defaultValue; if (this.maxLength != length) @@ -137,8 +149,15 @@ class ValueRepresentation { length ); } - const rawValue = this.readBytes(stream, length, syntax); - return { rawValue, value: this.applyFormatting(rawValue) }; + let rawValue = this.readBytes(stream, length, syntax); + const value = this.applyFormatting(rawValue); + + // avoid duplicating large binary data structures like pixel data which are unlikely to be formatted or directly manipulated + if (!this.storeRaw() && !readOptions.forceStoreRaw) { + rawValue = undefined; + } + + return { rawValue, value }; } applyFormatting(value) { @@ -327,6 +346,7 @@ class EncodedStringRepresentation extends ValueRepresentation { class BinaryRepresentation extends ValueRepresentation { constructor(type) { super(type); + this._storeRaw = false; } writeBytes(stream, value, syntax, isEncapsulated, writeOptions = {}) { @@ -990,6 +1010,7 @@ class SequenceOfItems extends ValueRepresentation { this.maxLength = null; this.padByte = PADDING_NULL; this.noMultiple = true; + this._storeRaw = false; } // TODO Craig: potentially need special logic for sequences when writing @@ -1326,9 +1347,10 @@ class ParsedUnknownValue extends BinaryRepresentation { this._isBinary = true; this._allowMultiple = false; this._isExplicit = true; + this._storeRaw = true; } - read(stream, length, syntax) { + read(stream, length, syntax, readOptions) { const arrayBuffer = this.readBytes(stream, length, syntax)[0]; const streamFromBuffer = new ReadBufferStream(arrayBuffer, true); const vr = ValueRepresentation.createByTypeString(this.type); @@ -1340,13 +1362,13 @@ class ParsedUnknownValue extends BinaryRepresentation { i = 0; while (i++ < times) { - const { rawValue, value } = vr.read(streamFromBuffer, vr.maxLength, syntax); + const { rawValue, value } = vr.read(streamFromBuffer, vr.maxLength, syntax, readOptions); rawValues.push(rawValue); values.push(value); } return { rawValue: rawValues, value: values }; } else { - return vr.read(streamFromBuffer, length, syntax); + return vr.read(streamFromBuffer, length, syntax, readOptions); } } } diff --git a/test/data.test.js b/test/data.test.js index c38bad49..8dca1803 100644 --- a/test/data.test.js +++ b/test/data.test.js @@ -1345,7 +1345,7 @@ describe("test_un_vr", () => { dicomDict.dict = dataset; // Write and re-read - const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + const outputDicomDict = DicomMessage.readFile(dicomDict.write(), { forceStoreRaw: true }); // Expect tag to be parsed correctly based on meta dictionary vr lookup expect(outputDicomDict.dict[tag].vr).toEqual(vr); diff --git a/test/lossless-read-write.test.js b/test/lossless-read-write.test.js index 41afa7a3..17fbe81e 100644 --- a/test/lossless-read-write.test.js +++ b/test/lossless-read-write.test.js @@ -13,6 +13,84 @@ const { DicomMetaDictionary, DicomDict, DicomMessage, ReadBufferStream } = describe('lossless-read-write', () => { + describe('storeRaw option', () => { + const dataset = { + '00080008': { + "vr": "CS", + "Value": ["DERIVED"], + }, + "00082112": { + "vr": "SQ", + "Value": [ + { + "00081150": { + "vr": "UI", + "Value": [ + "1.2.840.10008.5.1.4.1.1.7" + ], + }, + } + ] + }, + "00180050": { + "vr": "DS", + "Value": [1], + }, + "00181708": { + "vr": "IS", + "Value": [426], + }, + "00189328": { + "vr": "FD", + "Value": [30.98], + }, + "0020000D": { + "vr": "UI", + "Value": ["1.3.6.1.4.1.5962.99.1.2280943358.716200484.1363785608958.3.0"], + }, + "00400254": { + "vr": "LO", + "Value": ["DUCTO/GALACTOGRAM 1 DUCT LT"], + }, + "7FE00010": { + "vr": "OW", + "Value": [new Uint8Array([0x00, 0x00]).buffer] + } + }; + + test('storeRaw flag on VR should be respected by read', () => { + const tagsWithoutRaw = ['00082112', '7FE00010']; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + for (const tag in outputDicomDict.dict ) { + if (tagsWithoutRaw.includes(tag)) { + expect(outputDicomDict.dict[tag]._rawValue).toBeFalsy(); + } else { + expect(outputDicomDict.dict[tag]._rawValue).toBeTruthy(); + } + } + }); + + test('forceStoreRaw read option should override VR setting', () => { + const tagsWithoutRaw = ['00082112', '7FE00010']; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write(), { forceStoreRaw: true}); + + for (const tag in outputDicomDict.dict ) { + expect(outputDicomDict.dict[tag]._rawValue).toBeTruthy(); + } + }); + }); + + test('test DS value with additional allowed characters is written to file', () => { const dataset = { '00181041': { @@ -232,7 +310,7 @@ describe('lossless-read-write', () => { dicomDict.dict = dataset; // write and re-read - const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + const outputDicomDict = DicomMessage.readFile(dicomDict.write(), { forceStoreRaw: true }); // expect raw value to be unchanged, and Value parsed as Number to lose precision expect(outputDicomDict.dict['00181041']._rawValue).toEqual(dataElement._rawValue) @@ -419,7 +497,7 @@ describe('lossless-read-write', () => { dicomDict.dict = dataset; // write and re-read - const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + const outputDicomDict = DicomMessage.readFile(dicomDict.write(), { forceStoreRaw: true }); // expect raw value to be updated to match new Value parsed as Number to lose precision expect(outputDicomDict.dict['00181041']._rawValue).toEqual(dataElement.newRawValue ?? dataElement.Value) From cab4fe7c7e341bdd97e24a0643873889b85127cd Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Fri, 16 Aug 2024 16:44:44 -0400 Subject: [PATCH 15/16] Add sequence tests --- test/lossless-read-write.test.js | 861 +++++++++++++++++-------------- 1 file changed, 470 insertions(+), 391 deletions(-) diff --git a/test/lossless-read-write.test.js b/test/lossless-read-write.test.js index 17fbe81e..ad1c43c0 100644 --- a/test/lossless-read-write.test.js +++ b/test/lossless-read-write.test.js @@ -1,14 +1,12 @@ import "regenerator-runtime/runtime.js"; import fs from "fs"; -import path from "path"; import dcmjs from "../src/index.js"; import {deepEqual} from "../src/utilities/deepEqual"; -import {getTestDataset, getZippedTestDataset} from "./testUtils"; +import {getTestDataset} from "./testUtils"; -const { DicomMetaDictionary, DicomDict, DicomMessage, ReadBufferStream } = - dcmjs.data; +const {DicomDict, DicomMessage} = dcmjs.data; describe('lossless-read-write', () => { @@ -16,16 +14,16 @@ describe('lossless-read-write', () => { describe('storeRaw option', () => { const dataset = { '00080008': { - "vr": "CS", - "Value": ["DERIVED"], + vr: "CS", + Value: ["DERIVED"], }, "00082112": { - "vr": "SQ", - "Value": [ + vr: "SQ", + Value: [ { "00081150": { - "vr": "UI", - "Value": [ + vr: "UI", + Value: [ "1.2.840.10008.5.1.4.1.1.7" ], }, @@ -33,28 +31,28 @@ describe('lossless-read-write', () => { ] }, "00180050": { - "vr": "DS", - "Value": [1], + vr: "DS", + Value: [1], }, "00181708": { - "vr": "IS", - "Value": [426], + vr: "IS", + Value: [426], }, "00189328": { - "vr": "FD", - "Value": [30.98], + vr: "FD", + Value: [30.98], }, "0020000D": { - "vr": "UI", - "Value": ["1.3.6.1.4.1.5962.99.1.2280943358.716200484.1363785608958.3.0"], + vr: "UI", + Value: ["1.3.6.1.4.1.5962.99.1.2280943358.716200484.1363785608958.3.0"], }, "00400254": { - "vr": "LO", - "Value": ["DUCTO/GALACTOGRAM 1 DUCT LT"], + vr: "LO", + Value: ["DUCTO/GALACTOGRAM 1 DUCT LT"], }, "7FE00010": { - "vr": "OW", - "Value": [new Uint8Array([0x00, 0x00]).buffer] + vr: "OW", + Value: [new Uint8Array([0x00, 0x00]).buffer] } }; @@ -66,7 +64,7 @@ describe('lossless-read-write', () => { // write and re-read const outputDicomDict = DicomMessage.readFile(dicomDict.write()); - for (const tag in outputDicomDict.dict ) { + for (const tag in outputDicomDict.dict) { if (tagsWithoutRaw.includes(tag)) { expect(outputDicomDict.dict[tag]._rawValue).toBeFalsy(); } else { @@ -82,15 +80,14 @@ describe('lossless-read-write', () => { dicomDict.dict = dataset; // write and re-read - const outputDicomDict = DicomMessage.readFile(dicomDict.write(), { forceStoreRaw: true}); + const outputDicomDict = DicomMessage.readFile(dicomDict.write(), {forceStoreRaw: true}); - for (const tag in outputDicomDict.dict ) { + for (const tag in outputDicomDict.dict) { expect(outputDicomDict.dict[tag]._rawValue).toBeTruthy(); } }); }); - test('test DS value with additional allowed characters is written to file', () => { const dataset = { '00181041': { @@ -131,379 +128,461 @@ describe('lossless-read-write', () => { expect(outputDicomDict.dict['00181041'].Value).toEqual([9007199254740992]) }); - const unchangedTestCases = [ - { - vr: "AE", - _rawValue: [" TEST_AE "], // spaces non-significant for interpretation but allowed - Value: ["TEST_AE"], - }, - { - vr: "AS", - _rawValue: ["045Y"], - Value: ["045Y"], - }, - { - vr: "AT", - _rawValue: [0x00207E14, 0x0012839A], - Value: [0x00207E14, 0x0012839A], - }, - { - vr: "CS", - _rawValue: ["ORIGINAL ", " PRIMARY "], // spaces non-significant for interpretation but allowed - Value: ["ORIGINAL", "PRIMARY"], - }, - { - vr: "DA", - _rawValue: ["20240101"], - Value: ["20240101"], - }, - { - vr: "DS", - _rawValue: ["0000123.45"], // leading zeros allowed - Value: [123.45], - }, - { - vr: 'DT', - _rawValue: ["20240101123045.1 "], // trailing spaces allowed - Value: ["20240101123045.1 "], - }, - { - vr: 'FL', - _rawValue: [3.125], - Value: [3.125], - }, - { - vr: 'FD', - _rawValue: [3.14159265358979], // trailing spaces allowed - Value: [3.14159265358979], - }, - { - vr: 'IS', - _rawValue: [" -123 "], // leading/trailing spaces & sign allowed - Value: [-123], - }, - { - vr: 'LO', - _rawValue: [" A long string with spaces "], // leading/trailing spaces allowed - Value: ["A long string with spaces"], - }, - { - vr: 'LT', - _rawValue: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b. "], // leading spaces significant, trailing spaces allowed - Value: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b."], - }, - { - vr: 'OB', - _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], - Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], - }, - { - vr: 'OD', - _rawValue: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], - Value: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], - }, - { - vr: 'OF', - _rawValue: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], - Value: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], - }, - // TODO: VRs currently unimplemented - // { - // vr: 'OL', - // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], - // Value: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], - // }, - // { - // vr: 'OV', - // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], - // Value: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], - // }, - { - vr: 'OW', - _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], - Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], - }, - { - vr: 'PN', - _rawValue: ["Doe^John^A^Jr.^MD "], // trailing spaces allowed - Value: [{"Alphabetic": "Doe^John^A^Jr.^MD "}], - }, - { - vr: 'SH', - _rawValue: [" CT_SCAN_01 "], // leading/trailing spaces allowed - Value: ["CT_SCAN_01"], - }, - { - vr: 'SL', - _rawValue: [-2147483648], - Value: [-2147483648], - }, - { - vr: 'SS', - _rawValue: [-32768, 1234, 832], - Value: [-32768, 1234, 832], - }, - { - vr: 'ST', - _rawValue: ["Patient complains of headaches over the last week. "], // trailing spaces allowed - Value: ["Patient complains of headaches over the last week."], - }, - // TODO: VR currently unimplemented - // { - // vr: 'SV', - // _rawValue: [9007199254740993], // trailing spaces allowed - // Value: [9007199254740993], - // }, - { - vr: 'TM', - _rawValue: ["42530.123456 "], // trailing spaces allowed - Value: ["42530.123456"], - }, - { - vr: 'UC', - _rawValue: ["Detailed description of procedure or clinical notes that could be very long. "], // trailing spaces allowed - Value: ["Detailed description of procedure or clinical notes that could be very long."], - }, - { - vr: 'UI', - _rawValue: ["1.2.840.10008.1.2.1"], - Value: ["1.2.840.10008.1.2.1"], - }, - { - vr: 'UL', - _rawValue: [4294967295], - Value: [4294967295], - }, - { - vr: 'UR', - _rawValue: ["http://dicom.nema.org "], // trailing spaces ignored but allowed - Value: ["http://dicom.nema.org "], - }, - { - vr: 'US', - _rawValue: [65535], - Value: [65535], - }, - { - vr: 'UT', - _rawValue: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset. "], // leading spaces significant, trailing spaces allowed - Value: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset."], - }, - // TODO: VR currently unimplemented - // { - // vr: 'UV', - // _rawValue: [18446744073709551616], // 2^64 - // Value: [18446744073709551616], - // }, - ]; - - test.each(unchangedTestCases)( - `Test unchanged value is retained following read and write - $vr`, - (dataElement) => { - const dataset = { - '00181041': { - ...dataElement - }, - }; + describe('Individual VR comparisons', () => { - const dicomDict = new DicomDict({}); - dicomDict.dict = dataset; + const unchangedTestCases = [ + { + vr: "AE", + _rawValue: [" TEST_AE "], // spaces non-significant for interpretation but allowed + Value: ["TEST_AE"], + }, + { + vr: "AS", + _rawValue: ["045Y"], + Value: ["045Y"], + }, + { + vr: "AT", + _rawValue: [0x00207E14, 0x0012839A], + Value: [0x00207E14, 0x0012839A], + }, + { + vr: "CS", + _rawValue: ["ORIGINAL ", " PRIMARY "], // spaces non-significant for interpretation but allowed + Value: ["ORIGINAL", "PRIMARY"], + }, + { + vr: "DA", + _rawValue: ["20240101"], + Value: ["20240101"], + }, + { + vr: "DS", + _rawValue: ["0000123.45"], // leading zeros allowed + Value: [123.45], + }, + { + vr: 'DT', + _rawValue: ["20240101123045.1 "], // trailing spaces allowed + Value: ["20240101123045.1 "], + }, + { + vr: 'FL', + _rawValue: [3.125], + Value: [3.125], + }, + { + vr: 'FD', + _rawValue: [3.14159265358979], // trailing spaces allowed + Value: [3.14159265358979], + }, + { + vr: 'IS', + _rawValue: [" -123 "], // leading/trailing spaces & sign allowed + Value: [-123], + }, + { + vr: 'LO', + _rawValue: [" A long string with spaces "], // leading/trailing spaces allowed + Value: ["A long string with spaces"], + }, + { + vr: 'LT', + _rawValue: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b. "], // leading spaces significant, trailing spaces allowed + Value: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b."], + }, + { + vr: 'OB', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + }, + { + vr: 'OD', + _rawValue: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], + }, + { + vr: 'OF', + _rawValue: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], + }, + // TODO: VRs currently unimplemented + // { + // vr: 'OL', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], + // Value: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + // { + // vr: 'OV', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], + // Value: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + { + vr: 'OW', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + }, + { + vr: 'PN', + _rawValue: ["Doe^John^A^Jr.^MD "], // trailing spaces allowed + Value: [{"Alphabetic": "Doe^John^A^Jr.^MD "}], + }, + { + vr: 'SH', + _rawValue: [" CT_SCAN_01 "], // leading/trailing spaces allowed + Value: ["CT_SCAN_01"], + }, + { + vr: 'SL', + _rawValue: [-2147483648], + Value: [-2147483648], + }, + { + vr: 'SS', + _rawValue: [-32768, 1234, 832], + Value: [-32768, 1234, 832], + }, + { + vr: 'ST', + _rawValue: ["Patient complains of headaches over the last week. "], // trailing spaces allowed + Value: ["Patient complains of headaches over the last week."], + }, + // TODO: VR currently unimplemented + // { + // vr: 'SV', + // _rawValue: [9007199254740993], // trailing spaces allowed + // Value: [9007199254740993], + // }, + { + vr: 'TM', + _rawValue: ["42530.123456 "], // trailing spaces allowed + Value: ["42530.123456"], + }, + { + vr: 'UC', + _rawValue: ["Detailed description of procedure or clinical notes that could be very long. "], // trailing spaces allowed + Value: ["Detailed description of procedure or clinical notes that could be very long."], + }, + { + vr: 'UI', + _rawValue: ["1.2.840.10008.1.2.1"], + Value: ["1.2.840.10008.1.2.1"], + }, + { + vr: 'UL', + _rawValue: [4294967295], + Value: [4294967295], + }, + { + vr: 'UR', + _rawValue: ["http://dicom.nema.org "], // trailing spaces ignored but allowed + Value: ["http://dicom.nema.org "], + }, + { + vr: 'US', + _rawValue: [65535], + Value: [65535], + }, + { + vr: 'UT', + _rawValue: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset. "], // leading spaces significant, trailing spaces allowed + Value: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset."], + }, + // TODO: VR currently unimplemented + // { + // vr: 'UV', + // _rawValue: [18446744073709551616], // 2^64 + // Value: [18446744073709551616], + // }, + ]; + test.each(unchangedTestCases)( + `Test unchanged value is retained following read and write - $vr`, + (dataElement) => { + const dataset = { + '00181041': { + ...dataElement + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write(), {forceStoreRaw: true}); + + // expect raw value to be unchanged, and Value parsed as Number to lose precision + expect(outputDicomDict.dict['00181041']._rawValue).toEqual(dataElement._rawValue) + expect(outputDicomDict.dict['00181041'].Value).toEqual(dataElement.Value) + } + ) - // write and re-read - const outputDicomDict = DicomMessage.readFile(dicomDict.write(), { forceStoreRaw: true }); - - // expect raw value to be unchanged, and Value parsed as Number to lose precision - expect(outputDicomDict.dict['00181041']._rawValue).toEqual(dataElement._rawValue) - expect(outputDicomDict.dict['00181041'].Value).toEqual(dataElement.Value) - } - ) - - const changedTestCases = [ - { - vr: "AE", - _rawValue: [" TEST_AE "], // spaces non-significant for interpretation but allowed - Value: ["NEW_AE"], - }, - { - vr: "AS", - _rawValue: ["045Y"], - Value: ["999Y"], - }, - { - vr: "AT", - _rawValue: [0x00207E14, 0x0012839A], - Value: [0x00200010], - }, - { - vr: "CS", - _rawValue: ["ORIGINAL ", " PRIMARY "], // spaces non-significant for interpretation but allowed - Value: ["ORIGINAL", "PRIMARY", "SECONDARY"], - }, - { - vr: "DA", - _rawValue: ["20240101"], - Value: ["20231225"], - }, - { - vr: "DS", - _rawValue: ["0000123.45"], // leading zeros allowed - Value: [123.456], - newRawValue: ["123.456 "] - }, - { - vr: 'DT', - _rawValue: ["20240101123045.1 "], // trailing spaces allowed - Value: ["20240101123045.3"], - }, - { - vr: 'FL', - _rawValue: [3.125], - Value: [22], - }, - { - vr: 'FD', - _rawValue: [3.14159265358979], // trailing spaces allowed - Value: [50.1242], - }, - { - vr: 'IS', - _rawValue: [" -123 "], // leading/trailing spaces & sign allowed - Value: [0], - newRawValue: ["0 "] - }, - { - vr: 'LO', - _rawValue: [" A long string with spaces "], // leading/trailing spaces allowed - Value: ["A changed string that is still long."], - }, - { - vr: 'LT', - _rawValue: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b. "], // leading spaces significant, trailing spaces allowed - Value: [" A modified string of text"], - }, - { - vr: 'OB', - _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], - Value: [new Uint8Array([0x01, 0x02]).buffer], - }, - { - vr: 'OD', - _rawValue: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], - Value: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x35, 0x6E, 0x9E, 0x42]).buffer], - }, - { - vr: 'OF', - _rawValue: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], - Value: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x43]).buffer], - }, - // TODO: VRs currently unimplemented - // { - // vr: 'OL', - // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], - // }, - // { - // vr: 'OV', - // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], - // }, - { - vr: 'OW', - _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x89, 0x91, 0x89, 0x89]).buffer], - Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], - }, - { - vr: 'PN', - _rawValue: ["Doe^John^A^Jr.^MD "], // trailing spaces allowed - Value: [{"Alphabetic": "Doe^Jane^A^Jr.^MD"}], - newRawValue: ["Doe^Jane^A^Jr.^MD"] - }, - { - vr: 'SH', - _rawValue: [" CT_SCAN_01 "], // leading/trailing spaces allowed - Value: ["MR_SCAN_91"], - }, - { - vr: 'SL', - _rawValue: [-2147483648], - Value: [-2147481234], - }, - { - vr: 'SS', - _rawValue: [-32768, 1234, 832], - Value: [1234], - }, - { - vr: 'ST', - _rawValue: ["Patient complains of headaches over the last week. "], // trailing spaces allowed - Value: ["Patient complains of headaches"], - }, - // TODO: VR currently unimplemented - // { - // vr: 'SV', - // _rawValue: [9007199254740993], // trailing spaces allowed - // }, - { - vr: 'TM', - _rawValue: ["42530.123456 "], // trailing spaces allowed - Value: ["42530"], - newRawValue: ["42530 "] - }, - { - vr: 'UC', - _rawValue: ["Detailed description of procedure or clinical notes that could be very long. "], // trailing spaces allowed - Value: ["Detailed description of procedure and other things"], - }, - { - vr: 'UI', - _rawValue: ["1.2.840.10008.1.2.1"], - Value: ["1.2.840.10008.1.2.2"], - }, - { - vr: 'UL', - _rawValue: [4294967295], - Value: [1], - }, - { - vr: 'UR', - _rawValue: ["http://dicom.nema.org "], // trailing spaces ignored but allowed - Value: ["https://github.com/dcmjs-org"], - }, - { - vr: 'US', - _rawValue: [65535], - Value: [1], - }, - { - vr: 'UT', - _rawValue: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset. "], // leading spaces significant, trailing spaces allowed - Value: [""], - }, - // TODO: VR currently unimplemented - // { - // vr: 'UV', - // _rawValue: [18446744073709551616], // 2^64 - // }, - ]; - - test.each(changedTestCases)( - `Test changed value overwrites original value following read and write - $vr`, - (dataElement) => { + const changedTestCases = [ + { + vr: "AE", + _rawValue: [" TEST_AE "], // spaces non-significant for interpretation but allowed + Value: ["NEW_AE"], + }, + { + vr: "AS", + _rawValue: ["045Y"], + Value: ["999Y"], + }, + { + vr: "AT", + _rawValue: [0x00207E14, 0x0012839A], + Value: [0x00200010], + }, + { + vr: "CS", + _rawValue: ["ORIGINAL ", " PRIMARY "], // spaces non-significant for interpretation but allowed + Value: ["ORIGINAL", "PRIMARY", "SECONDARY"], + }, + { + vr: "DA", + _rawValue: ["20240101"], + Value: ["20231225"], + }, + { + vr: "DS", + _rawValue: ["0000123.45"], // leading zeros allowed + Value: [123.456], + newRawValue: ["123.456 "] + }, + { + vr: 'DT', + _rawValue: ["20240101123045.1 "], // trailing spaces allowed + Value: ["20240101123045.3"], + }, + { + vr: 'FL', + _rawValue: [3.125], + Value: [22], + }, + { + vr: 'FD', + _rawValue: [3.14159265358979], // trailing spaces allowed + Value: [50.1242], + }, + { + vr: 'IS', + _rawValue: [" -123 "], // leading/trailing spaces & sign allowed + Value: [0], + newRawValue: ["0 "] + }, + { + vr: 'LO', + _rawValue: [" A long string with spaces "], // leading/trailing spaces allowed + Value: ["A changed string that is still long."], + }, + { + vr: 'LT', + _rawValue: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b. "], // leading spaces significant, trailing spaces allowed + Value: [" A modified string of text"], + }, + { + vr: 'OB', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + Value: [new Uint8Array([0x01, 0x02]).buffer], + }, + { + vr: 'OD', + _rawValue: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x35, 0x6E, 0x9E, 0x42]).buffer], + }, + { + vr: 'OF', + _rawValue: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x43]).buffer], + }, + // TODO: VRs currently unimplemented + // { + // vr: 'OL', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + // { + // vr: 'OV', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + { + vr: 'OW', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x89, 0x91, 0x89, 0x89]).buffer], + Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + }, + { + vr: 'PN', + _rawValue: ["Doe^John^A^Jr.^MD "], // trailing spaces allowed + Value: [{"Alphabetic": "Doe^Jane^A^Jr.^MD"}], + newRawValue: ["Doe^Jane^A^Jr.^MD"] + }, + { + vr: 'SH', + _rawValue: [" CT_SCAN_01 "], // leading/trailing spaces allowed + Value: ["MR_SCAN_91"], + }, + { + vr: 'SL', + _rawValue: [-2147483648], + Value: [-2147481234], + }, + { + vr: 'SS', + _rawValue: [-32768, 1234, 832], + Value: [1234], + }, + { + vr: 'ST', + _rawValue: ["Patient complains of headaches over the last week. "], // trailing spaces allowed + Value: ["Patient complains of headaches"], + }, + // TODO: VR currently unimplemented + // { + // vr: 'SV', + // _rawValue: [9007199254740993], // trailing spaces allowed + // }, + { + vr: 'TM', + _rawValue: ["42530.123456 "], // trailing spaces allowed + Value: ["42530"], + newRawValue: ["42530 "] + }, + { + vr: 'UC', + _rawValue: ["Detailed description of procedure or clinical notes that could be very long. "], // trailing spaces allowed + Value: ["Detailed description of procedure and other things"], + }, + { + vr: 'UI', + _rawValue: ["1.2.840.10008.1.2.1"], + Value: ["1.2.840.10008.1.2.2"], + }, + { + vr: 'UL', + _rawValue: [4294967295], + Value: [1], + }, + { + vr: 'UR', + _rawValue: ["http://dicom.nema.org "], // trailing spaces ignored but allowed + Value: ["https://github.com/dcmjs-org"], + }, + { + vr: 'US', + _rawValue: [65535], + Value: [1], + }, + { + vr: 'UT', + _rawValue: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset. "], // leading spaces significant, trailing spaces allowed + Value: [""], + }, + // TODO: VR currently unimplemented + // { + // vr: 'UV', + // _rawValue: [18446744073709551616], // 2^64 + // }, + ]; + + test.each(changedTestCases)( + `Test changed value overwrites original value following read and write - $vr`, + (dataElement) => { + const dataset = { + '00181041': { + ...dataElement + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write(), {forceStoreRaw: true}); + + // expect raw value to be updated to match new Value parsed as Number to lose precision + expect(outputDicomDict.dict['00181041']._rawValue).toEqual(dataElement.newRawValue ?? dataElement.Value) + expect(outputDicomDict.dict['00181041'].Value).toEqual(dataElement.Value) + } + ) + + }); + + describe('sequences', () => { + test('nested sequences should support lossless round trip', () => { const dataset = { - '00181041': { - ...dataElement - }, + "52009229": { + vr: "SQ", + Value: [ + { + "0020000E": { + vr: "UI", + Value: ["1.3.6.1.4.1.5962.99.1.2280943358.716200484.1363785608958.1.1"] + }, + "00089123": { + vr: "SQ", + Value: [ + { + "00181030": { + vr: "AE", + _rawValue: [" TEST_AE "], + Value: ["TEST_AE"], + }, + "00180050": { + vr: "DS", + Value: [5.0], + _rawValue: ["5.000 "] + } + }, + { + "00181030": { + vr: "AE", + _rawValue: [" TEST_AE "], + Value: ["TEST_AE"], + }, + "00180050": { + vr: "DS", + Value: [6.0], + _rawValue: ["6.000 "] + } + } + ] + } + }, + { + "0020000E": { + vr: "UI", + Value: ["1.3.6.1.4.1.5962.99.1.2280943358.716200484.1363785608958.1.2"] + }, + "00089123": { + vr: "SQ", + Value: [ + { + "00181030": { + vr: "LO", + Value: ["ABDOMEN MRI"] + }, + "00180050": { + vr: 'IS', + _rawValue: [" -123 "], // leading/trailing spaces & sign allowed + Value: [-123], + } + } + ] + } + } + ] + } }; - + const dicomDict = new DicomDict({}); dicomDict.dict = dataset; - // write and re-read - const outputDicomDict = DicomMessage.readFile(dicomDict.write(), { forceStoreRaw: true }); + // confirm after write raw values are re-encoded + const outputBuffer = dicomDict.write(); + const outputDicomDict = DicomMessage.readFile(outputBuffer); - // expect raw value to be updated to match new Value parsed as Number to lose precision - expect(outputDicomDict.dict['00181041']._rawValue).toEqual(dataElement.newRawValue ?? dataElement.Value) - expect(outputDicomDict.dict['00181041'].Value).toEqual(dataElement.Value) - } - ) + // lossless read/write should match entire data set + deepEqual(dicomDict.dict, outputDicomDict.dict) + }) + }) test('File dataset should be equal after read and write', async () => { const inputBuffer = await getDcmjsDataFile("unknown-VR", "sample-dicom-with-un-vr.dcm"); From 9b0ad2c1fbd96475cb3538fe23267a312d663179 Mon Sep 17 00:00:00 2001 From: Craig Berry Date: Fri, 16 Aug 2024 17:41:55 -0400 Subject: [PATCH 16/16] Fix comments and formatting before review --- src/DicomMessage.js | 11 ++++++----- src/ValueRepresentation.js | 5 ++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/DicomMessage.js b/src/DicomMessage.js index 178ddcf0..ebde2329 100644 --- a/src/DicomMessage.js +++ b/src/DicomMessage.js @@ -256,7 +256,7 @@ class DicomMessage { tagObject = jsonObjects[tagString], vrType = tagObject.vr; - var values = DicomMessage._getTagWriteValue(vrType, tagObject); + var values = DicomMessage._getTagWriteValues(vrType, tagObject); written += tag.write( useStream, @@ -270,16 +270,17 @@ class DicomMessage { return written; } - static _getTagWriteValue(vrType, tagObject) { + static _getTagWriteValues(vrType, tagObject) { if (!tagObject._rawValue) { return tagObject.Value; } + // apply VR specific formatting to the original _rawValue and compare to the Value const vr = ValueRepresentation.createByTypeString(vrType); - const compareValue = tagObject._rawValue.map((val) => vr.applyFormatting(val)) + const originalValue = tagObject._rawValue.map((val) => vr.applyFormatting(val)) - // if the _rawValue is unchanged, write it unformatted back to the file - if (deepEqual(compareValue, tagObject.Value)) { + // if Value has not changed, write _rawValue unformatted back into the file + if (deepEqual(tagObject.Value, originalValue)) { return tagObject._rawValue; } else { return tagObject.Value; diff --git a/src/ValueRepresentation.js b/src/ValueRepresentation.js index c110a4f3..59f6e9ea 100644 --- a/src/ValueRepresentation.js +++ b/src/ValueRepresentation.js @@ -107,7 +107,7 @@ class ValueRepresentation { * The `_rawValue` is used for lossless round trip processing, which preserves data (whitespace, special chars) on write * that may be lost after casting to other data structures like Number, or applying formatting for readability. * - * Example: DecimalString: _rawValue: ["-0.000"], Value: [0] + * Example DecimalString: _rawValue: ["-0.000"], Value: [0] */ storeRaw() { return this._storeRaw; @@ -1013,7 +1013,6 @@ class SequenceOfItems extends ValueRepresentation { this._storeRaw = false; } - // TODO Craig: potentially need special logic for sequences when writing readBytes(stream, sqlength, syntax) { if (sqlength == 0x0) { return []; //contains no dataset @@ -1286,6 +1285,7 @@ class UniqueIdentifier extends AsciiStringRepresentation { readBytes(stream, length) { const result = this.readPaddedAsciiString(stream, length); + const BACKSLASH = String.fromCharCode(VM_DELIMITER); // Treat backslashes as a delimiter for multiple UIDs, in which case an @@ -1303,7 +1303,6 @@ class UniqueIdentifier extends AsciiStringRepresentation { } } - // TODO: Can we make the array formatting generic in value representaiton base applyFormatting(value) { const removeInvalidUidChars = (uidStr) => { return uidStr.replace(/[^0-9.]/g, "");