Skip to main content

dicom_toolkit_data/
xml.rs

1//! DICOM XML representation (PS3.19 Native DICOM Model).
2//!
3//! Ports DCMTK's `dcmtk/dcmdata/dcrledrg.h` XML output capability.
4//! The XML format uses `<NativeDicomModel>` as root, with `<DicomAttribute>` for
5//! each element, matching the Native DICOM Model (PS3.19 §A.1).
6
7use crate::dataset::DataSet;
8use crate::element::Element;
9use crate::value::{PixelData, Value};
10use dicom_toolkit_core::error::DcmResult;
11use dicom_toolkit_dict::Tag;
12
13// ── Serialization ─────────────────────────────────────────────────────────────
14
15/// Serialize a `DataSet` to a DICOM XML (Native DICOM Model) string.
16///
17/// Produces XML per PS3.19 Annex A — suitable for WADO-RS multipart responses.
18pub fn to_xml(dataset: &DataSet) -> DcmResult<String> {
19    let mut out = String::with_capacity(4096);
20    out.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
21    out.push('\n');
22    out.push_str("<NativeDicomModel xml:space=\"preserve\">\n");
23    write_dataset(&mut out, dataset, 1)?;
24    out.push_str("</NativeDicomModel>\n");
25    Ok(out)
26}
27
28fn indent(level: usize) -> String {
29    "  ".repeat(level)
30}
31
32fn write_dataset(out: &mut String, dataset: &DataSet, level: usize) -> DcmResult<()> {
33    for (tag, elem) in dataset.iter() {
34        if tag.is_group_length() || tag.is_delimiter() {
35            continue;
36        }
37        write_element(out, tag, elem, level)?;
38    }
39    Ok(())
40}
41
42fn write_element(out: &mut String, tag: &Tag, elem: &Element, level: usize) -> DcmResult<()> {
43    let pad = indent(level);
44    let vr_str = elem.vr.code();
45
46    out.push_str(&format!(
47        r#"{}<DicomAttribute tag="{:04X}{:04X}" vr="{}">"#,
48        pad, tag.group, tag.element, vr_str
49    ));
50
51    match &elem.value {
52        Value::Empty => {
53            out.push_str("/>\n");
54            return Ok(());
55        }
56        Value::Sequence(items) => {
57            out.push('\n');
58            for (i, item) in items.iter().enumerate() {
59                out.push_str(&format!(
60                    "{}<Item number=\"{}\">\n",
61                    indent(level + 1),
62                    i + 1
63                ));
64                write_dataset(out, item, level + 2)?;
65                out.push_str(&format!("{}</Item>\n", indent(level + 1)));
66            }
67            out.push_str(&format!("{}</DicomAttribute>\n", pad));
68            return Ok(());
69        }
70        _ => {}
71    }
72
73    out.push('\n');
74
75    match &elem.value {
76        Value::Strings(v) => {
77            for (i, s) in v.iter().enumerate() {
78                out.push_str(&format!(
79                    "{}<Value number=\"{}\">{}</Value>\n",
80                    indent(level + 1),
81                    i + 1,
82                    xml_escape(s)
83                ));
84            }
85        }
86        Value::Uid(s) => {
87            out.push_str(&format!(
88                "{}<Value number=\"1\">{}</Value>\n",
89                indent(level + 1),
90                xml_escape(s)
91            ));
92        }
93        Value::PersonNames(names) => {
94            for (i, pn) in names.iter().enumerate() {
95                out.push_str(&format!(
96                    "{}<PersonName number=\"{}\">\n",
97                    indent(level + 1),
98                    i + 1
99                ));
100                if !pn.alphabetic.is_empty() {
101                    out.push_str(&format!(
102                        "{}<Alphabetic><FamilyName>{}</FamilyName></Alphabetic>\n",
103                        indent(level + 2),
104                        xml_escape(pn.last_name())
105                    ));
106                }
107                if !pn.ideographic.is_empty() {
108                    out.push_str(&format!(
109                        "{}<Ideographic>{}</Ideographic>\n",
110                        indent(level + 2),
111                        xml_escape(&pn.ideographic)
112                    ));
113                }
114                if !pn.phonetic.is_empty() {
115                    out.push_str(&format!(
116                        "{}<Phonetic>{}</Phonetic>\n",
117                        indent(level + 2),
118                        xml_escape(&pn.phonetic)
119                    ));
120                }
121                out.push_str(&format!("{}</PersonName>\n", indent(level + 1)));
122            }
123        }
124        Value::Date(dates) => {
125            for (i, d) in dates.iter().enumerate() {
126                out.push_str(&format!(
127                    "{}<Value number=\"{}\">{}</Value>\n",
128                    indent(level + 1),
129                    i + 1,
130                    d
131                ));
132            }
133        }
134        Value::Time(times) => {
135            for (i, t) in times.iter().enumerate() {
136                out.push_str(&format!(
137                    "{}<Value number=\"{}\">{}</Value>\n",
138                    indent(level + 1),
139                    i + 1,
140                    t
141                ));
142            }
143        }
144        Value::DateTime(dts) => {
145            for (i, dt) in dts.iter().enumerate() {
146                out.push_str(&format!(
147                    "{}<Value number=\"{}\">{}</Value>\n",
148                    indent(level + 1),
149                    i + 1,
150                    dt
151                ));
152            }
153        }
154        Value::Ints(v) => {
155            for (i, n) in v.iter().enumerate() {
156                out.push_str(&format!(
157                    "{}<Value number=\"{}\">{}</Value>\n",
158                    indent(level + 1),
159                    i + 1,
160                    n
161                ));
162            }
163        }
164        Value::Decimals(v) => {
165            for (i, n) in v.iter().enumerate() {
166                out.push_str(&format!(
167                    "{}<Value number=\"{}\">{}</Value>\n",
168                    indent(level + 1),
169                    i + 1,
170                    n
171                ));
172            }
173        }
174        Value::U16(v) => {
175            for (i, n) in v.iter().enumerate() {
176                out.push_str(&format!(
177                    "{}<Value number=\"{}\">{}</Value>\n",
178                    indent(level + 1),
179                    i + 1,
180                    n
181                ));
182            }
183        }
184        Value::I16(v) => {
185            for (i, n) in v.iter().enumerate() {
186                out.push_str(&format!(
187                    "{}<Value number=\"{}\">{}</Value>\n",
188                    indent(level + 1),
189                    i + 1,
190                    n
191                ));
192            }
193        }
194        Value::U32(v) => {
195            for (i, n) in v.iter().enumerate() {
196                out.push_str(&format!(
197                    "{}<Value number=\"{}\">{}</Value>\n",
198                    indent(level + 1),
199                    i + 1,
200                    n
201                ));
202            }
203        }
204        Value::I32(v) => {
205            for (i, n) in v.iter().enumerate() {
206                out.push_str(&format!(
207                    "{}<Value number=\"{}\">{}</Value>\n",
208                    indent(level + 1),
209                    i + 1,
210                    n
211                ));
212            }
213        }
214        Value::U64(v) => {
215            for (i, n) in v.iter().enumerate() {
216                out.push_str(&format!(
217                    "{}<Value number=\"{}\">{}</Value>\n",
218                    indent(level + 1),
219                    i + 1,
220                    n
221                ));
222            }
223        }
224        Value::I64(v) => {
225            for (i, n) in v.iter().enumerate() {
226                out.push_str(&format!(
227                    "{}<Value number=\"{}\">{}</Value>\n",
228                    indent(level + 1),
229                    i + 1,
230                    n
231                ));
232            }
233        }
234        Value::F32(v) => {
235            for (i, n) in v.iter().enumerate() {
236                out.push_str(&format!(
237                    "{}<Value number=\"{}\">{}</Value>\n",
238                    indent(level + 1),
239                    i + 1,
240                    n
241                ));
242            }
243        }
244        Value::F64(v) => {
245            for (i, n) in v.iter().enumerate() {
246                out.push_str(&format!(
247                    "{}<Value number=\"{}\">{}</Value>\n",
248                    indent(level + 1),
249                    i + 1,
250                    n
251                ));
252            }
253        }
254        Value::Tags(tags) => {
255            for (i, t) in tags.iter().enumerate() {
256                out.push_str(&format!(
257                    "{}<Value number=\"{}\">{:04X}{:04X}</Value>\n",
258                    indent(level + 1),
259                    i + 1,
260                    t.group,
261                    t.element
262                ));
263            }
264        }
265        // Binary data — base64 encoded InlineBinary
266        Value::U8(bytes) => {
267            use base64::Engine;
268            let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
269            out.push_str(&format!(
270                "{}<InlineBinary>{}</InlineBinary>\n",
271                indent(level + 1),
272                b64
273            ));
274        }
275        Value::PixelData(pd) => {
276            use base64::Engine;
277            let bytes: &[u8] = match pd {
278                PixelData::Native { bytes } => bytes,
279                PixelData::Encapsulated { fragments, .. } => {
280                    fragments.first().map(|f| f.as_slice()).unwrap_or(&[])
281                }
282            };
283            let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
284            out.push_str(&format!(
285                "{}<InlineBinary>{}</InlineBinary>\n",
286                indent(level + 1),
287                b64
288            ));
289        }
290        // Empty handled above
291        Value::Empty | Value::Sequence(_) => {}
292    }
293
294    out.push_str(&format!("{}</DicomAttribute>\n", pad));
295    Ok(())
296}
297
298/// Escape special XML characters in a text value.
299fn xml_escape(s: &str) -> String {
300    s.replace('&', "&amp;")
301        .replace('<', "&lt;")
302        .replace('>', "&gt;")
303        .replace('"', "&quot;")
304        .replace('\'', "&apos;")
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use dicom_toolkit_dict::{tags, Vr};
311
312    #[test]
313    fn xml_has_root_element() {
314        let ds = DataSet::new();
315        let xml = to_xml(&ds).unwrap();
316        assert!(
317            xml.contains("<NativeDicomModel"),
318            "should have NativeDicomModel root"
319        );
320        assert!(
321            xml.contains("</NativeDicomModel>"),
322            "should close NativeDicomModel"
323        );
324    }
325
326    #[test]
327    fn xml_contains_patient_name() {
328        let mut ds = DataSet::new();
329        ds.set_string(tags::PATIENT_NAME, Vr::PN, "Doe^Jane");
330        let xml = to_xml(&ds).unwrap();
331        assert!(xml.contains("00100010"), "should contain PatientName tag");
332        assert!(xml.contains("PN"), "should contain VR");
333    }
334
335    #[test]
336    fn xml_contains_uid() {
337        let mut ds = DataSet::new();
338        ds.set_uid(tags::SOP_INSTANCE_UID, "1.2.3.4.5");
339        let xml = to_xml(&ds).unwrap();
340        assert!(xml.contains("1.2.3.4.5"), "should contain UID value");
341    }
342
343    #[test]
344    fn xml_escapes_special_chars() {
345        let mut ds = DataSet::new();
346        ds.set_string(tags::PATIENT_ID, Vr::LO, "A<B>&C");
347        let xml = to_xml(&ds).unwrap();
348        assert!(xml.contains("&lt;"), "< should be escaped");
349        assert!(xml.contains("&amp;"), "& should be escaped");
350        assert!(!xml.contains("A<B>"), "raw < should not appear in value");
351    }
352
353    #[test]
354    fn xml_contains_sequence() {
355        let mut ds = DataSet::new();
356        let mut item = DataSet::new();
357        item.set_string(tags::PATIENT_ID, Vr::LO, "ITEM-1");
358        ds.set_sequence(tags::REFERENCED_SOP_SEQUENCE, vec![item]);
359        let xml = to_xml(&ds).unwrap();
360        assert!(
361            xml.contains("<Item number=\"1\">"),
362            "should have Item element"
363        );
364        assert!(xml.contains("</Item>"), "should close Item");
365    }
366
367    #[test]
368    fn xml_is_well_formed() {
369        let mut ds = DataSet::new();
370        ds.set_string(tags::PATIENT_NAME, Vr::PN, "Smith^John");
371        ds.set_string(tags::PATIENT_ID, Vr::LO, "ID-001");
372        ds.set_uid(tags::SOP_INSTANCE_UID, "1.2.3");
373        ds.set_u16(tags::ROWS, 256);
374        ds.set_u16(tags::COLUMNS, 256);
375        let xml = to_xml(&ds).unwrap();
376        // Basic well-formedness: every open tag should have a close
377        assert!(
378            xml.contains("</DicomAttribute>") || xml.contains("/>"),
379            "all attributes should be closed"
380        );
381    }
382}