Skip to main content

document_svg/document/
docbook.rs

1//! Bounded DocBook 4/5 article and book preview.
2//!
3//! The converter keeps DocBook content inert: it lays out titles, paragraphs,
4//! lists, source blocks, simple CALS-like tables, and validated local
5//! PNG/JPEG media. XInclude, entities, processing instructions, links,
6//! MathML, scripts, and publisher-specific extensions are never evaluated.
7
8use 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                // quick-xml reports the declaration without resolving it; keep
436                // the document inert and make the omission visible to callers.
437                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}