Skip to main content

document_svg/document/
quarto.rs

1use std::fs;
2use std::fs::File;
3use std::io::Read;
4use std::path::Path;
5
6use crate::convert::{ConvertOptions, PageConsumer};
7use crate::document::html::{
8    HtmlBlock, load_local_image_sources, render_blocks_to_pages_with_warnings,
9};
10use crate::document::markdown::{
11    collect_markdown_image_sources, expand_markdown_reference_images,
12    parse_markdown_blocks_with_images,
13};
14use crate::error::{Error, Result};
15
16const MAX_QUARTO_DOCUMENT_BYTES: u64 = 128 * 1024 * 1024;
17const MAX_QUARTO_LINES: usize = 500_000;
18const MAX_QUARTO_LINE_BYTES: usize = 1024 * 1024;
19const MAX_QUARTO_FRONTMATTER_BYTES: usize = 1024 * 1024;
20
21pub(crate) fn convert(
22    path: &Path,
23    options: &ConvertOptions,
24    sink: &mut dyn PageConsumer,
25) -> Result<Vec<String>> {
26    let max_bytes = options.max_input_bytes.min(MAX_QUARTO_DOCUMENT_BYTES);
27    let mut bytes = Vec::new();
28    Read::take(File::open(path)?, max_bytes.saturating_add(1)).read_to_end(&mut bytes)?;
29    if bytes.len() as u64 > max_bytes {
30        return Err(Error::LimitExceeded(format!(
31            "Quarto/R Markdown input exceeds maximum bytes ({max_bytes})"
32        )));
33    }
34    let text = String::from_utf8(bytes).map_err(|error| {
35        Error::InvalidInput(format!(
36            "Quarto/R Markdown file is not valid UTF-8: {error}"
37        ))
38    })?;
39    let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
40    let expanded_text = expand_markdown_reference_images(text, max_bytes)?;
41    let parent = path
42        .parent()
43        .filter(|parent| !parent.as_os_str().is_empty())
44        .unwrap_or_else(|| Path::new("."));
45    let base_dir = fs::canonicalize(parent)?;
46    let (sources, source_limit_exceeded) = collect_markdown_image_sources(&expanded_text);
47    let (inline_images, mut warnings) =
48        load_local_image_sources(&base_dir, sources, source_limit_exceeded)?;
49    let (blocks, parser_warnings) = parse_document_with_images(&expanded_text, &inline_images)?;
50    warnings.extend(parser_warnings);
51    render_blocks_to_pages_with_warnings(&blocks, sink, options, &warnings)?;
52    Ok(warnings)
53}
54
55fn parse_document_with_images(
56    text: &str,
57    inline_images: &std::collections::HashMap<String, crate::document::html::InlineHtmlImage>,
58) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
59    if text.lines().count() > MAX_QUARTO_LINES {
60        return Err(Error::LimitExceeded(format!(
61            "Quarto/R Markdown input exceeds {MAX_QUARTO_LINES} lines"
62        )));
63    }
64    if text.lines().any(|line| line.len() > MAX_QUARTO_LINE_BYTES) {
65        return Err(Error::LimitExceeded(format!(
66            "Quarto/R Markdown line exceeds {MAX_QUARTO_LINE_BYTES} bytes"
67        )));
68    }
69
70    let mut blocks = Vec::new();
71    let mut warnings = vec![
72        "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(),
73    ];
74    let body = if let Some((frontmatter, body)) = split_frontmatter(text) {
75        if frontmatter.len() > MAX_QUARTO_FRONTMATTER_BYTES {
76            return Err(Error::LimitExceeded(format!(
77                "Quarto/R Markdown YAML front matter exceeds {MAX_QUARTO_FRONTMATTER_BYTES} bytes"
78            )));
79        }
80        let metadata = read_simple_frontmatter(frontmatter);
81        if let Some(title) = metadata.title {
82            blocks.push(HtmlBlock::Heading {
83                level: 1,
84                text: title,
85            });
86        }
87        let byline = [metadata.author, metadata.date]
88            .into_iter()
89            .flatten()
90            .collect::<Vec<_>>();
91        if !byline.is_empty() {
92            blocks.push(HtmlBlock::Paragraph {
93                text: byline.join(" · "),
94            });
95        }
96        body
97    } else {
98        warnings.push(
99            "Quarto/R Markdown file has no closed YAML front-matter block; content was parsed as Markdown".into(),
100        );
101        text
102    };
103
104    let (markdown_blocks, markdown_warnings) =
105        parse_markdown_blocks_with_images(&safe_text(body), inline_images)?;
106    blocks.extend(markdown_blocks);
107    warnings.extend(markdown_warnings);
108    Ok((blocks, warnings))
109}
110
111struct FrontmatterSummary {
112    title: Option<String>,
113    author: Option<String>,
114    date: Option<String>,
115}
116
117fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
118    let first_end = text.find('\n').map_or(text.len(), |index| index + 1);
119    let first_line = text[..first_end].trim_end_matches(['\r', '\n']).trim();
120    if first_line != "---" {
121        return None;
122    }
123
124    let mut offset = first_end;
125    for line in text[first_end..].split_inclusive('\n') {
126        let content = line.trim_end_matches(['\r', '\n']).trim();
127        let next_offset = offset.checked_add(line.len())?;
128        if content == "---" || content == "..." {
129            return Some((&text[first_end..offset], &text[next_offset..]));
130        }
131        offset = next_offset;
132    }
133    None
134}
135
136fn read_simple_frontmatter(frontmatter: &str) -> FrontmatterSummary {
137    let mut summary = FrontmatterSummary {
138        title: None,
139        author: None,
140        date: None,
141    };
142    for line in frontmatter.lines() {
143        let line = line.trim();
144        let Some((key, value)) = line.split_once(':') else {
145            continue;
146        };
147        let Some(value) = parse_simple_yaml_scalar(value.trim()) else {
148            continue;
149        };
150        match key.trim() {
151            "title" => summary.title = Some(value),
152            "author" => summary.author = Some(value),
153            "date" => summary.date = Some(value),
154            _ => {}
155        }
156    }
157    summary
158}
159
160fn parse_simple_yaml_scalar(value: &str) -> Option<String> {
161    if value.is_empty() || matches!(value, "|" | ">" | "[]" | "{}") {
162        return None;
163    }
164    if value.starts_with('[') || value.starts_with('{') {
165        return None;
166    }
167    let value = if value.len() >= 2
168        && ((value.starts_with('"') && value.ends_with('"'))
169            || (value.starts_with('\'') && value.ends_with('\'')))
170    {
171        &value[1..value.len() - 1]
172    } else {
173        value.split(" #").next().unwrap_or(value).trim()
174    };
175    let value = safe_text(value.trim());
176    (!value.is_empty()).then_some(value)
177}
178
179fn safe_text(text: &str) -> String {
180    text.chars()
181        .filter(|character| {
182            *character == '\n'
183                || *character == '\r'
184                || *character == '\t'
185                || !character.is_control()
186        })
187        .collect()
188}
189
190#[cfg(test)]
191mod tests {
192    use super::parse_document_with_images;
193    use crate::document::html::HtmlBlock;
194    use std::collections::HashMap;
195
196    #[test]
197    fn parses_simple_yaml_frontmatter_and_code_chunks_as_source() {
198        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";
199        let (blocks, warnings) = parse_document_with_images(source, &HashMap::new()).unwrap();
200        assert!(matches!(
201            &blocks[0],
202            HtmlBlock::Heading { level: 1, text } if text == "Analysis Report"
203        ));
204        assert!(matches!(
205            &blocks[1],
206            HtmlBlock::Paragraph { text } if text == "Ada Lovelace · 2026-09-13"
207        ));
208        assert!(blocks.iter().any(|block| matches!(
209            block,
210            HtmlBlock::CodeBlock { text } if text.contains("1 + 1")
211        )));
212        assert!(
213            warnings
214                .iter()
215                .any(|warning| warning.contains("are not executed"))
216        );
217    }
218
219    #[test]
220    fn leaves_unclosed_frontmatter_as_markdown_and_enforces_line_limits() {
221        let (blocks, warnings) =
222            parse_document_with_images("---\ntitle: No closing delimiter\n", &HashMap::new())
223                .unwrap();
224        assert!(!blocks.is_empty());
225        assert!(
226            warnings
227                .iter()
228                .any(|warning| warning.contains("no closed YAML"))
229        );
230        let too_long = format!("{}\n", "x".repeat(super::MAX_QUARTO_LINE_BYTES + 1));
231        assert!(matches!(
232            parse_document_with_images(&too_long, &HashMap::new()),
233            Err(crate::error::Error::LimitExceeded(_))
234        ));
235    }
236}