Skip to main content

document_svg/document/
orgmode.rs

1//! Bounded Org-mode (`.org`) document preview.
2//!
3//! Renders outline headings, paragraphs, lists, tables, literal source blocks,
4//! quote blocks, and safe local PNG/JPEG file links. Babel code, `#+INCLUDE`,
5//! raw export blocks, links, and table formulas are never evaluated or fetched.
6
7use std::collections::HashMap;
8use std::fs;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{
13    HtmlBlock, InlineHtmlImage, load_local_image_sources, render_blocks_to_pages,
14};
15use crate::error::{Error, Result};
16use crate::table::{TableAlign, TableData};
17
18const MAX_ORG_LINES: usize = 200_000;
19const MAX_ORG_LINE_BYTES: usize = 1024 * 1024;
20const MAX_ORG_IMAGE_REFERENCES: usize = 10_000;
21const MAX_ORG_INPUT_BYTES: u64 = 64 * 1024 * 1024;
22const MAX_ORG_IMAGE_WIDTH: u32 = 4096;
23const MAX_ORG_TABLE_COLUMNS: usize = 64;
24const MAX_ORG_TABLE_ROWS: usize = 10_000;
25const MAX_ORG_TABLE_CELLS: usize = 500_000;
26
27pub(crate) fn convert(
28    path: &Path,
29    options: &ConvertOptions,
30    sink: &mut dyn PageConsumer,
31) -> Result<Vec<String>> {
32    let bytes = read_limited_file(
33        path,
34        options.max_input_bytes.min(MAX_ORG_INPUT_BYTES),
35        "Org-mode input",
36    )?;
37    let text = String::from_utf8(bytes).map_err(|error| {
38        Error::InvalidInput(format!("Org-mode file is not valid UTF-8: {error}"))
39    })?;
40    validate_org_lines(&text)?;
41    let (sources, too_many) = collect_image_sources(&text);
42    let mut warnings = Vec::new();
43    let images = if sources.is_empty() && !too_many {
44        HashMap::new()
45    } else {
46        let parent = path
47            .parent()
48            .filter(|parent| !parent.as_os_str().is_empty())
49            .unwrap_or_else(|| Path::new("."));
50        let base_dir = fs::canonicalize(parent)?;
51        let (images, image_warnings) = load_local_image_sources(&base_dir, sources, too_many)?;
52        warnings.extend(image_warnings);
53        if !images.is_empty() {
54            push_warning(
55                &mut warnings,
56                "Org-mode file links to images are rendered as centered flow blocks; inline positioning is approximated",
57            );
58        }
59        images
60    };
61    let (blocks, parser_warnings) = parse_org_blocks_with_images(&text, &images)?;
62    for warning in parser_warnings {
63        push_warning(&mut warnings, &warning);
64    }
65    render_blocks_to_pages(&blocks, sink, options)?;
66    Ok(warnings)
67}
68
69/// Parses common Org-mode syntax into renderable blocks without evaluating blocks.
70pub fn parse_org_blocks(text: &str) -> Result<Vec<HtmlBlock>> {
71    parse_org_blocks_with_images(text, &HashMap::new()).map(|(blocks, _)| blocks)
72}
73
74pub(crate) fn looks_like_org_prefix(prefix: &[u8]) -> bool {
75    let text = String::from_utf8_lossy(prefix);
76    text.lines()
77        .take(12)
78        .map(str::trim)
79        .any(|line| line.to_ascii_uppercase().starts_with("#+TITLE:"))
80}
81
82fn validate_org_lines(text: &str) -> Result<()> {
83    if text.len() as u64 > MAX_ORG_INPUT_BYTES {
84        return Err(Error::LimitExceeded(format!(
85            "Org-mode input exceeds {MAX_ORG_INPUT_BYTES} bytes"
86        )));
87    }
88    if text.lines().count() > MAX_ORG_LINES {
89        return Err(Error::LimitExceeded(format!(
90            "Org-mode input exceeds {MAX_ORG_LINES} lines"
91        )));
92    }
93    if text.lines().any(|line| line.len() > MAX_ORG_LINE_BYTES) {
94        return Err(Error::LimitExceeded(format!(
95            "Org-mode line exceeds {MAX_ORG_LINE_BYTES} bytes"
96        )));
97    }
98    Ok(())
99}
100
101fn collect_image_sources(text: &str) -> (Vec<String>, bool) {
102    let mut sources = Vec::new();
103    let mut too_many = false;
104    for line in text.lines().map(str::trim) {
105        let Some(target) = standalone_file_link(line) else {
106            continue;
107        };
108        if !is_supported_image_path(target) {
109            continue;
110        }
111        if sources.len() >= MAX_ORG_IMAGE_REFERENCES {
112            too_many = true;
113        } else {
114            sources.push(target.to_owned());
115        }
116    }
117    (sources, too_many)
118}
119
120fn parse_org_blocks_with_images(
121    text: &str,
122    images: &HashMap<String, InlineHtmlImage>,
123) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
124    validate_org_lines(text)?;
125    let lines = text.lines().collect::<Vec<_>>();
126    let mut blocks = Vec::new();
127    let mut warnings = Vec::new();
128    let mut pending_caption = None::<String>;
129    let mut pending_image_width = None::<u32>;
130    let mut table_cells = 0usize;
131    let mut i = 0usize;
132
133    while i < lines.len() {
134        let trimmed = lines[i].trim();
135        if trimmed.is_empty() {
136            i += 1;
137            continue;
138        }
139
140        if let Some(title) = keyword_value(trimmed, "#+TITLE:") {
141            pending_caption = None;
142            pending_image_width = None;
143            blocks.push(HtmlBlock::Heading {
144                level: 1,
145                text: clean_org_inline(title),
146            });
147            i += 1;
148            continue;
149        }
150        if let Some(caption) = keyword_value(trimmed, "#+CAPTION:") {
151            pending_caption = Some(clean_org_inline(caption));
152            i += 1;
153            continue;
154        }
155        if let Some(width) = org_image_width_attribute(trimmed) {
156            match width {
157                Ok(Some(width)) => pending_image_width = Some(width),
158                Ok(None) => {}
159                Err(()) => push_warning(
160                    &mut warnings,
161                    "Org-mode image width must be an integer in pixels from 1 to 4096; the attribute was ignored",
162                ),
163            }
164            i += 1;
165            continue;
166        }
167        if let Some((kind, parameters)) = begin_block(trimmed) {
168            pending_caption = None;
169            pending_image_width = None;
170            let kind = kind.to_ascii_uppercase();
171            let (body, next) = collect_org_block(&lines, i + 1, &kind);
172            i = next;
173            match kind.as_str() {
174                "SRC" | "EXAMPLE" => blocks.push(HtmlBlock::CodeBlock {
175                    text: body.join("\n"),
176                }),
177                "QUOTE" | "QUOTATION" => {
178                    if !body.is_empty() {
179                        blocks.push(HtmlBlock::Paragraph {
180                            text: format!("> {}", clean_org_inline(&body.join(" "))),
181                        });
182                    }
183                }
184                "VERSE" | "CENTER" => {
185                    if !body.is_empty() {
186                        blocks.push(HtmlBlock::Paragraph {
187                            text: clean_org_inline(&body.join(" ")),
188                        });
189                    }
190                }
191                "EXPORT" => push_warning(
192                    &mut warnings,
193                    "Org-mode raw/export blocks were omitted and not interpreted",
194                ),
195                _ => {
196                    push_warning(
197                        &mut warnings,
198                        "unsupported Org-mode block type was shown literally",
199                    );
200                    blocks.push(HtmlBlock::CodeBlock {
201                        text: std::iter::once(format!("#+BEGIN_{kind}{parameters}"))
202                            .chain(body)
203                            .chain(std::iter::once(format!("#+END_{kind}")))
204                            .collect::<Vec<_>>()
205                            .join("\n"),
206                    });
207                }
208            }
209            continue;
210        }
211
212        if trimmed.to_ascii_uppercase().starts_with("#+INCLUDE:") {
213            pending_caption = None;
214            pending_image_width = None;
215            push_warning(&mut warnings, "Org-mode #+INCLUDE was not evaluated");
216            i += 1;
217            continue;
218        }
219        if trimmed.to_ascii_uppercase().starts_with("#+CALL:") {
220            pending_caption = None;
221            pending_image_width = None;
222            push_warning(&mut warnings, "Org-mode Babel calls were not executed");
223            i += 1;
224            continue;
225        }
226        if trimmed.to_ascii_uppercase().starts_with("#+TBLFM:") {
227            pending_caption = None;
228            pending_image_width = None;
229            push_warning(&mut warnings, "Org-mode table formulas were not evaluated");
230            i += 1;
231            continue;
232        }
233        if trimmed.starts_with("#+") {
234            pending_caption = None;
235            pending_image_width = None;
236            // Metadata and export options change Org presentation, not the source
237            // text preview; never pass raw HTML/CSS or setup files through.
238            if !is_known_metadata_keyword(trimmed) {
239                push_warning(&mut warnings, "unknown Org-mode keyword was omitted");
240            }
241            i += 1;
242            continue;
243        }
244
245        if let Some(path) = standalone_file_link(trimmed)
246            && is_supported_image_path(path)
247        {
248            if let Some(image) = images.get(path) {
249                let caption = pending_caption.take();
250                let (pixel_width, pixel_height) =
251                    scaled_image_dimensions(image, pending_image_width.take());
252                blocks.push(HtmlBlock::Image {
253                    href: image.href.clone(),
254                    pixel_width,
255                    pixel_height,
256                    alt: caption.clone().unwrap_or_default(),
257                });
258                if let Some(caption) = caption {
259                    blocks.push(HtmlBlock::Paragraph { text: caption });
260                }
261            } else {
262                pending_image_width = None;
263                push_warning(
264                    &mut warnings,
265                    "Org-mode local image link was omitted because it was not a validated local PNG/JPEG file",
266                );
267                if let Some(caption) = pending_caption.take() {
268                    blocks.push(HtmlBlock::Paragraph {
269                        text: format!("[Image omitted: {caption}]"),
270                    });
271                }
272            }
273            i += 1;
274            continue;
275        }
276
277        // Caption/image attributes apply only to the immediately following
278        // renderable element; do not carry them across unrelated blocks.
279        pending_caption = None;
280        pending_image_width = None;
281
282        if let Some((table, next)) = parse_org_table(&lines, i, &mut table_cells, &mut warnings) {
283            blocks.push(HtmlBlock::Table(table));
284            i = next;
285            continue;
286        }
287        if let Some((level, title)) = headline(trimmed) {
288            blocks.push(HtmlBlock::Heading {
289                level,
290                text: clean_org_inline(title),
291            });
292            i += 1;
293            continue;
294        }
295        if let Some((bullet, text)) = list_item(trimmed) {
296            blocks.push(HtmlBlock::ListItem {
297                bullet,
298                text: clean_org_inline(text),
299            });
300            i += 1;
301            continue;
302        }
303        if is_horizontal_rule(trimmed) {
304            blocks.push(HtmlBlock::HorizontalRule);
305            i += 1;
306            continue;
307        }
308
309        let mut paragraph = Vec::new();
310        while i < lines.len() {
311            let line = lines[i].trim();
312            if line.is_empty()
313                || line.starts_with("#+")
314                || begin_block(line).is_some()
315                || headline(line).is_some()
316                || list_item(line).is_some()
317                || is_org_table_row(line)
318                || is_horizontal_rule(line)
319                || standalone_file_link(line).is_some()
320            {
321                break;
322            }
323            paragraph.push(line);
324            i += 1;
325        }
326        if paragraph.is_empty() {
327            i += 1;
328            continue;
329        }
330        blocks.push(HtmlBlock::Paragraph {
331            text: clean_org_inline(&paragraph.join(" ")),
332        });
333    }
334    Ok((blocks, warnings))
335}
336
337fn begin_block(line: &str) -> Option<(&str, &str)> {
338    if !line
339        .get(..8)
340        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("#+BEGIN_"))
341    {
342        return None;
343    }
344    let rest = line.get(8..)?;
345    let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
346    let kind = &rest[..end];
347    if kind.is_empty()
348        || !kind
349            .bytes()
350            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
351    {
352        return None;
353    }
354    Some((kind, &rest[end..]))
355}
356
357fn collect_org_block(lines: &[&str], mut index: usize, kind: &str) -> (Vec<String>, usize) {
358    let end_marker = format!("#+END_{}", kind.to_ascii_uppercase());
359    let mut body = Vec::new();
360    while index < lines.len() {
361        if lines[index].trim().eq_ignore_ascii_case(&end_marker) {
362            return (body, index + 1);
363        }
364        body.push(lines[index].to_owned());
365        index += 1;
366    }
367    (body, index)
368}
369
370fn keyword_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
371    line.get(..key.len())
372        .filter(|prefix| prefix.eq_ignore_ascii_case(key))
373        .map(|_| line[key.len()..].trim())
374}
375
376fn org_image_width_attribute(line: &str) -> Option<std::result::Result<Option<u32>, ()>> {
377    let attributes = keyword_value(line, "#+ATTR_ORG:")?;
378    let mut tokens = attributes.split_whitespace();
379    while let Some(attribute) = tokens.next() {
380        if !attribute.eq_ignore_ascii_case(":width") {
381            continue;
382        }
383        let Some(value) = tokens.next() else {
384            return Some(Err(()));
385        };
386        let value = if value.len() >= 2
387            && value.as_bytes()[value.len() - 2].eq_ignore_ascii_case(&b'p')
388            && value.as_bytes()[value.len() - 1].eq_ignore_ascii_case(&b'x')
389        {
390            &value[..value.len() - 2]
391        } else {
392            value
393        };
394        return Some(
395            value
396                .parse::<u32>()
397                .ok()
398                .filter(|width| (1..=MAX_ORG_IMAGE_WIDTH).contains(width))
399                .map(Some)
400                .ok_or(()),
401        );
402    }
403    Some(Ok(None))
404}
405
406fn scaled_image_dimensions(image: &InlineHtmlImage, requested_width: Option<u32>) -> (u32, u32) {
407    let Some(width) = requested_width else {
408        return (image.pixel_width, image.pixel_height);
409    };
410    if image.pixel_width == 0 {
411        return (image.pixel_width, image.pixel_height);
412    }
413    let height = (u64::from(image.pixel_height) * u64::from(width)
414        + u64::from(image.pixel_width) / 2)
415        / u64::from(image.pixel_width);
416    (width, height.clamp(1, u64::from(u32::MAX)) as u32)
417}
418
419fn is_known_metadata_keyword(line: &str) -> bool {
420    [
421        "#+TITLE:",
422        "#+SUBTITLE:",
423        "#+AUTHOR:",
424        "#+DATE:",
425        "#+EMAIL:",
426        "#+OPTIONS:",
427        "#+STARTUP:",
428        "#+LANGUAGE:",
429        "#+FILETAGS:",
430        "#+PROPERTY:",
431        "#+TODO:",
432        "#+SEQ_TODO:",
433        "#+PRIORITIES:",
434        "#+BIBLIOGRAPHY:",
435        "#+LATEX_CLASS:",
436        "#+RESULTS:",
437        "#+CAPTION:",
438        "#+NAME:",
439        "#+ATTR_ORG:",
440        "#+ATTR_HTML:",
441    ]
442    .iter()
443    .any(|prefix| {
444        line.get(..prefix.len())
445            .is_some_and(|value| value.eq_ignore_ascii_case(prefix))
446    })
447}
448
449fn headline(line: &str) -> Option<(u8, &str)> {
450    let level = line.bytes().take_while(|byte| *byte == b'*').count();
451    if level == 0 || level > 6 || line.as_bytes().get(level) != Some(&b' ') {
452        return None;
453    }
454    // Keep workflow states visible; they are document content, not export
455    // controls or execution hints.
456    Some((level as u8, line[level..].trim()))
457}
458
459fn list_item(line: &str) -> Option<(String, &str)> {
460    for marker in ["- ", "+ "] {
461        if let Some(text) = line.strip_prefix(marker) {
462            return Some(("• ".into(), text));
463        }
464    }
465    let split = line.find(char::is_whitespace)?;
466    let token = &line[..split];
467    let number = token
468        .strip_suffix('.')
469        .or_else(|| token.strip_suffix(')'))?;
470    number
471        .parse::<usize>()
472        .ok()
473        .map(|_| (format!("{token} "), line[split..].trim_start()))
474}
475
476fn is_horizontal_rule(line: &str) -> bool {
477    line.len() >= 5 && line.bytes().all(|byte| byte == b'-')
478}
479
480fn standalone_file_link(line: &str) -> Option<&str> {
481    let target = line.strip_prefix("[[file:")?.strip_suffix("]]")?;
482    (!target.is_empty() && !target.contains(']')).then_some(target)
483}
484
485fn is_supported_image_path(path: &str) -> bool {
486    let lower = path.to_ascii_lowercase();
487    [".png", ".jpg", ".jpeg"]
488        .iter()
489        .any(|ext| lower.ends_with(ext))
490}
491
492fn parse_org_table(
493    lines: &[&str],
494    start: usize,
495    document_cell_count: &mut usize,
496    warnings: &mut Vec<String>,
497) -> Option<(TableData, usize)> {
498    if !is_org_table_row(lines.get(start)?.trim()) {
499        return None;
500    }
501    let mut raw_rows = Vec::<Vec<String>>::new();
502    let mut columns = None::<usize>;
503    let mut index = start;
504    while index < lines.len() {
505        let line = lines[index].trim();
506        if is_org_table_separator(line) {
507            index += 1;
508            continue;
509        }
510        if !is_org_table_row(line) {
511            break;
512        }
513        if raw_rows.len() >= MAX_ORG_TABLE_ROWS {
514            push_warning(
515                warnings,
516                "Org-mode table exceeded the supported row limit; remaining rows were omitted",
517            );
518            while index < lines.len()
519                && (is_org_table_row(lines[index].trim())
520                    || is_org_table_separator(lines[index].trim()))
521            {
522                index += 1;
523            }
524            break;
525        }
526        let mut cells = line
527            .trim_matches('|')
528            .split('|')
529            .take(MAX_ORG_TABLE_COLUMNS + 1)
530            .map(|cell| clean_org_inline(cell.trim()))
531            .collect::<Vec<_>>();
532        if cells.len() > MAX_ORG_TABLE_COLUMNS {
533            push_warning(
534                warnings,
535                "Org-mode table exceeded the supported column limit; extra cells were omitted",
536            );
537            cells.truncate(MAX_ORG_TABLE_COLUMNS);
538        }
539        let width = *columns.get_or_insert(cells.len());
540        cells.resize(width, String::new());
541        cells.truncate(width);
542        if document_cell_count.saturating_add(width) > MAX_ORG_TABLE_CELLS {
543            push_warning(
544                warnings,
545                "Org-mode tables exceeded the document cell budget; remaining table rows were omitted",
546            );
547            while index < lines.len()
548                && (is_org_table_row(lines[index].trim())
549                    || is_org_table_separator(lines[index].trim()))
550            {
551                index += 1;
552            }
553            break;
554        }
555        *document_cell_count += width;
556        raw_rows.push(cells);
557        index += 1;
558    }
559    if raw_rows.is_empty() {
560        return None;
561    }
562    let headers = raw_rows.remove(0);
563    let columns = headers.len();
564    let mut rows = raw_rows;
565    for row in &mut rows {
566        row.resize(columns, String::new());
567        row.truncate(columns);
568    }
569    Some((
570        TableData {
571            headers,
572            rows,
573            alignments: vec![TableAlign::Left; columns],
574            raw_source: String::new(),
575        },
576        index,
577    ))
578}
579
580fn is_org_table_row(line: &str) -> bool {
581    line.starts_with('|') && line.ends_with('|') && line.len() >= 2
582}
583
584fn is_org_table_separator(line: &str) -> bool {
585    is_org_table_row(line)
586        && line
587            .bytes()
588            .all(|byte| matches!(byte, b'|' | b'-' | b'+' | b' '))
589        && line.contains('-')
590}
591
592fn clean_org_inline(text: &str) -> String {
593    let mut output = String::with_capacity(text.len());
594    let mut index = 0usize;
595    let bytes = text.as_bytes();
596    while index < bytes.len() {
597        if text[index..].starts_with("[[")
598            && let Some(end) = text[index + 2..].find("]]")
599        {
600            let end = index + 2 + end;
601            let link = &text[index + 2..end];
602            let visible = link
603                .split_once("][")
604                .map(|(_, description)| description)
605                .unwrap_or_else(|| link.strip_prefix("file:").unwrap_or(link));
606            output.push_str(visible);
607            index = end + 2;
608            continue;
609        }
610        let marker = match bytes[index] {
611            b'*' | b'/' | b'_' | b'+' | b'=' | b'~' => Some(bytes[index] as char),
612            _ => None,
613        };
614        if let Some(marker) = marker {
615            let ch = marker.to_string();
616            if let Some(end) = text[index + 1..].find(&ch) {
617                let end = index + 1 + end;
618                if end > index + 1 {
619                    output.push_str(&text[index + 1..end]);
620                    index = end + 1;
621                    continue;
622                }
623            }
624        }
625        let character = text[index..].chars().next().unwrap_or_default();
626        output.push(character);
627        index += character.len_utf8();
628    }
629    output
630}
631
632fn push_warning(warnings: &mut Vec<String>, warning: &str) {
633    if !warnings.iter().any(|existing| existing == warning) {
634        warnings.push(warning.to_owned());
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn renders_outline_blocks_and_keeps_code_literal() {
644        let org = "#+TITLE: Org Example\n\n* TODO First section\nText with *bold* and [[https://example.invalid][a link]].\n\n| Name | State |\n|------+-------|\n| Parser | Ready |\n\n#+BEGIN_SRC emacs-lisp\n(delete-file \"important\")\n#+END_SRC\n\n#+BEGIN_EXPORT html\n<script>must-not-render()</script>\n#+END_EXPORT\n\n#+INCLUDE: \"/etc/passwd\"\n#+CALL: dangerous-block()\n";
645        let (blocks, warnings) = parse_org_blocks_with_images(org, &HashMap::new()).unwrap();
646        assert!(
647            matches!(&blocks[0], HtmlBlock::Heading { level: 1, text } if text == "Org Example")
648        );
649        assert!(
650            matches!(&blocks[1], HtmlBlock::Heading { level: 1, text } if text == "TODO First section")
651        );
652        assert!(blocks.iter().any(|block| matches!(block, HtmlBlock::Table(table) if table.headers == ["Name", "State"] && table.rows.len() == 1)));
653        assert!(blocks.iter().any(
654            |block| matches!(block, HtmlBlock::CodeBlock { text } if text.contains("delete-file"))
655        ));
656        assert!(
657            warnings
658                .iter()
659                .any(|warning| warning.contains("raw/export blocks were omitted"))
660        );
661        assert!(
662            warnings
663                .iter()
664                .any(|warning| warning.contains("#+INCLUDE was not evaluated"))
665        );
666        assert!(
667            warnings
668                .iter()
669                .any(|warning| warning.contains("Babel calls were not executed"))
670        );
671        let serialized = format!("{blocks:?}");
672        assert!(!serialized.contains("must-not-render"));
673        assert!(!serialized.contains("/etc/passwd"));
674    }
675
676    #[test]
677    fn limits_long_org_lines() {
678        let text = format!("{}\n", "x".repeat(MAX_ORG_LINE_BYTES + 1));
679        assert!(
680            validate_org_lines(&text)
681                .unwrap_err()
682                .to_string()
683                .contains("line exceeds")
684        );
685    }
686
687    #[test]
688    fn parses_bounded_org_image_width_in_pixels() {
689        assert_eq!(
690            org_image_width_attribute("#+ATTR_ORG: :width 96px"),
691            Some(Ok(Some(96)))
692        );
693        assert_eq!(
694            org_image_width_attribute("#+ATTR_ORG: :width 8192"),
695            Some(Err(()))
696        );
697        let image = InlineHtmlImage {
698            href: "data:image/png;base64,AA==".into(),
699            pixel_width: 2,
700            pixel_height: 1,
701        };
702        assert_eq!(scaled_image_dimensions(&image, Some(96)), (96, 48));
703    }
704}