Skip to main content

document_svg/document/
dmn.rs

1//! Bounded, inert previews for DMN decision requirements and decision tables.
2
3use 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>&lt; 18</text></inputEntry><outputEntry><text>\"minor\"</text></outputEntry></rule><rule><inputEntry><text>&gt;= 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}