Skip to main content

document_svg/document/
cda.rs

1//! Bounded HL7 Clinical Document Architecture (CDA) previews.
2//!
3//! CDA documents contain protected patient summaries and narrative sections.
4//! This adapter validates the document envelope and reports structural counts
5//! only; PHI, identifiers, narrative text, coded values and references remain
6//! inert.
7
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::html::{HtmlBlock, render_blocks_to_pages};
12use crate::error::{Error, Result};
13use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
14use crate::table::{TableAlign, TableData};
15
16const MAX_CDA_BYTES: u64 = 128 * 1024 * 1024;
17const MAX_CDA_EVENTS: usize = 1_000_000;
18const MAX_CDA_NODES: usize = 500_000;
19const MAX_CDA_DEPTH: usize = 128;
20const MAX_CDA_TEXT_BYTES: usize = 32 * 1024 * 1024;
21const MAX_CDA_ROWS: usize = 200_000;
22const MAX_CDA_DISPLAY_BYTES: usize = 512;
23const CDA_NAMESPACE: &str = "urn:hl7-org:v3";
24
25#[derive(Default)]
26struct Summary {
27    sections: usize,
28    entries: usize,
29    observations: usize,
30    acts: usize,
31    encounters: usize,
32    procedures: usize,
33    organizers: usize,
34    authors: usize,
35    participants: usize,
36    assigned_entities: usize,
37    narrative_blocks: usize,
38    templates: usize,
39    rows: Vec<Vec<String>>,
40}
41
42struct CdaPageSink<'a> {
43    inner: &'a mut dyn PageConsumer,
44    warnings: &'a [String],
45}
46
47impl PageConsumer for CdaPageSink<'_> {
48    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
49        page.source_format = "cda".into();
50        if page.title.is_empty() {
51            page.title = "CDA clinical document".into();
52        }
53        page.description =
54            "HL7 CDA structure is rendered as bounded inert metadata; patient data and narrative content are not exposed".into();
55        for warning in self.warnings {
56            page.warn(warning.clone());
57        }
58        self.inner.consume(page)
59    }
60}
61
62pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
63    crate::geospatial::xml_tree::looks_like_root(prefix, b"ClinicalDocument", None)
64        && String::from_utf8_lossy(prefix)
65            .to_ascii_lowercase()
66            .contains("urn:hl7-org:v3")
67}
68
69pub(crate) fn convert(
70    path: &Path,
71    options: &ConvertOptions,
72    sink: &mut dyn PageConsumer,
73) -> Result<Vec<String>> {
74    let bytes = read_limited_file(
75        path,
76        options.max_input_bytes.min(MAX_CDA_BYTES),
77        "CDA input",
78    )?;
79    let root = parse_xml_tree(
80        &bytes,
81        &XmlLimits {
82            max_events: options.max_xml_events.min(MAX_CDA_EVENTS),
83            max_nodes: MAX_CDA_NODES,
84            max_depth: MAX_CDA_DEPTH,
85            max_text_bytes: MAX_CDA_TEXT_BYTES,
86        },
87        "CDA",
88    )?;
89    if !root.name.eq_ignore_ascii_case("ClinicalDocument") {
90        return Err(Error::InvalidInput(
91            "CDA root must be <ClinicalDocument>".into(),
92        ));
93    }
94    if root.namespace.as_deref() != Some(CDA_NAMESPACE) {
95        return Err(Error::InvalidInput(
96            "CDA root uses an unsupported namespace".into(),
97        ));
98    }
99    let mut summary = Summary {
100        sections: count_named(&root, "section"),
101        entries: count_named(&root, "entry"),
102        observations: count_named(&root, "observation"),
103        acts: count_named(&root, "act"),
104        encounters: count_named(&root, "encounter"),
105        procedures: count_named(&root, "procedure"),
106        organizers: count_named(&root, "organizer"),
107        authors: count_named(&root, "author"),
108        participants: count_named(&root, "participant"),
109        assigned_entities: count_named(&root, "assignedEntity"),
110        narrative_blocks: count_named(&root, "text")
111            + count_named(&root, "list")
112            + count_named(&root, "table"),
113        templates: count_named(&root, "templateId"),
114        ..Summary::default()
115    };
116    push_row(
117        &mut summary.rows,
118        "Document",
119        &root.name,
120        &format!(
121            "sections={} templates={}",
122            summary.sections, summary.templates
123        ),
124    )?;
125    push_row(
126        &mut summary.rows,
127        "Clinical entries",
128        &summary.entries.to_string(),
129        &format!(
130            "observations={} acts={} organizers={}",
131            summary.observations, summary.acts, summary.organizers
132        ),
133    )?;
134    push_row(
135        &mut summary.rows,
136        "Care context",
137        &summary.encounters.to_string(),
138        &format!(
139            "procedures={} authors={} participants={} entities={}",
140            summary.procedures, summary.authors, summary.participants, summary.assigned_entities
141        ),
142    )?;
143    push_row(
144        &mut summary.rows,
145        "Narrative",
146        &summary.narrative_blocks.to_string(),
147        "narrative values omitted",
148    )?;
149    let blocks = vec![
150        HtmlBlock::Heading {
151            level: 1,
152            text: "CDA clinical document".into(),
153        },
154        HtmlBlock::Paragraph {
155            text: "Clinical Document Architecture structure is summarized without displaying protected health information or narrative text.".into(),
156        },
157        HtmlBlock::Table(TableData {
158            headers: vec!["Kind".into(), "Value".into(), "Detail".into()],
159            rows: summary.rows,
160            alignments: vec![TableAlign::Left; 3],
161            raw_source: String::new(),
162        }),
163    ];
164    let warnings = vec![
165        "CDA patient names, identifiers, dates, narrative XHTML, coded values, addresses, providers, references and clinical payloads are omitted or redacted; this preview is not de-identification".into(),
166        "CDA templates, vocabulary/schema locations, XSLT stylesheets, external documents and clinical operations are never fetched or executed".into(),
167    ];
168    let mut page_sink = CdaPageSink {
169        inner: sink,
170        warnings: &warnings,
171    };
172    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
173    Ok(warnings)
174}
175
176fn count_named(element: &XmlElement, name: &str) -> usize {
177    element
178        .children
179        .iter()
180        .map(|child| usize::from(child.name.eq_ignore_ascii_case(name)) + count_named(child, name))
181        .sum()
182}
183
184fn push_row(rows: &mut Vec<Vec<String>>, kind: &str, value: &str, detail: &str) -> Result<()> {
185    if rows.len() >= MAX_CDA_ROWS {
186        return Err(Error::LimitExceeded(format!(
187            "CDA rows exceed {MAX_CDA_ROWS}"
188        )));
189    }
190    rows.push(vec![truncate(kind), truncate(value), truncate(detail)]);
191    Ok(())
192}
193
194fn truncate(value: &str) -> String {
195    if value.len() <= MAX_CDA_DISPLAY_BYTES {
196        value.to_owned()
197    } else {
198        let mut end = MAX_CDA_DISPLAY_BYTES;
199        while end > 0 && !value.is_char_boundary(end) {
200            end -= 1;
201        }
202        format!("{}…", &value[..end])
203    }
204}