1use 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
69pub 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 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 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(¶graph.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 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}