document-svg 2.0.0

Bounded document, diagram, CAD/CAE, and image archive conversion to SVG in Rust
Documentation
use std::fs;
use std::fs::File;
use std::io::Read;
use std::path::Path;

use crate::convert::{ConvertOptions, PageConsumer};
use crate::document::html::{
    HtmlBlock, load_local_image_sources, render_blocks_to_pages_with_warnings,
};
use crate::document::markdown::{
    collect_markdown_image_sources, expand_markdown_reference_images,
    parse_markdown_blocks_with_images,
};
use crate::error::{Error, Result};

const MAX_QUARTO_DOCUMENT_BYTES: u64 = 128 * 1024 * 1024;
const MAX_QUARTO_LINES: usize = 500_000;
const MAX_QUARTO_LINE_BYTES: usize = 1024 * 1024;
const MAX_QUARTO_FRONTMATTER_BYTES: usize = 1024 * 1024;

pub(crate) fn convert(
    path: &Path,
    options: &ConvertOptions,
    sink: &mut dyn PageConsumer,
) -> Result<Vec<String>> {
    let max_bytes = options.max_input_bytes.min(MAX_QUARTO_DOCUMENT_BYTES);
    let mut bytes = Vec::new();
    Read::take(File::open(path)?, max_bytes.saturating_add(1)).read_to_end(&mut bytes)?;
    if bytes.len() as u64 > max_bytes {
        return Err(Error::LimitExceeded(format!(
            "Quarto/R Markdown input exceeds maximum bytes ({max_bytes})"
        )));
    }
    let text = String::from_utf8(bytes).map_err(|error| {
        Error::InvalidInput(format!(
            "Quarto/R Markdown file is not valid UTF-8: {error}"
        ))
    })?;
    let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
    let expanded_text = expand_markdown_reference_images(text, max_bytes)?;
    let parent = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    let base_dir = fs::canonicalize(parent)?;
    let (sources, source_limit_exceeded) = collect_markdown_image_sources(&expanded_text);
    let (inline_images, mut warnings) =
        load_local_image_sources(&base_dir, sources, source_limit_exceeded)?;
    let (blocks, parser_warnings) = parse_document_with_images(&expanded_text, &inline_images)?;
    warnings.extend(parser_warnings);
    render_blocks_to_pages_with_warnings(&blocks, sink, options, &warnings)?;
    Ok(warnings)
}

fn parse_document_with_images(
    text: &str,
    inline_images: &std::collections::HashMap<String, crate::document::html::InlineHtmlImage>,
) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
    if text.lines().count() > MAX_QUARTO_LINES {
        return Err(Error::LimitExceeded(format!(
            "Quarto/R Markdown input exceeds {MAX_QUARTO_LINES} lines"
        )));
    }
    if text.lines().any(|line| line.len() > MAX_QUARTO_LINE_BYTES) {
        return Err(Error::LimitExceeded(format!(
            "Quarto/R Markdown line exceeds {MAX_QUARTO_LINE_BYTES} bytes"
        )));
    }

    let mut blocks = Vec::new();
    let mut warnings = vec![
        "Quarto/R Markdown code chunks and inline expressions are shown as source and are not executed; generated results, filters, citations, cross-references, and includes are not evaluated".into(),
    ];
    let body = if let Some((frontmatter, body)) = split_frontmatter(text) {
        if frontmatter.len() > MAX_QUARTO_FRONTMATTER_BYTES {
            return Err(Error::LimitExceeded(format!(
                "Quarto/R Markdown YAML front matter exceeds {MAX_QUARTO_FRONTMATTER_BYTES} bytes"
            )));
        }
        let metadata = read_simple_frontmatter(frontmatter);
        if let Some(title) = metadata.title {
            blocks.push(HtmlBlock::Heading {
                level: 1,
                text: title,
            });
        }
        let byline = [metadata.author, metadata.date]
            .into_iter()
            .flatten()
            .collect::<Vec<_>>();
        if !byline.is_empty() {
            blocks.push(HtmlBlock::Paragraph {
                text: byline.join(" ยท "),
            });
        }
        body
    } else {
        warnings.push(
            "Quarto/R Markdown file has no closed YAML front-matter block; content was parsed as Markdown".into(),
        );
        text
    };

    let (markdown_blocks, markdown_warnings) =
        parse_markdown_blocks_with_images(&safe_text(body), inline_images)?;
    blocks.extend(markdown_blocks);
    warnings.extend(markdown_warnings);
    Ok((blocks, warnings))
}

struct FrontmatterSummary {
    title: Option<String>,
    author: Option<String>,
    date: Option<String>,
}

fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
    let first_end = text.find('\n').map_or(text.len(), |index| index + 1);
    let first_line = text[..first_end].trim_end_matches(['\r', '\n']).trim();
    if first_line != "---" {
        return None;
    }

    let mut offset = first_end;
    for line in text[first_end..].split_inclusive('\n') {
        let content = line.trim_end_matches(['\r', '\n']).trim();
        let next_offset = offset.checked_add(line.len())?;
        if content == "---" || content == "..." {
            return Some((&text[first_end..offset], &text[next_offset..]));
        }
        offset = next_offset;
    }
    None
}

fn read_simple_frontmatter(frontmatter: &str) -> FrontmatterSummary {
    let mut summary = FrontmatterSummary {
        title: None,
        author: None,
        date: None,
    };
    for line in frontmatter.lines() {
        let line = line.trim();
        let Some((key, value)) = line.split_once(':') else {
            continue;
        };
        let Some(value) = parse_simple_yaml_scalar(value.trim()) else {
            continue;
        };
        match key.trim() {
            "title" => summary.title = Some(value),
            "author" => summary.author = Some(value),
            "date" => summary.date = Some(value),
            _ => {}
        }
    }
    summary
}

fn parse_simple_yaml_scalar(value: &str) -> Option<String> {
    if value.is_empty() || matches!(value, "|" | ">" | "[]" | "{}") {
        return None;
    }
    if value.starts_with('[') || value.starts_with('{') {
        return None;
    }
    let value = if value.len() >= 2
        && ((value.starts_with('"') && value.ends_with('"'))
            || (value.starts_with('\'') && value.ends_with('\'')))
    {
        &value[1..value.len() - 1]
    } else {
        value.split(" #").next().unwrap_or(value).trim()
    };
    let value = safe_text(value.trim());
    (!value.is_empty()).then_some(value)
}

fn safe_text(text: &str) -> String {
    text.chars()
        .filter(|character| {
            *character == '\n'
                || *character == '\r'
                || *character == '\t'
                || !character.is_control()
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::parse_document_with_images;
    use crate::document::html::HtmlBlock;
    use std::collections::HashMap;

    #[test]
    fn parses_simple_yaml_frontmatter_and_code_chunks_as_source() {
        let source = "---\ntitle: \"Analysis Report\"\nauthor: Ada Lovelace\ndate: 2026-09-13\nexecute:\n  echo: false\n---\n\n# Findings\nThe result is inline `r value`.\n\n```{r, echo=FALSE}\n1 + 1\n```\n";
        let (blocks, warnings) = parse_document_with_images(source, &HashMap::new()).unwrap();
        assert!(matches!(
            &blocks[0],
            HtmlBlock::Heading { level: 1, text } if text == "Analysis Report"
        ));
        assert!(matches!(
            &blocks[1],
            HtmlBlock::Paragraph { text } if text == "Ada Lovelace ยท 2026-09-13"
        ));
        assert!(blocks.iter().any(|block| matches!(
            block,
            HtmlBlock::CodeBlock { text } if text.contains("1 + 1")
        )));
        assert!(
            warnings
                .iter()
                .any(|warning| warning.contains("are not executed"))
        );
    }

    #[test]
    fn leaves_unclosed_frontmatter_as_markdown_and_enforces_line_limits() {
        let (blocks, warnings) =
            parse_document_with_images("---\ntitle: No closing delimiter\n", &HashMap::new())
                .unwrap();
        assert!(!blocks.is_empty());
        assert!(
            warnings
                .iter()
                .any(|warning| warning.contains("no closed YAML"))
        );
        let too_long = format!("{}\n", "x".repeat(super::MAX_QUARTO_LINE_BYTES + 1));
        assert!(matches!(
            parse_document_with_images(&too_long, &HashMap::new()),
            Err(crate::error::Error::LimitExceeded(_))
        ));
    }
}