dicom_dump/
lib.rs

1#![allow(clippy::derive_partial_eq_without_eq)]
2//! DICOM data dumping library
3//!
4//! This is a helper library
5//! for dumping the contents of DICOM objects and elements
6//! in a human readable way.
7//!
8//! # Examples
9//!
10//! A quick and easy way to dump the contents of a DICOM object
11//! is via [`dump_file`]
12//! (or [`dump_file_to`] to print to an arbitrary writer).
13//!
14//! ```no_run
15//! use dicom_object::open_file;
16//! use dicom_dump::dump_file;
17//!
18//! let obj = open_file("path/to/file.dcm")?;
19//! dump_file(&obj)?;
20//! # Result::<(), Box<dyn std::error::Error>>::Ok(())
21//! ```
22//!
23//! See the [`DumpOptions`] builder for additional dumping options.
24//!
25//! ```no_run
26//! use dicom_object::open_file;
27//! use dicom_dump::{DumpOptions, dump_file};
28//!
29//! let obj = open_file("path/to/file2.dcm")?;
30//! let mut options = DumpOptions::new();
31//! // dump to stdout (width = 100)
32//! options.width(100).dump_file(&obj)?;
33//! # Result::<(), Box<dyn std::error::Error>>::Ok(())
34//! ```
35#[cfg(feature = "cli")]
36use clap::ValueEnum;
37#[cfg(feature = "sop-class")]
38use dicom_core::dictionary::UidDictionary;
39use dicom_core::dictionary::{DataDictionary, DataDictionaryEntry};
40use dicom_core::header::Header;
41use dicom_core::value::{PrimitiveValue, Value as DicomValue};
42use dicom_core::VR;
43#[cfg(feature = "sop-class")]
44use dicom_dictionary_std::StandardSopClassDictionary;
45use dicom_encoding::transfer_syntax::TransferSyntaxIndex;
46use dicom_json::DicomJson;
47use dicom_object::mem::{InMemDicomObject, InMemElement};
48use dicom_object::{FileDicomObject, FileMetaTable, StandardDataDictionary};
49use dicom_transfer_syntax_registry::TransferSyntaxRegistry;
50use owo_colors::*;
51use std::borrow::Cow;
52use std::fmt::{self, Display, Formatter};
53use std::io::{stdout, Result as IoResult, Write};
54use std::str::FromStr;
55
56#[derive(Clone, Debug, PartialEq, Default)]
57#[cfg_attr(feature = "cli", derive(ValueEnum))]
58pub enum DumpFormat {
59    /// Text dump of DICOM file
60    ///
61    /// It is primarily designed to be human readable,
62    /// although its output can be used to recover the original object
63    /// in its uncut form (no limit width).
64    /// It makes a distinction between single value and multi-value elements,
65    /// and displays the tag, alias, and VR of each element.
66    ///
67    /// Note that this format is not stabilized,
68    /// and may change with subsequent versions of the crate.
69    #[default]
70    Text,
71    /// DICOM part 18 chapter F JSON format,
72    /// provided via [`dicom_json`]
73    Json,
74}
75
76/// Options and flags to configure how to dump a DICOM file or object.
77///
78/// This is a builder which exposes the various options available
79/// for printing the contents of the DICOM file in a readable way.
80///
81/// Once set up,
82/// the [`dump_file`] or [`dump_file_to`] methods can be used
83/// to finalize the DICOM data dumping process on an open file.
84/// Both file meta table and main data set are dumped.
85/// Alternatively,
86/// [`dump_object`] or [`dump_object_to`] methods
87/// work on bare DICOM objects without a file meta table.
88///
89/// [`dump_file`]: DumpOptions::dump_file
90/// [`dump_file_to`]: DumpOptions::dump_file_to
91/// [`dump_object`]: DumpOptions::dump_object
92/// [`dump_object_to`]: DumpOptions::dump_object_to
93///
94/// # Example
95///
96/// ```no_run
97/// use dicom_object::open_file;
98/// use dicom_dump::{ColorMode, DumpOptions};
99///
100/// let my_dicom_file = open_file("/path_to_file")?;
101/// let mut options = DumpOptions::new();
102/// options
103///     // maximum 120 characters per line
104///     .width(120)
105///     // no limit for text values
106///     .no_text_limit(true)
107///     // never print colored output
108///     .color_mode(ColorMode::Never)
109///     // dump to stdout
110///     .dump_file(&my_dicom_file)?;
111/// # Result::<(), Box<dyn std::error::Error>>::Ok(())
112/// ```
113#[derive(Debug, Default, Clone, PartialEq)]
114#[non_exhaustive]
115pub struct DumpOptions {
116    /// the output format
117    pub format: DumpFormat,
118    /// whether to produce colored output
119    pub color: ColorMode,
120    /// the console width to assume when trimming long values
121    pub width: Option<u32>,
122    /// never trim out long text values
123    pub no_text_limit: bool,
124    /// never trim out any values (implies `no_text_limit`)
125    pub no_limit: bool,
126}
127
128impl DumpOptions {
129    pub fn new() -> Self {
130        Default::default()
131    }
132
133    /// Set the output format.
134    ///
135    /// See the [`DumpFormat`] documentation for the list of supported formats.
136    pub fn format(&mut self, format: DumpFormat) -> &mut Self {
137        self.format = format;
138        self
139    }
140
141    /// Set the maximum output width in number of characters.
142    ///
143    /// The methods [`dump_file_to`] and [`dump_object_to`],
144    /// will print everything to the end,
145    /// regardless of this option.
146    pub fn width(&mut self, width: u32) -> &mut Self {
147        self.width = Some(width);
148        self
149    }
150
151    /// Set the maximum output width to automatic,
152    /// based on terminal size.
153    ///
154    /// This is the default behavior.
155    /// If a terminal width could not be determined,
156    /// the default width of 120 characters is used.
157    ///
158    /// The methods [`dump_file_to`] and [`dump_object_to`],
159    /// will print everything to the end,
160    /// regardless of this option.
161    pub fn width_auto(&mut self) -> &mut Self {
162        self.width = None;
163        self
164    }
165
166    /// Set whether to remove the maximum width restriction for text values.
167    pub fn no_text_limit(&mut self, no_text_limit: bool) -> &mut Self {
168        self.no_text_limit = no_text_limit;
169        self
170    }
171
172    /// Set whether to remove the maximum width restriction
173    /// for all DICOM values.
174    pub fn no_limit(&mut self, no_limit: bool) -> &mut Self {
175        self.no_limit = no_limit;
176        self
177    }
178
179    /// Set the output color mode.
180    pub fn color_mode(&mut self, color: ColorMode) -> &mut Self {
181        self.color = color;
182        self
183    }
184
185    /// Dump the contents of an open DICOM file to standard output.
186    pub fn dump_file<D>(&self, obj: &FileDicomObject<InMemDicomObject<D>>) -> IoResult<()>
187    where
188        D: DataDictionary,
189    {
190        self.dump_file_impl(stdout(), obj, true)
191    }
192
193    /// Dump the contents of an open DICOM file to the given writer.
194    pub fn dump_file_to<D>(
195        &self,
196        to: impl Write,
197        obj: &FileDicomObject<InMemDicomObject<D>>,
198    ) -> IoResult<()>
199    where
200        D: DataDictionary,
201    {
202        self.dump_file_impl(to, obj, false)
203    }
204
205    fn dump_file_impl<D>(
206        &self,
207        mut to: impl Write,
208        obj: &FileDicomObject<InMemDicomObject<D>>,
209        to_stdout: bool,
210    ) -> IoResult<()>
211    where
212        D: DataDictionary,
213    {
214        match self.color {
215            ColorMode::Never => owo_colors::set_override(false),
216            ColorMode::Always => owo_colors::set_override(true),
217            ColorMode::Auto => owo_colors::unset_override(),
218        }
219
220        let meta = obj.meta();
221
222        let width = determine_width(self.width);
223
224        let (no_text_limit, no_limit) = if to_stdout {
225            (self.no_text_limit, self.no_limit)
226        } else {
227            (true, true)
228        };
229        match self.format {
230            DumpFormat::Text => {
231                meta_dump(&mut to, meta, if no_limit { u32::MAX } else { width })?;
232
233                writeln!(to, "{:-<58}", "")?;
234
235                dump(&mut to, obj, width, 0, no_text_limit, no_limit)?;
236
237                Ok(())
238            }
239            DumpFormat::Json => {
240                let json_obj = DicomJson::from(obj);
241                serde_json::to_writer_pretty(stdout(), &json_obj)?;
242                Ok(())
243            }
244        }
245    }
246
247    /// Dump the contents of a DICOM object to standard output.
248    #[inline]
249    pub fn dump_object<D>(&self, obj: &InMemDicomObject<D>) -> IoResult<()>
250    where
251        D: DataDictionary,
252    {
253        self.dump_object_impl(stdout(), obj, true)
254    }
255
256    /// Dump the contents of a DICOM object to the given writer.
257    #[inline]
258    pub fn dump_object_to<D>(&self, to: impl Write, obj: &InMemDicomObject<D>) -> IoResult<()>
259    where
260        D: DataDictionary,
261    {
262        self.dump_object_impl(to, obj, false)
263    }
264
265    fn dump_object_impl<D>(
266        &self,
267        mut to: impl Write,
268        obj: &InMemDicomObject<D>,
269        to_stdout: bool,
270    ) -> IoResult<()>
271    where
272        D: DataDictionary,
273    {
274        match self.format {
275            DumpFormat::Text => {
276                match (self.color, to_stdout) {
277                    (ColorMode::Never, _) => colored::control::set_override(false),
278                    (ColorMode::Always, _) => colored::control::set_override(true),
279                    (ColorMode::Auto, false) => colored::control::set_override(false),
280                    (ColorMode::Auto, true) => colored::control::unset_override(),
281                }
282
283                let width = determine_width(self.width);
284
285                let (no_text_limit, no_limit) = if to_stdout {
286                    (self.no_text_limit, self.no_limit)
287                } else {
288                    (true, true)
289                };
290
291                dump(&mut to, obj, width, 0, no_text_limit, no_limit)?;
292
293                Ok(())
294            }
295            DumpFormat::Json => {
296                let json_obj = DicomJson::from(obj);
297                serde_json::to_writer_pretty(to, &json_obj)?;
298                Ok(())
299            }
300        }
301    }
302}
303
304/// Enumeration of output coloring modes.
305#[derive(Debug, Default, Copy, Clone, Eq, Hash, PartialEq)]
306pub enum ColorMode {
307    /// Produce colored output if supported by the destination
308    /// (namely, if the destination is a terminal).
309    /// When calling [`dump_file_to`](DumpOptions::dump_file_to)
310    /// or [`dump_object_to`](DumpOptions::dump_object_to),
311    /// the output will not be colored.
312    ///
313    /// This is the default behavior.
314    #[default]
315    Auto,
316    /// Never produce colored output.
317    Never,
318    /// Always produce colored output.
319    Always,
320}
321
322impl std::fmt::Display for ColorMode {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        match self {
325            ColorMode::Never => f.write_str("never"),
326            ColorMode::Auto => f.write_str("auto"),
327            ColorMode::Always => f.write_str("always"),
328        }
329    }
330}
331
332impl FromStr for ColorMode {
333    type Err = ColorModeError;
334    fn from_str(color: &str) -> Result<Self, Self::Err> {
335        match color {
336            "never" => Ok(ColorMode::Never),
337            "auto" => Ok(ColorMode::Auto),
338            "always" => Ok(ColorMode::Always),
339            _ => Err(ColorModeError),
340        }
341    }
342}
343
344/// The error raised when providing an invalid color mode.
345#[derive(Debug, Default, Copy, Clone, Eq, Hash, PartialEq)]
346pub struct ColorModeError;
347
348impl Display for ColorModeError {
349    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
350        f.write_str("invalid color mode")
351    }
352}
353
354impl std::error::Error for ColorModeError {}
355
356#[derive(Clone, Copy, Debug, PartialEq, Eq)]
357enum DumpValue<T>
358where
359    T: ToString,
360{
361    TagNum(T),
362    Alias(T),
363    Num(T),
364    Str(T),
365    DateTime(T),
366    Invalid(T),
367    Nothing,
368}
369
370impl<T> fmt::Display for DumpValue<T>
371where
372    T: fmt::Display,
373{
374    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
375        fn write_value_with_width(value: impl fmt::Display, f: &mut fmt::Formatter) -> fmt::Result {
376            if let Some(width) = f.width() {
377                write!(f, "{value:width$}")
378            } else {
379                write!(f, "{value}")
380            }
381        }
382
383        match self {
384            DumpValue::TagNum(v) => {
385                let value = v.if_supports_color(Stream::Stdout, |v| v.dimmed());
386                write_value_with_width(value, f)
387            }
388            DumpValue::Alias(v) => {
389                let value = v.if_supports_color(Stream::Stdout, |v| v.bold());
390                write_value_with_width(value, f)
391            }
392            DumpValue::Num(v) => {
393                let value = v.if_supports_color(Stream::Stdout, |v| v.cyan());
394                write_value_with_width(value, f)
395            }
396            DumpValue::Str(v) => {
397                let value = v.if_supports_color(Stream::Stdout, |v| v.yellow());
398                write_value_with_width(value, f)
399            }
400            DumpValue::DateTime(v) => {
401                let value = v.if_supports_color(Stream::Stdout, |v| v.green());
402                write_value_with_width(value, f)
403            }
404            DumpValue::Invalid(v) => {
405                let value = v.if_supports_color(Stream::Stdout, |v| v.red());
406                write_value_with_width(value, f)
407            }
408            DumpValue::Nothing => {
409                let value = "(no value)".if_supports_color(Stream::Stdout, |v| v.italic());
410                write_value_with_width(value, f)
411            }
412        }
413    }
414}
415
416/// Dump the contents of a DICOM file to stdout.
417///
418/// Both file meta table and main data set are dumped.
419pub fn dump_file<D>(obj: &FileDicomObject<InMemDicomObject<D>>) -> IoResult<()>
420where
421    D: DataDictionary,
422{
423    DumpOptions::new().dump_file(obj)
424}
425
426/// Dump the contents of a DICOM file to the given writer.
427///
428/// Both file meta table and main data set are dumped.
429pub fn dump_file_to<D>(to: impl Write, obj: &FileDicomObject<InMemDicomObject<D>>) -> IoResult<()>
430where
431    D: DataDictionary,
432{
433    DumpOptions::new().dump_file_to(to, obj)
434}
435
436/// Dump the contents of a DICOM object to stdout.
437pub fn dump_object<D>(obj: &InMemDicomObject<D>) -> IoResult<()>
438where
439    D: DataDictionary,
440{
441    DumpOptions::new().dump_object(obj)
442}
443
444/// Dump the contents of a DICOM object to the given writer.
445pub fn dump_object_to<D>(to: impl Write, obj: &InMemDicomObject<D>) -> IoResult<()>
446where
447    D: DataDictionary,
448{
449    DumpOptions::new().dump_object_to(to, obj)
450}
451
452#[inline]
453fn whitespace_or_null(c: char) -> bool {
454    c.is_whitespace() || c == '\0'
455}
456
457fn meta_dump<W>(to: &mut W, meta: &FileMetaTable, width: u32) -> IoResult<()>
458where
459    W: ?Sized + Write,
460{
461    let sop_class_uid = meta
462        .media_storage_sop_class_uid
463        .trim_end_matches(whitespace_or_null);
464
465    #[cfg(feature = "sop-class")]
466    #[inline]
467    fn translate_sop_class(uid: &str) -> Option<&'static str> {
468        StandardSopClassDictionary.by_uid(uid).map(|e| e.name)
469    }
470    #[cfg(not(feature = "sop-class"))]
471    #[inline]
472    fn translate_sop_class(_uid: &str) -> Option<&'static str> {
473        None
474    }
475
476    if let Some(name) = translate_sop_class(sop_class_uid) {
477        writeln!(
478            to,
479            "{}: {} ({})",
480            "Media Storage SOP Class UID".if_supports_color(Stream::Stdout, |v| v.bold()),
481            sop_class_uid,
482            name,
483        )?;
484    } else {
485        writeln!(
486            to,
487            "{}: {}",
488            "Media Storage SOP Class UID".if_supports_color(Stream::Stdout, |v| v.bold()),
489            sop_class_uid,
490        )?;
491    }
492    writeln!(
493        to,
494        "{}: {}",
495        "Media Storage SOP Instance UID".if_supports_color(Stream::Stdout, |v| v.bold()),
496        meta.media_storage_sop_instance_uid
497            .trim_end_matches(whitespace_or_null),
498    )?;
499    if let Some(ts) = TransferSyntaxRegistry.get(&meta.transfer_syntax) {
500        writeln!(
501            to,
502            "{}: {} ({})",
503            "Transfer Syntax".if_supports_color(Stream::Stdout, |v| v.bold()),
504            ts.uid(),
505            ts.name()
506        )?;
507    } else {
508        writeln!(
509            to,
510            "{}: {} («UNKNOWN»)",
511            "Transfer Syntax".if_supports_color(Stream::Stdout, |v| v.bold()),
512            meta.transfer_syntax.trim_end_matches(whitespace_or_null)
513        )?;
514    }
515    writeln!(
516        to,
517        "{}: {}",
518        "Implementation Class UID".if_supports_color(Stream::Stdout, |v| v.bold()),
519        meta.implementation_class_uid
520            .trim_end_matches(whitespace_or_null),
521    )?;
522
523    if let Some(v) = meta.implementation_version_name.as_ref() {
524        writeln!(
525            to,
526            "{}: {}",
527            "Implementation version name".if_supports_color(Stream::Stdout, |v| v.bold()),
528            v.trim_end()
529        )?;
530    }
531
532    if let Some(v) = meta.source_application_entity_title.as_ref() {
533        writeln!(
534            to,
535            "{}: {}",
536            "Source Application Entity Title".if_supports_color(Stream::Stdout, |v| v.bold()),
537            v.trim_end()
538        )?;
539    }
540
541    if let Some(v) = meta.sending_application_entity_title.as_ref() {
542        writeln!(
543            to,
544            "{}: {}",
545            "Sending Application Entity Title".if_supports_color(Stream::Stdout, |v| v.bold()),
546            v.trim_end()
547        )?;
548    }
549
550    if let Some(v) = meta.receiving_application_entity_title.as_ref() {
551        writeln!(
552            to,
553            "{}: {}",
554            "Receiving Application Entity Title".if_supports_color(Stream::Stdout, |v| v.bold()),
555            v.trim_end()
556        )?;
557    }
558
559    if let Some(v) = meta.private_information_creator_uid.as_ref() {
560        writeln!(
561            to,
562            "{}: {}",
563            "Private Information Creator UID".if_supports_color(Stream::Stdout, |v| v.bold()),
564            v.trim_end_matches(whitespace_or_null)
565        )?;
566    }
567
568    if let Some(v) = meta.private_information.as_ref() {
569        writeln!(
570            to,
571            "{}: {}",
572            "Private Information".if_supports_color(Stream::Stdout, |v| v.bold()),
573            format_value_list(v.iter().map(|n| format!("{n:02X}")), Some(width), false)
574        )?;
575    }
576
577    writeln!(to)?;
578    Ok(())
579}
580
581fn dump<W, D>(
582    to: &mut W,
583    obj: &InMemDicomObject<D>,
584    width: u32,
585    depth: u32,
586    no_text_limit: bool,
587    no_limit: bool,
588) -> IoResult<()>
589where
590    W: ?Sized + Write,
591    D: DataDictionary,
592{
593    for elem in obj {
594        dump_element(&mut *to, elem, width, depth, no_text_limit, no_limit)?;
595    }
596
597    Ok(())
598}
599
600pub fn dump_element<W, D>(
601    to: &mut W,
602    elem: &InMemElement<D>,
603    width: u32,
604    depth: u32,
605    no_text_limit: bool,
606    no_limit: bool,
607) -> IoResult<()>
608where
609    W: ?Sized + Write,
610    D: DataDictionary,
611{
612    let indent = vec![b' '; (depth * 2) as usize];
613    let tag_alias = StandardDataDictionary
614        .by_tag(elem.tag())
615        .map(DataDictionaryEntry::alias)
616        .unwrap_or("«Unknown Attribute»");
617    to.write_all(&indent)?;
618    let vm = match elem.vr() {
619        VR::OB | VR::OW | VR::UN => 1,
620        _ => elem.value().multiplicity(),
621    };
622
623    match elem.value() {
624        DicomValue::Sequence(seq) => {
625            writeln!(
626                to,
627                "{} {:28} {} ({} Item{})",
628                DumpValue::TagNum(elem.tag()),
629                DumpValue::Alias(tag_alias),
630                elem.vr(),
631                vm,
632                if vm == 1 { "" } else { "s" },
633            )?;
634            for item in seq.items() {
635                dump_item(&mut *to, item, width, depth + 2, no_text_limit, no_limit)?;
636            }
637            to.write_all(&indent)?;
638            writeln!(
639                to,
640                "{} {}",
641                DumpValue::TagNum("(FFFE,E0DD)"),
642                DumpValue::Alias("SequenceDelimitationItem"),
643            )?;
644        }
645        DicomValue::PixelSequence(seq) => {
646            // write pixel sequence start line
647            let vr = elem.vr();
648            let num_items = 1 + seq.fragments().len();
649            writeln!(
650                to,
651                "{} {:28} {} (PixelSequence, {} Item{})",
652                DumpValue::TagNum(elem.tag()),
653                "PixelData".bold(),
654                vr,
655                num_items,
656                if num_items == 1 { "" } else { "s" },
657            )?;
658
659            // write offset table
660            let offset_table = seq.offset_table();
661            let byte_len = offset_table.len() * 4;
662            let summary = offset_table_summary(
663                offset_table,
664                Some(width)
665                    .filter(|_| !no_limit)
666                    .map(|w| w.saturating_sub(38 + depth * 2)),
667            );
668            writeln!(
669                to,
670                "  {} offset table ({:>2}, {:>2} bytes): {}",
671                DumpValue::TagNum("(FFFE,E000)"),
672                offset_table.len(),
673                byte_len,
674                summary,
675            )?;
676
677            // write compressed fragments
678            for fragment in seq.fragments() {
679                let byte_len = fragment.len();
680                let summary = item_value_summary(
681                    fragment,
682                    Some(width)
683                        .filter(|_| !no_limit)
684                        .map(|w| w.saturating_sub(38 + depth * 2)),
685                );
686                writeln!(
687                    to,
688                    "  {} pi ({:>3} bytes): {}",
689                    DumpValue::TagNum("(FFFE,E000)"),
690                    byte_len,
691                    summary
692                )?;
693            }
694        }
695        DicomValue::Primitive(value) => {
696            let vr = elem.vr();
697            let byte_len = elem.header().len.0;
698            writeln!(
699                to,
700                "{} {:28} {} ({},{:>3} bytes): {}",
701                DumpValue::TagNum(elem.tag()),
702                DumpValue::Alias(tag_alias),
703                vr,
704                vm,
705                byte_len,
706                value_summary(
707                    value,
708                    vr,
709                    width.saturating_sub(63 + depth * 2),
710                    no_text_limit,
711                    no_limit,
712                ),
713            )?;
714        }
715    }
716
717    Ok(())
718}
719
720fn dump_item<W, D>(
721    to: &mut W,
722    item: &InMemDicomObject<D>,
723    width: u32,
724    depth: u32,
725    no_text_limit: bool,
726    no_limit: bool,
727) -> IoResult<()>
728where
729    W: ?Sized + Write,
730    D: DataDictionary,
731{
732    let indent: String = "  ".repeat(depth as usize);
733    writeln!(
734        to,
735        "{}{} na {}",
736        indent,
737        DumpValue::TagNum("(FFFE,E000)"),
738        DumpValue::Alias("Item"),
739    )?;
740    dump(to, item, width, depth + 1, no_text_limit, no_limit)?;
741    writeln!(
742        to,
743        "{}{} {}",
744        indent,
745        DumpValue::TagNum("(FFFE,E00D)"),
746        DumpValue::Alias("ItemDelimitationItem"),
747    )?;
748    Ok(())
749}
750
751fn value_summary(
752    value: &PrimitiveValue,
753    vr: VR,
754    max_characters: u32,
755    no_text_limit: bool,
756    no_limit: bool,
757) -> DumpValue<String> {
758    use PrimitiveValue::*;
759
760    let max_characters = match (no_limit, no_text_limit, vr) {
761        (true, _, _) => None,
762        (
763            false,
764            true,
765            VR::CS
766            | VR::AE
767            | VR::AS
768            | VR::DA
769            | VR::DS
770            | VR::DT
771            | VR::IS
772            | VR::LO
773            | VR::LT
774            | VR::PN
775            | VR::SH
776            | VR::ST
777            | VR::TM
778            | VR::UC
779            | VR::UI
780            | VR::UR
781            | VR::UT,
782        ) => None,
783        (false, _, _) => Some(max_characters),
784    };
785    match (value, vr) {
786        (F32(values), _) => DumpValue::Num(format_value_list(values, max_characters, false)),
787        (F64(values), _) => DumpValue::Num(format_value_list(values, max_characters, false)),
788        (I32(values), _) => DumpValue::Num(format_value_list(values, max_characters, false)),
789        (I64(values), _) => DumpValue::Num(format_value_list(values, max_characters, false)),
790        (U32(values), _) => DumpValue::Num(format_value_list(values, max_characters, false)),
791        (U64(values), _) => DumpValue::Num(format_value_list(values, max_characters, false)),
792        (I16(values), _) => DumpValue::Num(format_value_list(values, max_characters, false)),
793        (U16(values), VR::OW) => DumpValue::Num(format_value_list(
794            values.into_iter().map(|n| format!("{n:02X}")),
795            max_characters,
796            false,
797        )),
798        (U16(values), _) => DumpValue::Num(format_value_list(values, max_characters, false)),
799        (U8(values), VR::OB) | (U8(values), VR::UN) => DumpValue::Num(format_value_list(
800            values.into_iter().map(|n| format!("{n:02X}")),
801            max_characters,
802            false,
803        )),
804        (U8(values), _) => DumpValue::Num(format_value_list(values, max_characters, false)),
805        (Tags(values), _) => DumpValue::Str(format_value_list(values, max_characters, false)),
806        (Strs(values), VR::DA) => {
807            match value.to_multi_date() {
808                Ok(values) => {
809                    // print as reformatted date
810                    DumpValue::DateTime(format_value_list(values, max_characters, false))
811                }
812                Err(_e) => {
813                    // print as text
814                    DumpValue::Invalid(format_value_list(values, max_characters, true))
815                }
816            }
817        }
818        (Strs(values), VR::TM) => {
819            match value.to_multi_time() {
820                Ok(values) => {
821                    // print as reformatted date
822                    DumpValue::DateTime(format_value_list(values, max_characters, false))
823                }
824                Err(_e) => {
825                    // print as text
826                    DumpValue::Invalid(format_value_list(values, max_characters, true))
827                }
828            }
829        }
830        (Strs(values), VR::DT) => {
831            match value.to_multi_datetime() {
832                Ok(values) => {
833                    // print as reformatted date
834                    DumpValue::DateTime(format_value_list(values, max_characters, false))
835                }
836                Err(_e) => {
837                    // print as text
838                    DumpValue::Invalid(format_value_list(values, max_characters, true))
839                }
840            }
841        }
842        (Strs(values), _) => DumpValue::Str(format_value_list(
843            values
844                .iter()
845                .map(|s| s.trim_end_matches(whitespace_or_null)),
846            max_characters,
847            true,
848        )),
849        (Date(values), _) => DumpValue::DateTime(format_value_list(values, max_characters, false)),
850        (Time(values), _) => DumpValue::DateTime(format_value_list(values, max_characters, false)),
851        (DateTime(values), _) => {
852            DumpValue::DateTime(format_value_list(values, max_characters, false))
853        }
854        (Str(value), _) => {
855            let txt = format!(
856                "\"{}\"",
857                value
858                    .to_string()
859                    .trim_end_matches(whitespace_or_null)
860                    // sanitize input
861                    .replace('\n', "␊")
862                    .replace('\r', "␍")
863                    .replace('\0', "␀")
864                    .replace(|c: char| c.is_control(), "�")
865            );
866            if let Some(max) = max_characters {
867                DumpValue::Str(cut_str(&txt, max).to_string())
868            } else {
869                DumpValue::Str(txt)
870            }
871        }
872        (Empty, _) => DumpValue::Nothing,
873    }
874}
875
876fn item_value_summary(data: &[u8], max_characters: Option<u32>) -> DumpValue<String> {
877    DumpValue::Num(format_value_list(
878        data.iter().map(|n| format!("{n:02X}")),
879        max_characters,
880        false,
881    ))
882}
883
884fn offset_table_summary(data: &[u32], max_characters: Option<u32>) -> String {
885    if data.is_empty() {
886        format!("{}", "(empty)".italic())
887    } else {
888        format_value_list(
889            data.iter().map(|n| format!("{n:04X}")),
890            max_characters,
891            false,
892        )
893    }
894}
895
896fn format_value_list<I>(values: I, max_characters: Option<u32>, quoted: bool) -> String
897where
898    I: IntoIterator,
899    I::IntoIter: ExactSizeIterator,
900    I::Item: std::fmt::Display,
901{
902    let values = values.into_iter();
903    let len = values.len();
904    let mut acc_size = 0;
905    let mut pieces = String::new();
906    if len > 1 {
907        pieces.push('[');
908    }
909    for piece in values {
910        let mut piece = piece.to_string();
911        // sanitize value piece
912        piece = piece
913            .replace('\n', "␊")
914            .replace('\r', "␍")
915            .replace('\0', "␀")
916            .replace(|c: char| c.is_control(), "�");
917
918        if acc_size > 0 {
919            pieces.push_str(", ");
920        }
921
922        if quoted {
923            piece = piece.replace('\"', "\\\"");
924            pieces.push('"');
925        }
926
927        acc_size += piece.len();
928        pieces.push_str(&piece);
929        if quoted {
930            pieces.push('"');
931        }
932        // stop earlier if applicable
933        if max_characters
934            .filter(|max| (*max as usize) < acc_size)
935            .is_some()
936        {
937            break;
938        }
939    }
940    if len > 1 {
941        pieces.push(']');
942    }
943    if let Some(max_characters) = max_characters {
944        cut_str(&pieces, max_characters).into_owned()
945    } else {
946        pieces
947    }
948}
949
950fn cut_str(s: &str, max_characters: u32) -> Cow<'_, str> {
951    let max = (max_characters.saturating_sub(3)) as usize;
952    let len = s.chars().count();
953
954    if len > max {
955        s.chars()
956            .take(max)
957            .chain("...".chars())
958            .collect::<String>()
959            .into()
960    } else {
961        s.into()
962    }
963}
964
965fn determine_width(user_width: Option<u32>) -> u32 {
966    user_width
967        .or_else(|| terminal_size::terminal_size().map(|(w, _)| w.0 as u32))
968        .unwrap_or(120)
969}
970
971#[cfg(test)]
972mod tests {
973
974    use dicom_core::{value::DicomDate, DataElement, PrimitiveValue, VR};
975    use dicom_dictionary_std::tags;
976    use dicom_object::{FileMetaTableBuilder, InMemDicomObject};
977
978    use super::whitespace_or_null;
979    use crate::{ColorMode, DumpOptions};
980
981    #[test]
982    fn trims_all_whitespace() {
983        assert_eq!("   ".trim_end_matches(whitespace_or_null), "");
984        assert_eq!("\0".trim_end_matches(whitespace_or_null), "");
985        assert_eq!("1.4.5.6\0".trim_end_matches(whitespace_or_null), "1.4.5.6");
986        assert_eq!("AETITLE ".trim_end_matches(whitespace_or_null), "AETITLE");
987    }
988
989    #[test]
990    fn dump_file_to_covers_properties() {
991        // create object
992        let obj = InMemDicomObject::from_element_iter(vec![DataElement::new(
993            tags::SOP_INSTANCE_UID,
994            VR::UI,
995            PrimitiveValue::from("1.2.888.123"),
996        )]);
997
998        let file = obj
999            .with_meta(
1000                FileMetaTableBuilder::new()
1001                    // Implicit VR Little Endian
1002                    .transfer_syntax("1.2.840.10008.1.2")
1003                    // Computed Radiography Image Storage
1004                    .media_storage_sop_class_uid("1.2.840.10008.5.1.4.1.1.1"),
1005            )
1006            .unwrap();
1007
1008        let mut out = Vec::new();
1009        DumpOptions::new()
1010            .color_mode(ColorMode::Never)
1011            .dump_file_to(&mut out, &file)
1012            .unwrap();
1013
1014        let lines: Vec<_> = std::str::from_utf8(&out)
1015            .expect("output is not valid UTF-8")
1016            .split('\n')
1017            .collect();
1018        if cfg!(feature = "sop-class") {
1019            assert_eq!(
1020                lines[0],
1021                "Media Storage SOP Class UID: 1.2.840.10008.5.1.4.1.1.1 (Computed Radiography Image Storage)"
1022            );
1023        } else {
1024            assert_eq!(
1025                lines[0],
1026                "Media Storage SOP Class UID: 1.2.840.10008.5.1.4.1.1.1"
1027            );
1028        }
1029        assert_eq!(lines[1], "Media Storage SOP Instance UID: 1.2.888.123");
1030        assert_eq!(
1031            lines[2],
1032            "Transfer Syntax: 1.2.840.10008.1.2 (Implicit VR Little Endian)"
1033        );
1034        assert!(lines[3].starts_with("Implementation Class UID: "));
1035        assert!(lines[4].starts_with("Implementation version name: "));
1036        assert_eq!(lines[5], "");
1037        assert_eq!(
1038            lines[6],
1039            "----------------------------------------------------------"
1040        );
1041
1042        let parts: Vec<&str> = lines[7].split(" ").filter(|p| !p.is_empty()).collect();
1043        assert_eq!(&parts[..3], &["(0008,0018)", "SOPInstanceUID", "UI"]);
1044    }
1045
1046    #[test]
1047    fn dump_object_to_covers_properties() {
1048        // create object
1049        let obj = InMemDicomObject::from_element_iter([
1050            DataElement::new(
1051                tags::SOP_INSTANCE_UID,
1052                VR::UI,
1053                PrimitiveValue::from("1.2.888.123"),
1054            ),
1055            DataElement::new(
1056                tags::STUDY_DATE,
1057                VR::DA,
1058                PrimitiveValue::from(DicomDate::from_ymd(2017, 1, 1).unwrap()),
1059            ),
1060            DataElement::new(tags::CONTENT_DATE, VR::DA, PrimitiveValue::Empty),
1061            DataElement::new(tags::MODALITY, VR::CS, PrimitiveValue::from("OT")),
1062            DataElement::new(
1063                tags::INSTITUTION_NAME,
1064                VR::LO,
1065                PrimitiveValue::from("Hospital"),
1066            ),
1067            DataElement::new(
1068                tags::INSTITUTION_ADDRESS,
1069                VR::ST,
1070                PrimitiveValue::from("Country Roads 1\nWest Virginia"),
1071            ),
1072            DataElement::new(tags::SAMPLES_PER_PIXEL, VR::US, PrimitiveValue::from(3_u16)),
1073        ]);
1074
1075        let mut out = Vec::new();
1076        DumpOptions::new()
1077            .color_mode(ColorMode::Never)
1078            .dump_object_to(&mut out, &obj)
1079            .unwrap();
1080
1081        let lines: Vec<_> = std::str::from_utf8(&out)
1082            .expect("output is not valid UTF-8")
1083            .split('\n')
1084            .collect();
1085
1086        check_line(
1087            lines[0],
1088            ("(0008,0018)", "SOPInstanceUID", "UI", "\"1.2.888.123\""),
1089        );
1090        check_line(lines[1], ("(0008,0020)", "StudyDate", "DA", "2017-01-01"));
1091        check_line(lines[2], ("(0008,0023)", "ContentDate", "DA", "(no value)"));
1092        check_line(lines[3], ("(0008,0060)", "Modality", "CS", "\"OT\""));
1093        check_line(
1094            lines[4],
1095            ("(0008,0080)", "InstitutionName", "LO", "\"Hospital\""),
1096        );
1097        check_line(
1098            lines[5],
1099            (
1100                "(0008,0081)",
1101                "InstitutionAddress",
1102                "ST",
1103                "\"Country Roads 1␊West Virginia\"",
1104            ),
1105        );
1106        check_line(lines[6], ("(0028,0002)", "SamplesPerPixel", "US", "3"));
1107
1108        fn check_line(line: &str, expected: (&str, &str, &str, &str)) {
1109            let parts: Vec<&str> = line.split(" ").filter(|p| !p.is_empty()).collect();
1110            let value = line.split(':').nth(1).unwrap().trim();
1111            assert_eq!(&parts[..3], &[expected.0, expected.1, expected.2]);
1112            assert_eq!(value, expected.3);
1113        }
1114    }
1115
1116    #[test]
1117    fn dump_json() {
1118        // create object
1119        let obj = InMemDicomObject::from_element_iter(vec![DataElement::new(
1120            tags::SOP_INSTANCE_UID,
1121            VR::UI,
1122            PrimitiveValue::from("1.2.888.123"),
1123        )]);
1124
1125        let mut out = Vec::new();
1126        DumpOptions::new()
1127            .color_mode(ColorMode::Never)
1128            .format(crate::DumpFormat::Json)
1129            .dump_object_to(&mut out, &obj)
1130            .unwrap();
1131
1132        let json = std::str::from_utf8(&out).expect("output is not valid UTF-8");
1133        assert_eq!(
1134            json,
1135            r#"{
1136  "00080018": {
1137    "vr": "UI",
1138    "Value": [
1139      "1.2.888.123"
1140    ]
1141  }
1142}"#
1143        );
1144    }
1145}