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