document_svg/document/
quarto.rs1use 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}