1use std::collections::HashMap;
4use std::path::Path;
5
6use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
7use crate::document::html::{HtmlBlock, render_blocks_to_pages};
8use crate::error::{Error, Result};
9use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
10use crate::table::{TableAlign, TableData};
11
12const MAX_DMN_BYTES: u64 = 32 * 1024 * 1024;
13const MAX_DMN_EVENTS: usize = 500_000;
14const MAX_DMN_NODES: usize = 200_000;
15const MAX_DMN_DEPTH: usize = 80;
16const MAX_DMN_TEXT_BYTES: usize = 16 * 1024 * 1024;
17const MAX_DMN_TABLES: usize = 10_000;
18const MAX_DMN_RULES: usize = 200_000;
19const MAX_DMN_COLUMNS: usize = 128;
20const MAX_DMN_CELLS: usize = 1_000_000;
21const MAX_DMN_VALUE_BYTES: usize = 2 * 1024 * 1024;
22const MAX_DMN_RENDERED_BYTES: usize = 32 * 1024 * 1024;
23
24const DMN_MODEL_15: &str = "https://www.omg.org/spec/DMN/20230324/MODEL/";
25const DMN_MODEL_14: &str = "https://www.omg.org/spec/DMN/20211108/MODEL/";
26const DMN_MODEL_13: &str = "https://www.omg.org/spec/DMN/20191111/MODEL/";
27const DMN_MODEL_12: &str = "https://www.omg.org/spec/DMN/20180521/MODEL/";
28const DMN_MODEL_11: &str = "http://www.omg.org/spec/DMN/20151101/dmn.xsd";
29
30struct DmnPageSink<'a> {
31 inner: &'a mut dyn PageConsumer,
32 warnings: &'a [String],
33}
34
35impl PageConsumer for DmnPageSink<'_> {
36 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
37 page.source_format = "dmn".into();
38 if page.title.is_empty() {
39 page.title = "DMN decision model".into();
40 }
41 for warning in self.warnings {
42 page.warn(warning.clone());
43 }
44 self.inner.consume(page)
45 }
46}
47
48pub(crate) fn convert(
49 path: &Path,
50 options: &ConvertOptions,
51 sink: &mut dyn PageConsumer,
52) -> Result<Vec<String>> {
53 let bytes = read_limited_file(
54 path,
55 options.max_input_bytes.min(MAX_DMN_BYTES),
56 "DMN input",
57 )?;
58 let root = parse_dmn(&bytes)?;
59 let (blocks, warnings) = render_dmn(&root)?;
60 if options.max_pages == 0 {
61 return Err(Error::LimitExceeded(
62 "DMN conversion requires at least one page; max_pages is zero".into(),
63 ));
64 }
65 let mut page_sink = DmnPageSink {
66 inner: sink,
67 warnings: &warnings,
68 };
69 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
70 Ok(warnings)
71}
72
73pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
74 crate::geospatial::xml_tree::looks_like_root(bytes, b"definitions", None)
75 && parse_dmn_prefix_root(bytes)
76}
77
78fn parse_dmn(bytes: &[u8]) -> Result<XmlElement> {
79 let root = parse_xml_tree(
80 bytes,
81 &XmlLimits {
82 max_events: MAX_DMN_EVENTS,
83 max_nodes: MAX_DMN_NODES,
84 max_depth: MAX_DMN_DEPTH,
85 max_text_bytes: MAX_DMN_TEXT_BYTES,
86 },
87 "DMN",
88 )?;
89 if root.name != "definitions" || !is_dmn_model_namespace(root.namespace.as_deref()) {
90 return Err(Error::Unsupported(
91 "DMN input must have a recognized DMN definitions namespace".into(),
92 ));
93 }
94 Ok(root)
95}
96
97fn parse_dmn_prefix_root(bytes: &[u8]) -> bool {
98 let mut reader = quick_xml::NsReader::from_reader(std::io::Cursor::new(bytes));
99 let mut buffer = Vec::new();
100 loop {
101 match reader.read_resolved_event_into(&mut buffer) {
102 Ok((namespace, quick_xml::events::Event::Start(element)))
103 | Ok((namespace, quick_xml::events::Event::Empty(element))) => {
104 return element.name().as_ref() == b"definitions"
105 && match namespace {
106 quick_xml::name::ResolveResult::Bound(namespace) => {
107 is_dmn_model_namespace(std::str::from_utf8(namespace.as_ref()).ok())
108 }
109 _ => false,
110 };
111 }
112 Ok((_, quick_xml::events::Event::Eof)) | Err(_) => return false,
113 _ => buffer.clear(),
114 }
115 buffer.clear();
116 }
117}
118
119fn is_dmn_model_namespace(namespace: Option<&str>) -> bool {
120 matches!(
121 namespace,
122 Some(DMN_MODEL_11 | DMN_MODEL_12 | DMN_MODEL_13 | DMN_MODEL_14 | DMN_MODEL_15)
123 )
124}
125
126fn render_dmn(root: &XmlElement) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
127 let mut elements = Vec::new();
128 collect_elements(root, &mut elements);
129 let mut names = HashMap::<String, (String, String)>::new();
130 for element in &elements {
131 if element.namespace.as_deref() == root.namespace.as_deref()
132 && let Some(id) = element.attribute("id")
133 && names
134 .insert(
135 id.to_owned(),
136 (
137 element.name.clone(),
138 element.attribute("name").unwrap_or_default().to_owned(),
139 ),
140 )
141 .is_some()
142 {
143 return Err(Error::InvalidInput(format!(
144 "DMN document contains duplicate id {id:?}"
145 )));
146 }
147 }
148
149 let decisions = elements
150 .into_iter()
151 .filter(|element| {
152 element.name == "decision" && element.namespace.as_deref() == root.namespace.as_deref()
153 })
154 .collect::<Vec<_>>();
155
156 let mut tables_seen = 0usize;
157 let mut rule_count = 0usize;
158 let mut cell_count = 0usize;
159 let mut rendered_bytes = 0usize;
160 let mut warnings = Vec::new();
161 let mut blocks = vec![HtmlBlock::Heading {
162 level: 1,
163 text: "DMN decision model".into(),
164 }];
165
166 for decision in decisions {
167 let decision_name = decision
168 .attribute("name")
169 .or_else(|| decision.attribute("id"))
170 .unwrap_or("Unnamed decision");
171 blocks.push(HtmlBlock::Heading {
172 level: 2,
173 text: decision_name.to_owned(),
174 });
175 push_text(
176 &mut blocks,
177 &mut rendered_bytes,
178 format!("Decision: {decision_name}"),
179 )?;
180 append_information_requirements(
181 decision,
182 &names,
183 &mut blocks,
184 &mut warnings,
185 &mut rendered_bytes,
186 )?;
187
188 let Some(table) = decision.children.iter().find(|child| {
189 child.name == "decisionTable" && child.namespace.as_deref() == root.namespace.as_deref()
190 }) else {
191 if let Some(expression) = decision.children.iter().find(|child| {
192 child.name == "literalExpression"
193 && child.namespace.as_deref() == root.namespace.as_deref()
194 }) {
195 let literal = child_text(expression, "text").unwrap_or_default();
196 push_text(
197 &mut blocks,
198 &mut rendered_bytes,
199 format!("Literal expression (displayed, not evaluated): {literal}"),
200 )?;
201 } else {
202 warnings.push(format!(
203 "DMN decision {decision_name:?} has no supported decision table/literal expression"
204 ));
205 }
206 continue;
207 };
208 tables_seen = tables_seen.saturating_add(1);
209 if tables_seen > MAX_DMN_TABLES {
210 return Err(Error::LimitExceeded(format!(
211 "DMN input exceeds {MAX_DMN_TABLES} decision tables"
212 )));
213 }
214 let hit_policy = table.attribute("hitPolicy").unwrap_or("UNIQUE");
215 let aggregation = table.attribute("aggregation").unwrap_or_default();
216 let mut headers = Vec::<String>::new();
217 let mut inputs = Vec::new();
218 let mut outputs = Vec::new();
219 for clause in &table.children {
220 if clause.namespace.as_deref() != root.namespace.as_deref() {
221 continue;
222 }
223 match clause.name.as_str() {
224 "input" => {
225 let label = clause
226 .attribute("label")
227 .filter(|label| !label.is_empty())
228 .map(str::to_owned)
229 .or_else(|| {
230 clause
231 .children
232 .iter()
233 .find(|child| {
234 child.name == "inputExpression"
235 && child.namespace.as_deref() == root.namespace.as_deref()
236 })
237 .and_then(|expression| child_text(expression, "text"))
238 })
239 .unwrap_or_else(|| format!("Input {}", inputs.len() + 1));
240 inputs.push(label.clone());
241 headers.push(label);
242 }
243 "output" => {
244 let label = clause
245 .attribute("label")
246 .filter(|label| !label.is_empty())
247 .or_else(|| clause.attribute("name").filter(|name| !name.is_empty()))
248 .map(str::to_owned)
249 .unwrap_or_else(|| format!("Output {}", outputs.len() + 1));
250 outputs.push(label.clone());
251 headers.push(label);
252 }
253 _ => {}
254 }
255 }
256 if headers.is_empty() || headers.len() > MAX_DMN_COLUMNS {
257 return Err(Error::LimitExceeded(format!(
258 "DMN decision table has {} columns; maximum is {MAX_DMN_COLUMNS}",
259 headers.len()
260 )));
261 }
262
263 let rules = table
264 .children
265 .iter()
266 .filter(|child| {
267 child.name == "rule" && child.namespace.as_deref() == root.namespace.as_deref()
268 })
269 .collect::<Vec<_>>();
270 if table.children.iter().any(|child| {
271 child.name == "annotation" && child.namespace.as_deref() == root.namespace.as_deref()
272 }) || rules.iter().any(|rule| {
273 rule.children.iter().any(|child| {
274 child.name == "annotationEntry"
275 && child.namespace.as_deref() == root.namespace.as_deref()
276 })
277 }) {
278 warnings.push(format!(
279 "DMN rule annotations for decision {decision_name:?} are omitted"
280 ));
281 }
282 rule_count = rule_count.saturating_add(rules.len());
283 if rule_count > MAX_DMN_RULES {
284 return Err(Error::LimitExceeded(format!(
285 "DMN input exceeds {MAX_DMN_RULES} decision rules"
286 )));
287 }
288 cell_count = cell_count.saturating_add(headers.len().saturating_mul(rules.len()));
289 if cell_count > MAX_DMN_CELLS {
290 return Err(Error::LimitExceeded(format!(
291 "DMN tables exceed {MAX_DMN_CELLS} rendered cells"
292 )));
293 }
294
295 let mut rows = Vec::with_capacity(rules.len());
296 for rule in rules {
297 let mut row = Vec::with_capacity(headers.len());
298 for entry_name in ["inputEntry", "outputEntry"] {
299 for entry in rule.children.iter().filter(|child| {
300 child.name == entry_name
301 && child.namespace.as_deref() == root.namespace.as_deref()
302 }) {
303 let value = child_text(entry, "text").unwrap_or_default();
304 if value.len() > MAX_DMN_VALUE_BYTES {
305 return Err(Error::LimitExceeded(format!(
306 "DMN entry exceeds {MAX_DMN_VALUE_BYTES} bytes"
307 )));
308 }
309 row.push(value);
310 }
311 }
312 if row.len() != headers.len() {
313 warnings.push(format!(
314 "DMN decision table {decision_name:?} has a rule with {} entries for {} columns; missing cells are blank and extra entries are omitted",
315 row.len(),
316 headers.len()
317 ));
318 row.truncate(headers.len());
319 row.resize(headers.len(), String::new());
320 }
321 rows.push(row);
322 }
323
324 let policy_label = if aggregation.is_empty() {
325 hit_policy.to_owned()
326 } else {
327 format!("{hit_policy} / {aggregation}")
328 };
329 push_text(
330 &mut blocks,
331 &mut rendered_bytes,
332 format!(
333 "Decision table — hit policy: {policy_label}. Expressions are shown as text; FEEL is not evaluated."
334 ),
335 )?;
336 let table_data = TableData {
337 alignments: vec![TableAlign::Left; headers.len()],
338 headers,
339 rows,
340 raw_source: String::new(),
341 };
342 blocks.push(HtmlBlock::Table(table_data));
343 }
344
345 if tables_seen == 0 && blocks.len() == 1 {
346 return Err(Error::Unsupported(
347 "DMN document contains no supported decision or literal-expression content".into(),
348 ));
349 }
350 warnings.sort();
351 warnings.dedup();
352 Ok((blocks, warnings))
353}
354
355fn append_information_requirements(
356 decision: &XmlElement,
357 names: &HashMap<String, (String, String)>,
358 blocks: &mut Vec<HtmlBlock>,
359 warnings: &mut Vec<String>,
360 rendered_bytes: &mut usize,
361) -> Result<()> {
362 for requirement in decision.children.iter().filter(|child| {
363 child.name == "informationRequirement"
364 && child.namespace.as_deref() == decision.namespace.as_deref()
365 }) {
366 for reference in requirement.children.iter().filter(|child| {
367 matches!(child.name.as_str(), "requiredInput" | "requiredDecision")
368 && child.namespace.as_deref() == decision.namespace.as_deref()
369 }) {
370 let Some(href) = reference.attribute("href") else {
371 warnings.push("DMN information requirement without href was omitted".into());
372 continue;
373 };
374 if let Some(id) = href.strip_prefix('#') {
375 if let Some((kind, name)) = names.get(id) {
376 let label = if name.is_empty() { id } else { name.as_str() };
377 push_text(blocks, rendered_bytes, format!("Requires {kind}: {label}"))?;
378 } else {
379 warnings.push(format!("DMN local reference #{id} has no matching element"));
380 }
381 } else {
382 warnings.push("DMN external references are not fetched and were omitted".into());
383 }
384 }
385 if requirement.children.iter().any(|child| {
386 child.name == "requiredKnowledge"
387 && child.namespace.as_deref() == decision.namespace.as_deref()
388 }) {
389 warnings.push("DMN requiredKnowledge references are not rendered".into());
390 }
391 }
392 Ok(())
393}
394
395fn child_text(parent: &XmlElement, name: &str) -> Option<String> {
396 let child = parent.children.iter().find(|child| {
397 child.name == name && child.namespace.as_deref() == parent.namespace.as_deref()
398 })?;
399 let text = child.text.trim();
400 (!text.is_empty()).then(|| text.to_owned())
401}
402
403fn collect_elements<'a>(element: &'a XmlElement, output: &mut Vec<&'a XmlElement>) {
404 output.push(element);
405 for child in &element.children {
406 collect_elements(child, output);
407 }
408}
409
410fn push_text(blocks: &mut Vec<HtmlBlock>, rendered_bytes: &mut usize, text: String) -> Result<()> {
411 *rendered_bytes = rendered_bytes.saturating_add(text.len());
412 if *rendered_bytes > MAX_DMN_RENDERED_BYTES {
413 return Err(Error::LimitExceeded(format!(
414 "DMN rendered text exceeds {MAX_DMN_RENDERED_BYTES} bytes"
415 )));
416 }
417 blocks.push(HtmlBlock::Paragraph { text });
418 Ok(())
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn recognizes_known_dmn_model_namespace_versions() {
427 for namespace in [
428 DMN_MODEL_11,
429 DMN_MODEL_12,
430 DMN_MODEL_13,
431 DMN_MODEL_14,
432 DMN_MODEL_15,
433 ] {
434 let source = format!("<definitions xmlns=\"{namespace}\"/>");
435 assert!(looks_like_prefix(source.as_bytes()));
436 }
437 assert!(!looks_like_prefix(b"<definitions/>"));
438 }
439
440 #[test]
441 fn renders_decision_table_entries_without_evaluating_feel() {
442 let source = format!(
443 "<definitions xmlns=\"{DMN_MODEL_15}\"><inputData id=\"Age\" name=\"Age\"/><decision id=\"Risk\" name=\"Risk band\"><informationRequirement><requiredInput href=\"#Age\"/></informationRequirement><decisionTable hitPolicy=\"UNIQUE\"><input label=\"Age\"><inputExpression><text>Age</text></inputExpression></input><output name=\"risk\"/><rule><inputEntry><text>< 18</text></inputEntry><outputEntry><text>\"minor\"</text></outputEntry></rule><rule><inputEntry><text>>= 18</text></inputEntry><outputEntry><text>\"adult\"</text></outputEntry></rule></decisionTable></decision></definitions>"
444 );
445 let root = parse_dmn(source.as_bytes()).unwrap();
446 let (blocks, warnings) = render_dmn(&root).unwrap();
447 let text = blocks
448 .iter()
449 .filter_map(|block| match block {
450 HtmlBlock::Heading { text, .. } | HtmlBlock::Paragraph { text } => {
451 Some(text.as_str())
452 }
453 HtmlBlock::Table(table) => Some(table.headers.first()?.as_str()),
454 _ => None,
455 })
456 .collect::<Vec<_>>();
457 assert!(text.contains(&"Risk band"));
458 assert!(text.contains(&"Requires inputData: Age"));
459 assert!(text.contains(&"Decision table — hit policy: UNIQUE. Expressions are shown as text; FEEL is not evaluated."));
460 let table = blocks
461 .iter()
462 .find_map(|block| match block {
463 HtmlBlock::Table(table) => Some(table),
464 _ => None,
465 })
466 .unwrap();
467 assert_eq!(table.headers, ["Age", "risk"]);
468 assert_eq!(table.rows[0], ["< 18", "\"minor\""]);
469 assert_eq!(table.rows[1], [">= 18", "\"adult\""]);
470 assert!(warnings.is_empty());
471 }
472
473 #[test]
474 fn rejects_doctypes_and_non_dmn_namespaces() {
475 let source = format!(
476 "<!DOCTYPE definitions SYSTEM \"https://example.invalid/dmn.dtd\"><definitions xmlns=\"{DMN_MODEL_15}\"/>"
477 );
478 assert!(parse_dmn(source.as_bytes()).is_err());
479 assert!(matches!(
480 parse_dmn(b"<definitions/>"),
481 Err(Error::Unsupported(_))
482 ));
483 }
484}