Skip to main content

document_svg/document/
dita.rs

1//! Bounded DITA topic and map previews.
2//!
3//! DITA is an XML vocabulary for modular technical documentation. This
4//! adapter renders topic titles, short descriptions, sections, paragraphs,
5//! lists, code blocks, simple tables, and local PNG/JPEG images. Topic maps
6//! resolve only local `topicref href` targets; no key resolution, XInclude,
7//! DTD/entity expansion, URL fetch, or code execution is performed.
8
9use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12
13use quick_xml::Reader;
14use quick_xml::events::Event;
15
16use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
17use crate::document::html::{
18    HtmlBlock, InlineHtmlImage, load_local_image_sources, render_blocks_to_pages_with_warnings,
19};
20use crate::error::{Error, Result};
21use crate::local_resource::resolve_relative_file;
22use crate::ooxml::{attribute, local_name};
23use crate::table::{TableAlign, TableData};
24
25const MAX_DITA_INPUT_BYTES: u64 = 128 * 1024 * 1024;
26const MAX_DITA_TOPIC_BYTES: u64 = 32 * 1024 * 1024;
27const MAX_DITA_XML_DEPTH: usize = 256;
28const MAX_DITA_TEXT_BYTES: usize = 64 * 1024 * 1024;
29const MAX_DITA_IMAGE_REFERENCES: usize = 10_000;
30const MAX_DITA_TOPIC_REFS: usize = 10_000;
31const MAX_DITA_TABLE_CELLS: usize = 200_000;
32
33#[derive(Default)]
34struct DitaTable {
35    rows: Vec<Vec<String>>,
36    row: Vec<String>,
37    cell: String,
38    in_cell: bool,
39    in_header: bool,
40    header_rows: usize,
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
44enum ActiveKind {
45    Heading(u8),
46    Paragraph,
47    ListItem(String),
48    Code,
49}
50
51struct ActiveText {
52    kind: ActiveKind,
53    text: String,
54}
55
56pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
57    let text = String::from_utf8_lossy(bytes);
58    let lower = text.to_ascii_lowercase();
59    let root = lower.contains("<topic")
60        || lower.contains("<concept")
61        || lower.contains("<task")
62        || lower.contains("<reference")
63        || lower.contains("<map");
64    let known_namespace = lower.contains("dita.oasis-open.org")
65        || lower.contains("dita.org")
66        || lower.contains("oasis-open.org/dita");
67    let known_doctype = lower.contains("<!doctype") && lower.contains("dita");
68    root && (known_namespace || known_doctype)
69}
70
71pub(crate) fn convert(
72    path: &Path,
73    options: &ConvertOptions,
74    sink: &mut dyn PageConsumer,
75) -> Result<Vec<String>> {
76    let bytes = read_limited_file(
77        path,
78        options.max_input_bytes.min(MAX_DITA_INPUT_BYTES),
79        "DITA input",
80    )?;
81    let xml = String::from_utf8(bytes)
82        .map_err(|error| Error::InvalidInput(format!("DITA input is not UTF-8: {error}")))?;
83    let parent = path
84        .parent()
85        .filter(|parent| !parent.as_os_str().is_empty())
86        .unwrap_or_else(|| Path::new("."));
87    let base_dir = fs::canonicalize(parent)?;
88    let root_name = root_name(&xml, options.max_xml_events)?;
89    let is_map = root_name.as_deref() == Some("map");
90    let mut blocks = Vec::new();
91    let mut warnings = Vec::new();
92    if is_map {
93        let (map_title, references) = collect_map_refs(&xml, options.max_xml_events)?;
94        if let Some(title) = map_title {
95            blocks.push(HtmlBlock::Heading {
96                level: 1,
97                text: title,
98            });
99        }
100        if references.is_empty() {
101            push_warning_once(&mut warnings, "DITA map contains no local topicref targets");
102        }
103        let mut total_topic_bytes = 0u64;
104        for reference in references {
105            let Some(topic_path) = resolve_relative_file(&base_dir, &reference) else {
106                push_warning_once(
107                    &mut warnings,
108                    "DITA topicref targets outside the map directory or uses an unsupported URI scheme",
109                );
110                continue;
111            };
112            let metadata = match fs::metadata(&topic_path) {
113                Ok(metadata) if metadata.is_file() => metadata,
114                _ => {
115                    push_warning_once(&mut warnings, "missing DITA topicref targets were omitted");
116                    continue;
117                }
118            };
119            if metadata.len() > MAX_DITA_TOPIC_BYTES {
120                push_warning_once(
121                    &mut warnings,
122                    "DITA topicref targets exceeding the per-topic byte limit were omitted",
123                );
124                continue;
125            }
126            total_topic_bytes = total_topic_bytes.saturating_add(metadata.len());
127            if total_topic_bytes > options.max_input_bytes.min(MAX_DITA_INPUT_BYTES) {
128                return Err(Error::LimitExceeded(
129                    "DITA map topic inputs exceed the cumulative input limit".into(),
130                ));
131            }
132            let topic_bytes = read_limited_file(&topic_path, MAX_DITA_TOPIC_BYTES, "DITA topic")?;
133            let topic_xml = String::from_utf8(topic_bytes).map_err(|error| {
134                Error::InvalidInput(format!("DITA topic is not UTF-8: {error}"))
135            })?;
136            let topic_base = topic_path.parent().unwrap_or_else(|| Path::new("."));
137            let (sources, exceeded) = collect_image_sources(&topic_xml, options.max_xml_events)?;
138            let (images, image_warnings) = load_local_image_sources(topic_base, sources, exceeded)?;
139            warnings.extend(image_warnings);
140            let (topic_blocks, topic_warnings) =
141                parse_topic(&topic_xml, &images, options.max_xml_events)?;
142            blocks.extend(topic_blocks);
143            warnings.extend(topic_warnings);
144        }
145    } else {
146        let (sources, exceeded) = collect_image_sources(&xml, options.max_xml_events)?;
147        let (images, image_warnings) = load_local_image_sources(&base_dir, sources, exceeded)?;
148        warnings.extend(image_warnings);
149        let (topic_blocks, topic_warnings) = parse_topic(&xml, &images, options.max_xml_events)?;
150        blocks.extend(topic_blocks);
151        warnings.extend(topic_warnings);
152    }
153    if blocks.is_empty() {
154        return Err(Error::InvalidInput(
155            "DITA document contains no renderable content".into(),
156        ));
157    }
158    render_blocks_to_pages_with_warnings(&blocks, sink, options, &warnings)?;
159    Ok(warnings)
160}
161
162fn root_name(xml: &str, max_events: usize) -> Result<Option<String>> {
163    let mut reader = Reader::from_str(xml);
164    reader.config_mut().trim_text(true);
165    let mut buffer = Vec::new();
166    for _ in 0..max_events {
167        match reader.read_event_into(&mut buffer)? {
168            Event::Start(element) | Event::Empty(element) => {
169                let qualified = element.name();
170                return Ok(Some(
171                    String::from_utf8_lossy(local_name(qualified.as_ref())).into_owned(),
172                ));
173            }
174            Event::DocType(_) => {}
175            Event::Eof => return Ok(None),
176            _ => {}
177        }
178        buffer.clear();
179    }
180    Err(Error::LimitExceeded(format!(
181        "DITA XML exceeds {max_events} parser events"
182    )))
183}
184
185fn collect_map_refs(xml: &str, max_events: usize) -> Result<(Option<String>, Vec<String>)> {
186    let mut reader = Reader::from_str(xml);
187    reader.config_mut().trim_text(true);
188    let mut buffer = Vec::new();
189    let mut title = None;
190    let mut in_title = false;
191    let mut title_text = String::new();
192    let mut references = Vec::new();
193    let mut events = 0usize;
194    loop {
195        events = events.saturating_add(1);
196        if events > max_events {
197            return Err(Error::LimitExceeded(format!(
198                "DITA map exceeds {max_events} parser events"
199            )));
200        }
201        match reader.read_event_into(&mut buffer)? {
202            Event::Start(element) => {
203                let qualified = element.name();
204                let name = local_name(qualified.as_ref());
205                if name == b"title" && title.is_none() {
206                    in_title = true;
207                    title_text.clear();
208                }
209                if name == b"topicref"
210                    && references.len() < MAX_DITA_TOPIC_REFS
211                    && let Some(href) = attribute(&element, b"href")
212                    && !href.trim().is_empty()
213                {
214                    let target = href.split('#').next().unwrap_or_default();
215                    if !target.is_empty() {
216                        references.push(target.to_owned());
217                    }
218                }
219            }
220            Event::Empty(element) => {
221                let qualified = element.name();
222                if local_name(qualified.as_ref()) == b"topicref"
223                    && references.len() < MAX_DITA_TOPIC_REFS
224                    && let Some(href) = attribute(&element, b"href")
225                    && !href.trim().is_empty()
226                {
227                    let target = href.split('#').next().unwrap_or_default();
228                    if !target.is_empty() {
229                        references.push(target.to_owned());
230                    }
231                }
232            }
233            Event::Text(text) if in_title => {
234                title_text.push_str(&text.decode().map_err(|error| {
235                    Error::InvalidInput(format!("invalid DITA map title: {error}"))
236                })?);
237            }
238            Event::End(element) => {
239                let qualified = element.name();
240                if local_name(qualified.as_ref()) == b"title" && in_title {
241                    let clean = clean_text(&title_text);
242                    if !clean.is_empty() {
243                        title = Some(clean);
244                    }
245                    in_title = false;
246                }
247            }
248            Event::DocType(_) => {}
249            Event::Eof => break,
250            _ => {}
251        }
252        buffer.clear();
253    }
254    Ok((title, references))
255}
256
257fn collect_image_sources(xml: &str, max_events: usize) -> Result<(Vec<String>, bool)> {
258    let mut reader = Reader::from_str(xml);
259    reader.config_mut().trim_text(true);
260    let mut buffer = Vec::new();
261    let mut sources = Vec::new();
262    let mut exceeded = false;
263    let mut events = 0usize;
264    loop {
265        events = events.saturating_add(1);
266        if events > max_events {
267            return Err(Error::LimitExceeded(format!(
268                "DITA XML exceeds {max_events} parser events"
269            )));
270        }
271        match reader.read_event_into(&mut buffer)? {
272            Event::Start(element) | Event::Empty(element) => {
273                let qualified = element.name();
274                if local_name(qualified.as_ref()) == b"image"
275                    && let Some(href) = attribute(&element, b"href")
276                    && !href.trim().is_empty()
277                {
278                    if sources.len() >= MAX_DITA_IMAGE_REFERENCES {
279                        exceeded = true;
280                    } else {
281                        sources.push(href);
282                    }
283                }
284            }
285            Event::DocType(_) => {}
286            Event::Eof => break,
287            _ => {}
288        }
289        buffer.clear();
290    }
291    Ok((sources, exceeded))
292}
293
294fn parse_topic(
295    xml: &str,
296    images: &HashMap<String, InlineHtmlImage>,
297    max_events: usize,
298) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
299    let mut reader = Reader::from_str(xml);
300    reader.config_mut().trim_text(false);
301    let mut buffer = Vec::new();
302    let mut stack = Vec::<String>::new();
303    let mut active = None::<ActiveText>;
304    let mut table = None::<DitaTable>;
305    let mut list_stack = Vec::<bool>::new();
306    let mut ordered_index = Vec::<usize>::new();
307    let mut blocks = Vec::new();
308    let mut warnings = Vec::new();
309    let mut text_bytes = 0usize;
310    let mut image_count = 0usize;
311    let mut events = 0usize;
312    loop {
313        events = events.saturating_add(1);
314        if events > max_events {
315            return Err(Error::LimitExceeded(format!(
316                "DITA topic exceeds {max_events} parser events"
317            )));
318        }
319        match reader.read_event_into(&mut buffer)? {
320            Event::Start(element) => {
321                let qualified = element.name();
322                let name = local_name(qualified.as_ref()).to_vec();
323                let name_string = String::from_utf8_lossy(&name).into_owned();
324                if stack.len() >= MAX_DITA_XML_DEPTH {
325                    return Err(Error::LimitExceeded(format!(
326                        "DITA XML nesting exceeds {MAX_DITA_XML_DEPTH}"
327                    )));
328                }
329                match name.as_slice() {
330                    b"title" | b"navtitle" => {
331                        active = Some(ActiveText {
332                            kind: ActiveKind::Heading(title_level(&stack)),
333                            text: String::new(),
334                        });
335                    }
336                    b"p" | b"shortdesc" | b"lq" | b"note" | b"hazardstatement" | b"stepsection" => {
337                        if active.is_none() && table.is_none() {
338                            active = Some(ActiveText {
339                                kind: ActiveKind::Paragraph,
340                                text: String::new(),
341                            });
342                        }
343                    }
344                    b"codeblock" | b"pre" | b"lines" | b"msgblock" => {
345                        if active.is_none() {
346                            active = Some(ActiveText {
347                                kind: ActiveKind::Code,
348                                text: String::new(),
349                            });
350                        }
351                    }
352                    b"ul" | b"sl" => list_stack.push(false),
353                    b"ol" => {
354                        list_stack.push(true);
355                        ordered_index.push(1);
356                    }
357                    b"li" | b"step" => {
358                        if active.is_none() {
359                            let ordered = list_stack.last().copied().unwrap_or(false);
360                            let bullet = if ordered {
361                                let index =
362                                    ordered_index.last_mut().expect("DITA ordered list counter");
363                                let bullet = format!("{index}.");
364                                *index = index.saturating_add(1);
365                                bullet
366                            } else {
367                                "•".into()
368                            };
369                            active = Some(ActiveText {
370                                kind: ActiveKind::ListItem(bullet),
371                                text: String::new(),
372                            });
373                        }
374                    }
375                    b"simpletable" | b"table" => table = Some(DitaTable::default()),
376                    b"sthead" | b"thead" => {
377                        if let Some(table) = table.as_mut() {
378                            table.in_header = true;
379                        }
380                    }
381                    b"stbody" | b"tbody" | b"tfoot" => {
382                        if let Some(table) = table.as_mut() {
383                            table.in_header = false;
384                        }
385                    }
386                    b"strow" | b"row" | b"tr" => {
387                        if let Some(table) = table.as_mut() {
388                            table.row.clear();
389                        }
390                    }
391                    b"stentry" | b"entry" | b"td" | b"th" => {
392                        if let Some(table) = table.as_mut() {
393                            table.cell.clear();
394                            table.in_cell = true;
395                            if name.as_slice() == b"th" {
396                                table.in_header = true;
397                            }
398                        }
399                    }
400                    b"image" => {
401                        image_count = image_count.saturating_add(1);
402                        if image_count <= MAX_DITA_IMAGE_REFERENCES {
403                            let source = attribute(&element, b"href");
404                            append_image(&mut blocks, &mut warnings, images, source.as_deref());
405                        } else {
406                            push_warning_once(
407                                &mut warnings,
408                                "DITA image references exceeded the supported limit; remaining images were omitted",
409                            );
410                        }
411                    }
412                    b"xref" | b"link" | b"object" | b"foreign" => push_warning_once(
413                        &mut warnings,
414                        "DITA links and foreign content remain inert; targets were not loaded",
415                    ),
416                    b"include" => {
417                        push_warning_once(&mut warnings, "DITA include content was not loaded")
418                    }
419                    _ => {}
420                }
421                stack.push(name_string);
422            }
423            Event::Empty(element) => {
424                let qualified = element.name();
425                let name = local_name(qualified.as_ref());
426                if name == b"image" {
427                    image_count = image_count.saturating_add(1);
428                    if image_count <= MAX_DITA_IMAGE_REFERENCES {
429                        let source = attribute(&element, b"href");
430                        append_image(&mut blocks, &mut warnings, images, source.as_deref());
431                    } else {
432                        push_warning_once(
433                            &mut warnings,
434                            "DITA image references exceeded the supported limit; remaining images were omitted",
435                        );
436                    }
437                }
438            }
439            Event::Text(text) => {
440                let value = text
441                    .decode()
442                    .map_err(|error| Error::InvalidInput(format!("invalid DITA text: {error}")))?;
443                let value = quick_xml::escape::unescape(&value)
444                    .map_err(|error| Error::InvalidInput(format!("invalid DITA text: {error}")))?;
445                append_text(&value, &mut active, &mut table, &mut text_bytes)?;
446            }
447            Event::CData(text) => {
448                let value = text
449                    .decode()
450                    .map_err(|error| Error::InvalidInput(format!("invalid DITA CDATA: {error}")))?;
451                append_text(&value, &mut active, &mut table, &mut text_bytes)?;
452            }
453            Event::GeneralRef(reference) => {
454                let value = reference.decode().map_err(|error| {
455                    Error::InvalidInput(format!("invalid DITA reference: {error}"))
456                })?;
457                append_text(
458                    &format!("&{value};"),
459                    &mut active,
460                    &mut table,
461                    &mut text_bytes,
462                )?;
463            }
464            Event::End(element) => {
465                let qualified = element.name();
466                let name = local_name(qualified.as_ref());
467                match name {
468                    b"title" | b"navtitle" => {
469                        if let Some(ActiveText {
470                            kind: ActiveKind::Heading(level),
471                            text,
472                        }) = active.take()
473                            && !clean_text(&text).is_empty()
474                        {
475                            blocks.push(HtmlBlock::Heading {
476                                level,
477                                text: clean_text(&text),
478                            });
479                        }
480                    }
481                    b"p" | b"shortdesc" | b"lq" | b"note" | b"hazardstatement" | b"stepsection"
482                    | b"li" | b"step" => flush_text_block(&mut blocks, &mut active),
483                    b"codeblock" | b"pre" | b"lines" | b"msgblock" => {
484                        if let Some(ActiveText {
485                            kind: ActiveKind::Code,
486                            text,
487                        }) = active.take()
488                            && !text.trim().is_empty()
489                        {
490                            blocks.push(HtmlBlock::CodeBlock { text });
491                        }
492                    }
493                    b"stentry" | b"entry" | b"td" | b"th" => {
494                        if let Some(table) = table.as_mut()
495                            && table.in_cell
496                        {
497                            table.row.push(clean_text(&table.cell));
498                            table.cell.clear();
499                            table.in_cell = false;
500                        }
501                    }
502                    b"strow" | b"row" | b"tr" => {
503                        if let Some(table) = table.as_mut()
504                            && !table.row.is_empty()
505                        {
506                            if table.in_header {
507                                table.header_rows = table.header_rows.saturating_add(1);
508                            }
509                            table.rows.push(std::mem::take(&mut table.row));
510                            if table.rows.iter().map(Vec::len).sum::<usize>() > MAX_DITA_TABLE_CELLS
511                            {
512                                return Err(Error::LimitExceeded(format!(
513                                    "DITA table exceeds {MAX_DITA_TABLE_CELLS} cells"
514                                )));
515                            }
516                        }
517                    }
518                    b"sthead" | b"thead" => {
519                        if let Some(table) = table.as_mut() {
520                            table.in_header = false;
521                        }
522                    }
523                    b"simpletable" | b"table" => {
524                        if let Some(table) = table.take() {
525                            let table = finish_table(table);
526                            if !table.headers.is_empty() || !table.rows.is_empty() {
527                                blocks.push(HtmlBlock::Table(table));
528                            }
529                        }
530                    }
531                    b"ul" | b"sl" | b"ol" => {
532                        list_stack.pop();
533                        if name == b"ol" {
534                            ordered_index.pop();
535                        }
536                    }
537                    _ => {}
538                }
539                stack.pop();
540            }
541            Event::DocType(_) => push_warning_once(
542                &mut warnings,
543                "DITA DTD declaration was ignored; external entities were not loaded",
544            ),
545            Event::Eof => break,
546            _ => {}
547        }
548        buffer.clear();
549    }
550    Ok((blocks, warnings))
551}
552
553fn append_text(
554    value: &str,
555    active: &mut Option<ActiveText>,
556    table: &mut Option<DitaTable>,
557    text_bytes: &mut usize,
558) -> Result<()> {
559    *text_bytes = text_bytes.saturating_add(value.len());
560    if *text_bytes > MAX_DITA_TEXT_BYTES {
561        return Err(Error::LimitExceeded(format!(
562            "DITA text exceeds {MAX_DITA_TEXT_BYTES} bytes"
563        )));
564    }
565    if let Some(table) = table.as_mut()
566        && table.in_cell
567    {
568        table.cell.push_str(value);
569    } else if let Some(active) = active.as_mut() {
570        active.text.push_str(value);
571    }
572    Ok(())
573}
574
575fn flush_text_block(blocks: &mut Vec<HtmlBlock>, active: &mut Option<ActiveText>) {
576    let Some(ActiveText { kind, text }) = active.take() else {
577        return;
578    };
579    let text = clean_text(&text);
580    if text.is_empty() {
581        return;
582    }
583    match kind {
584        ActiveKind::ListItem(bullet) => blocks.push(HtmlBlock::ListItem { bullet, text }),
585        ActiveKind::Paragraph => blocks.push(HtmlBlock::Paragraph { text }),
586        _ => {}
587    }
588}
589
590fn append_image(
591    blocks: &mut Vec<HtmlBlock>,
592    warnings: &mut Vec<String>,
593    images: &HashMap<String, InlineHtmlImage>,
594    source: Option<&str>,
595) {
596    let Some(source) = source else {
597        push_warning_once(warnings, "DITA image without href was omitted");
598        return;
599    };
600    if let Some(image) = images.get(source) {
601        blocks.push(HtmlBlock::Image {
602            href: image.href.clone(),
603            pixel_width: image.pixel_width,
604            pixel_height: image.pixel_height,
605            alt: "DITA image".into(),
606        });
607    } else {
608        push_warning_once(
609            warnings,
610            "DITA image was omitted because it was not a validated local PNG/JPEG resource",
611        );
612    }
613}
614
615fn finish_table(table: DitaTable) -> TableData {
616    let mut rows = table.rows;
617    let header_count = table.header_rows.min(rows.len());
618    let mut headers = if header_count > 0 {
619        rows.drain(..header_count).flatten().collect()
620    } else {
621        rows.first().cloned().unwrap_or_default()
622    };
623    if header_count == 0 && !rows.is_empty() {
624        rows.remove(0);
625    }
626    let columns = headers
627        .len()
628        .max(rows.iter().map(Vec::len).max().unwrap_or(0))
629        .max(1);
630    headers.resize(columns, String::new());
631    for row in &mut rows {
632        row.resize(columns, String::new());
633    }
634    TableData {
635        headers,
636        rows,
637        alignments: vec![TableAlign::Left; columns],
638        raw_source: String::new(),
639    }
640}
641
642fn title_level(stack: &[String]) -> u8 {
643    let depth = stack
644        .iter()
645        .filter(|name| {
646            matches!(
647                name.as_str(),
648                "topic" | "concept" | "task" | "reference" | "section" | "example" | "stepsection"
649            )
650        })
651        .count();
652    depth.clamp(1, 6) as u8
653}
654
655fn clean_text(value: &str) -> String {
656    value.split_whitespace().collect::<Vec<_>>().join(" ")
657}
658
659fn push_warning_once(warnings: &mut Vec<String>, warning: &str) {
660    if !warnings.iter().any(|existing| existing == warning) {
661        warnings.push(warning.to_owned());
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::looks_like_prefix;
668
669    #[test]
670    fn recognizes_dita_namespace_and_doctype_without_broad_text_sniffing() {
671        assert!(looks_like_prefix(
672            br#"<topic id="t" xmlns="http://dita.oasis-open.org/architecture/1.3/"><title>Guide</title></topic>"#
673        ));
674        assert!(looks_like_prefix(
675            br#"<!DOCTYPE concept PUBLIC "-//OASIS//DTD DITA Concept//EN" "concept.dtd"><concept/>"#
676        ));
677        assert!(!looks_like_prefix(
678            br#"<topic><p>This mentions DITA as prose.</p></topic>"#
679        ));
680    }
681}