1use std::collections::HashMap;
9use std::fs;
10use std::path::Path;
11
12use quick_xml::Reader;
13use quick_xml::events::Event;
14
15use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
16use crate::document::html::{
17 HtmlBlock, InlineHtmlImage, load_local_image_sources, render_blocks_to_pages_with_warnings,
18};
19use crate::error::{Error, Result};
20use crate::ooxml::{attribute, local_name};
21use crate::table::{TableAlign, TableData};
22
23const MAX_DOCBOOK_INPUT_BYTES: u64 = 128 * 1024 * 1024;
24const MAX_DOCBOOK_XML_DEPTH: usize = 256;
25const MAX_DOCBOOK_TEXT_BYTES: usize = 64 * 1024 * 1024;
26const MAX_DOCBOOK_IMAGE_REFERENCES: usize = 10_000;
27const MAX_DOCBOOK_TABLE_CELLS: usize = 200_000;
28
29#[derive(Default)]
30struct DocBookTable {
31 rows: Vec<Vec<String>>,
32 row: Vec<String>,
33 cell: String,
34 in_cell: bool,
35 in_header: bool,
36 header_rows: usize,
37}
38
39#[derive(Clone, Debug, Eq, PartialEq)]
40enum ActiveKind {
41 Title(u8),
42 Paragraph,
43 ListItem(String),
44 Code,
45 Caption,
46 Term,
47}
48
49struct ActiveText {
50 kind: ActiveKind,
51 text: String,
52}
53
54pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
55 let text = String::from_utf8_lossy(bytes);
56 let lower = text.to_ascii_lowercase();
57 let has_root = lower.contains("<book")
58 || lower.contains("<article")
59 || lower.contains("<chapter")
60 || lower.contains("<section")
61 || lower.contains("<topic");
62 let known_namespace = lower.contains("http://docbook.org/ns/docbook")
63 || lower.contains("https://docbook.org/ns/docbook")
64 || lower.contains("oasis-open.org/docbook");
65 let known_doctype = lower.contains("<!doctype") && lower.contains("docbook");
66 has_root && (known_namespace || known_doctype)
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_DOCBOOK_INPUT_BYTES),
77 "DocBook input",
78 )?;
79 let xml = String::from_utf8(bytes)
80 .map_err(|error| Error::InvalidInput(format!("DocBook input is not UTF-8: {error}")))?;
81 let parent = path
82 .parent()
83 .filter(|parent| !parent.as_os_str().is_empty())
84 .unwrap_or_else(|| Path::new("."));
85 let base_dir = fs::canonicalize(parent)?;
86 let (sources, source_limit_exceeded) = collect_image_sources(&xml, options.max_xml_events)?;
87 let (images, mut warnings) =
88 load_local_image_sources(&base_dir, sources, source_limit_exceeded)?;
89 let (blocks, parser_warnings) = parse_blocks(&xml, &images, options.max_xml_events)?;
90 warnings.extend(parser_warnings);
91 if blocks.is_empty() {
92 return Err(Error::InvalidInput(
93 "DocBook document contains no renderable content".into(),
94 ));
95 }
96 render_blocks_to_pages_with_warnings(&blocks, sink, options, &warnings)?;
97 Ok(warnings)
98}
99
100fn collect_image_sources(xml: &str, max_events: usize) -> Result<(Vec<String>, bool)> {
101 let mut reader = Reader::from_str(xml);
102 reader.config_mut().trim_text(true);
103 let mut buffer = Vec::new();
104 let mut sources = Vec::new();
105 let mut exceeded = false;
106 let mut events = 0usize;
107 loop {
108 events = events.saturating_add(1);
109 if events > max_events {
110 return Err(Error::LimitExceeded(format!(
111 "DocBook XML exceeds {max_events} parser events"
112 )));
113 }
114 match reader.read_event_into(&mut buffer)? {
115 Event::Start(element) | Event::Empty(element) => {
116 let qualified_name = element.name();
117 let name = local_name(qualified_name.as_ref());
118 if matches!(name, b"imagedata" | b"graphic" | b"inlinegraphic") {
119 let source = attribute(&element, b"fileref")
120 .or_else(|| attribute(&element, b"href"))
121 .or_else(|| attribute(&element, b"entityref"));
122 if let Some(source) = source.filter(|source| !source.trim().is_empty()) {
123 if sources.len() >= MAX_DOCBOOK_IMAGE_REFERENCES {
124 exceeded = true;
125 } else {
126 sources.push(source);
127 }
128 }
129 }
130 }
131 Event::DocType(_) => {}
132 Event::Eof => break,
133 _ => {}
134 }
135 buffer.clear();
136 }
137 Ok((sources, exceeded))
138}
139
140fn parse_blocks(
141 xml: &str,
142 images: &HashMap<String, InlineHtmlImage>,
143 max_events: usize,
144) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
145 let mut reader = Reader::from_str(xml);
146 reader.config_mut().trim_text(false);
147 let mut buffer = Vec::new();
148 let mut stack = Vec::<String>::new();
149 let mut blocks = Vec::new();
150 let mut warnings = Vec::new();
151 let mut active = None::<ActiveText>;
152 let mut table = None::<DocBookTable>;
153 let mut figure_caption = None::<String>;
154 let mut text_bytes = 0usize;
155 let mut event_count = 0usize;
156 let mut list_stack = Vec::<bool>::new();
157 let mut ordered_index = Vec::<usize>::new();
158 let mut image_count = 0usize;
159
160 loop {
161 event_count = event_count.saturating_add(1);
162 if event_count > max_events {
163 return Err(Error::LimitExceeded(format!(
164 "DocBook XML exceeds {max_events} parser events"
165 )));
166 }
167 match reader.read_event_into(&mut buffer)? {
168 Event::Start(element) => {
169 let name = local_name(element.name().as_ref()).to_vec();
170 let name_str = String::from_utf8_lossy(&name).into_owned();
171 if stack.len() >= MAX_DOCBOOK_XML_DEPTH {
172 return Err(Error::LimitExceeded(format!(
173 "DocBook XML nesting exceeds {MAX_DOCBOOK_XML_DEPTH}"
174 )));
175 }
176 match name.as_slice() {
177 b"title" => {
178 let level = title_level(&stack);
179 active = Some(ActiveText {
180 kind: ActiveKind::Title(level),
181 text: String::new(),
182 });
183 }
184 b"para" | b"simpara" | b"formalpara" | b"remark" | b"abstract"
185 | b"blockquote" | b"note" | b"tip" | b"warning" | b"important" | b"caution"
186 | b"danger" => {
187 if active.is_none() && table.is_none() {
188 active = Some(ActiveText {
189 kind: ActiveKind::Paragraph,
190 text: String::new(),
191 });
192 }
193 }
194 b"programlisting" | b"screen" | b"literallayout" | b"synopsis" => {
195 if active.is_none() {
196 active = Some(ActiveText {
197 kind: ActiveKind::Code,
198 text: String::new(),
199 });
200 }
201 }
202 b"caption" => {
203 active = Some(ActiveText {
204 kind: ActiveKind::Caption,
205 text: String::new(),
206 });
207 }
208 b"term" => {
209 active = Some(ActiveText {
210 kind: ActiveKind::Term,
211 text: String::new(),
212 });
213 }
214 b"itemizedlist" => list_stack.push(false),
215 b"orderedlist" => {
216 list_stack.push(true);
217 ordered_index.push(1);
218 }
219 b"listitem" => {
220 if active.is_none() {
221 let ordered = list_stack.last().copied().unwrap_or(false);
222 let bullet = if ordered {
223 let index = ordered_index.last_mut().expect("ordered list counter");
224 let bullet = format!("{index}.");
225 *index = index.saturating_add(1);
226 bullet
227 } else {
228 "•".into()
229 };
230 active = Some(ActiveText {
231 kind: ActiveKind::ListItem(bullet),
232 text: String::new(),
233 });
234 }
235 }
236 b"table" | b"informaltable" => table = Some(DocBookTable::default()),
237 b"thead" => {
238 if let Some(table) = table.as_mut() {
239 table.in_header = true;
240 }
241 }
242 b"tbody" | b"tfoot" => {
243 if let Some(table) = table.as_mut() {
244 table.in_header = false;
245 }
246 }
247 b"row" | b"tr" => {
248 if let Some(table) = table.as_mut() {
249 table.row.clear();
250 }
251 }
252 b"entry" | b"td" | b"th" => {
253 if let Some(table) = table.as_mut() {
254 table.cell.clear();
255 table.in_cell = true;
256 if name.as_slice() == b"th" {
257 table.in_header = true;
258 }
259 }
260 }
261 b"ulink" | b"link" | b"xref" | b"biblioref" => push_warning_once(
262 &mut warnings,
263 "DocBook links and cross-references are shown as text; targets were not loaded",
264 ),
265 b"equation" | b"inlineequation" | b"informalequation" | b"mathphrase" => {
266 push_warning_once(
267 &mut warnings,
268 "DocBook MathML and equation markup was shown as text without evaluation",
269 );
270 }
271 b"include" => push_warning_once(
272 &mut warnings,
273 "DocBook XInclude or include content was not loaded",
274 ),
275 _ => {}
276 }
277 stack.push(name_str);
278 }
279 Event::Empty(element) => {
280 let qualified_name = element.name();
281 let name = local_name(qualified_name.as_ref());
282 if matches!(name, b"imagedata" | b"graphic" | b"inlinegraphic") {
283 image_count = image_count.saturating_add(1);
284 if image_count <= MAX_DOCBOOK_IMAGE_REFERENCES {
285 let source = attribute(&element, b"fileref")
286 .or_else(|| attribute(&element, b"href"))
287 .or_else(|| attribute(&element, b"entityref"));
288 append_image(
289 &mut blocks,
290 &mut warnings,
291 images,
292 source.as_deref(),
293 figure_caption.take(),
294 );
295 } else {
296 push_warning_once(
297 &mut warnings,
298 "DocBook image references exceeded the supported limit; remaining images were omitted",
299 );
300 }
301 }
302 }
303 Event::Text(text) => {
304 let value = text.decode().map_err(|error| {
305 Error::InvalidInput(format!("invalid DocBook text: {error}"))
306 })?;
307 let value = quick_xml::escape::unescape(&value).map_err(|error| {
308 Error::InvalidInput(format!("invalid DocBook text: {error}"))
309 })?;
310 append_text(&value, &mut active, &mut table, &mut text_bytes)?;
311 }
312 Event::CData(text) => {
313 let value = text.decode().map_err(|error| {
314 Error::InvalidInput(format!("invalid DocBook CDATA: {error}"))
315 })?;
316 append_text(&value, &mut active, &mut table, &mut text_bytes)?;
317 }
318 Event::GeneralRef(reference) => {
319 let value = reference.decode().map_err(|error| {
320 Error::InvalidInput(format!("invalid DocBook reference: {error}"))
321 })?;
322 append_text(
323 &format!("&{value};"),
324 &mut active,
325 &mut table,
326 &mut text_bytes,
327 )?;
328 }
329 Event::End(element) => {
330 let qualified_name = element.name();
331 let name = local_name(qualified_name.as_ref());
332 match name {
333 b"title" => {
334 if let Some(ActiveText {
335 kind: ActiveKind::Title(level),
336 text,
337 }) = active.take()
338 {
339 let text = clean_text(&text);
340 if !text.is_empty() {
341 blocks.push(HtmlBlock::Heading { level, text });
342 }
343 }
344 }
345 b"para" | b"simpara" | b"formalpara" | b"remark" | b"abstract"
346 | b"blockquote" | b"note" | b"tip" | b"warning" | b"important" | b"caution"
347 | b"danger" => flush_active_paragraph(&mut blocks, &mut active, &stack),
348 b"listitem" => flush_active_paragraph(&mut blocks, &mut active, &stack),
349 b"programlisting" | b"screen" | b"literallayout" | b"synopsis" => {
350 if let Some(ActiveText {
351 kind: ActiveKind::Code,
352 text,
353 }) = active.take()
354 && !text.trim().is_empty()
355 {
356 blocks.push(HtmlBlock::CodeBlock { text });
357 }
358 }
359 b"caption" => {
360 if let Some(ActiveText {
361 kind: ActiveKind::Caption,
362 text,
363 }) = active.take()
364 {
365 let text = clean_text(&text);
366 if !text.is_empty() {
367 figure_caption = Some(text);
368 }
369 }
370 }
371 b"term" => {
372 if let Some(ActiveText {
373 kind: ActiveKind::Term,
374 text,
375 }) = active.take()
376 {
377 let text = clean_text(&text);
378 if !text.is_empty() {
379 blocks.push(HtmlBlock::Heading { level: 4, text });
380 }
381 }
382 }
383 b"entry" | b"td" | b"th" => {
384 if let Some(table) = table.as_mut()
385 && table.in_cell
386 {
387 table.row.push(clean_text(&table.cell));
388 table.cell.clear();
389 table.in_cell = false;
390 }
391 }
392 b"row" | b"tr" => {
393 if let Some(table) = table.as_mut()
394 && !table.row.is_empty()
395 {
396 if table.in_header {
397 table.header_rows = table.header_rows.saturating_add(1);
398 }
399 table.rows.push(std::mem::take(&mut table.row));
400 if table.rows.iter().map(Vec::len).sum::<usize>()
401 > MAX_DOCBOOK_TABLE_CELLS
402 {
403 return Err(Error::LimitExceeded(format!(
404 "DocBook table exceeds {MAX_DOCBOOK_TABLE_CELLS} cells"
405 )));
406 }
407 }
408 }
409 b"table" | b"informaltable" => {
410 if let Some(table) = table.take() {
411 let table = finish_table(table);
412 if !table.headers.is_empty() || !table.rows.is_empty() {
413 blocks.push(HtmlBlock::Table(table));
414 }
415 }
416 }
417 b"figure" | b"informalfigure" => {
418 if let Some(caption) = figure_caption.take() {
419 blocks.push(HtmlBlock::Paragraph {
420 text: format!("Figure: {caption}"),
421 });
422 }
423 }
424 b"itemizedlist" | b"orderedlist" => {
425 list_stack.pop();
426 if name == b"orderedlist" {
427 ordered_index.pop();
428 }
429 }
430 _ => {}
431 }
432 stack.pop();
433 }
434 Event::DocType(_) => {
435 push_warning_once(
438 &mut warnings,
439 "DocBook DTD declaration was ignored; external entities were not loaded",
440 );
441 }
442 Event::Eof => break,
443 _ => {}
444 }
445 buffer.clear();
446 }
447 if text_bytes > MAX_DOCBOOK_TEXT_BYTES {
448 return Err(Error::LimitExceeded(format!(
449 "DocBook text exceeds {MAX_DOCBOOK_TEXT_BYTES} bytes"
450 )));
451 }
452 Ok((blocks, warnings))
453}
454
455fn append_text(
456 value: &str,
457 active: &mut Option<ActiveText>,
458 table: &mut Option<DocBookTable>,
459 text_bytes: &mut usize,
460) -> Result<()> {
461 *text_bytes = text_bytes.saturating_add(value.len());
462 if *text_bytes > MAX_DOCBOOK_TEXT_BYTES {
463 return Err(Error::LimitExceeded(format!(
464 "DocBook text exceeds {MAX_DOCBOOK_TEXT_BYTES} bytes"
465 )));
466 }
467 if let Some(table) = table.as_mut()
468 && table.in_cell
469 {
470 table.cell.push_str(value);
471 } else if let Some(active) = active.as_mut() {
472 active.text.push_str(value);
473 }
474 Ok(())
475}
476
477fn flush_active_paragraph(
478 blocks: &mut Vec<HtmlBlock>,
479 active: &mut Option<ActiveText>,
480 _stack: &[String],
481) {
482 let Some(kind) = active.as_ref().map(|value| &value.kind) else {
483 return;
484 };
485 if !matches!(kind, ActiveKind::Paragraph | ActiveKind::ListItem(_)) {
486 return;
487 }
488 let Some(ActiveText { kind, text }) = active.take() else {
489 return;
490 };
491 let text = clean_text(&text);
492 if text.is_empty() {
493 return;
494 }
495 match kind {
496 ActiveKind::ListItem(bullet) => blocks.push(HtmlBlock::ListItem { bullet, text }),
497 ActiveKind::Paragraph => blocks.push(HtmlBlock::Paragraph { text }),
498 _ => {}
499 }
500}
501
502fn append_image(
503 blocks: &mut Vec<HtmlBlock>,
504 warnings: &mut Vec<String>,
505 images: &HashMap<String, InlineHtmlImage>,
506 source: Option<&str>,
507 caption: Option<String>,
508) {
509 if let Some(caption_text) = caption.as_deref() {
510 blocks.push(HtmlBlock::Paragraph {
511 text: format!("Figure: {caption_text}"),
512 });
513 }
514 let Some(source) = source else {
515 push_warning_once(warnings, "DocBook image without fileref was omitted");
516 return;
517 };
518 if let Some(image) = images.get(source) {
519 blocks.push(HtmlBlock::Image {
520 href: image.href.clone(),
521 pixel_width: image.pixel_width,
522 pixel_height: image.pixel_height,
523 alt: caption.unwrap_or_else(|| "DocBook image".into()),
524 });
525 } else {
526 push_warning_once(
527 warnings,
528 "DocBook image was omitted because it was not a validated local PNG/JPEG resource",
529 );
530 }
531}
532
533fn finish_table(table: DocBookTable) -> TableData {
534 let mut rows = table.rows;
535 let header_count = table.header_rows.min(rows.len());
536 let mut headers = if header_count > 0 {
537 rows.drain(..header_count).flatten().collect()
538 } else {
539 rows.first().cloned().unwrap_or_default()
540 };
541 if header_count == 0 && !rows.is_empty() {
542 rows.remove(0);
543 }
544 let columns = headers
545 .len()
546 .max(rows.iter().map(Vec::len).max().unwrap_or(0))
547 .max(1);
548 headers.resize(columns, String::new());
549 for row in &mut rows {
550 row.resize(columns, String::new());
551 }
552 TableData {
553 headers,
554 rows,
555 alignments: vec![TableAlign::Left; columns],
556 raw_source: String::new(),
557 }
558}
559
560fn title_level(stack: &[String]) -> u8 {
561 let depth = stack
562 .iter()
563 .filter(|name| {
564 matches!(
565 name.as_str(),
566 "part"
567 | "chapter"
568 | "appendix"
569 | "preface"
570 | "section"
571 | "sect1"
572 | "sect2"
573 | "sect3"
574 | "sect4"
575 | "sect5"
576 | "simplesect"
577 | "topic"
578 )
579 })
580 .count();
581 depth.clamp(1, 6) as u8
582}
583
584fn clean_text(value: &str) -> String {
585 value.split_whitespace().collect::<Vec<_>>().join(" ")
586}
587
588fn push_warning_once(warnings: &mut Vec<String>, warning: &str) {
589 if !warnings.iter().any(|existing| existing == warning) {
590 warnings.push(warning.to_owned());
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::looks_like_prefix;
597
598 #[test]
599 fn recognizes_docbook_namespace_and_doctype_without_broad_text_sniffing() {
600 assert!(looks_like_prefix(
601 br#"<article xmlns="http://docbook.org/ns/docbook"><title>Guide</title></article>"#
602 ));
603 assert!(looks_like_prefix(
604 br#"<!DOCTYPE book PUBLIC "-//OASIS//DTD DocBook XML V4.5//EN" "docbookx.dtd"><book/>"#
605 ));
606 assert!(!looks_like_prefix(
607 br#"<article><para>This mentions DocBook as prose.</para></article>"#
608 ));
609 }
610}