1use 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}