krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
1//! RENDER stage (spec §2.1, §2.4): resolved element tree → HTML fragment.
2//!
3//! A tree walk emitting HTML into a buffer. Two sub-concerns get care (spec §2.4):
4//! 1. Footnotes use a two-pass layout — definitions are collected up front, references
5//! are numbered in order of first appearance during the walk, and a back-linked
6//! notes section is emitted at page end.
7//! 2. Syntax highlighting happens HERE, not at parse time — it is an output concern
8//! and its cost must be cache-skippable (spec §4.2). Emit CSS classes, not inline
9//! styles, so themes live in the stylesheet (spec §3.2).
10//!
11//! Renders the supported set (`docs/guide/05-org-support.org`): headings (always
12//! anchored, with TODO keyword, priority and tags), paragraphs, plain lists
13//! (unordered/ordered/description, nested, with checkboxes), tables, source blocks
14//! (syntect-highlighted), example/quote/center blocks, HTML export blocks, horizontal
15//! rules, images and captioned figures, footnotes, timestamps, and inline markup.
16//! Out-of-scope elements (generic drawers, comments, stray keywords, non-HTML export
17//! blocks) render to nothing.
18
19use std::collections::HashMap;
20use std::sync::OnceLock;
21
22use syntect::highlighting::ThemeSet;
23use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGenerator};
24use syntect::parsing::SyntaxSet;
25use syntect::util::LinesWithEndings;
26
27use crate::config::SubSuperscript;
28use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow};
29use crate::parser::is_image_target;
30use crate::resolve::ResolvedDoc;
31use crate::util::{export_options, heading_anchor, option_enabled, plain_text, slugify};
32
33/// A rendered HTML fragment (content only — no page chrome; spec §2.4).
34#[derive(Debug, Clone)]
35pub struct Html(pub String);
36
37/// Pluggable highlighter so tree-sitter can replace syntect per-language later
38/// without touching the renderer (spec §3.2, R6).
39pub trait Highlighter {
40 fn highlight(&self, code: &str, lang: Option<&str>) -> Html;
41}
42
43/// The class style used for both the emitted spans and the generated stylesheet. The
44/// two must agree or the CSS will not match the markup.
45const CLASS_STYLE: ClassStyle = ClassStyle::Spaced;
46
47/// Syntax definitions syntect does not bundle, compiled into the binary.
48///
49/// Both are gaps this project hits on its own first page: every `orgo.toml` example is
50/// TOML, and a tool for org users is going to be written about in org. Embedding them
51/// rather than shipping files means they work with no setup, which is the same promise
52/// the rest of the zero-config path makes.
53const BUNDLED_SYNTAXES: &[(&str, &str)] = &[
54 ("TOML", include_str!("../syntaxes/TOML.sublime-syntax")),
55 ("Org", include_str!("../syntaxes/Org.sublime-syntax")),
56];
57
58/// Syntect's default syntax definitions plus [`BUNDLED_SYNTAXES`], loaded once per
59/// process (loading is far more expensive than highlighting, and a site build highlights
60/// many blocks).
61fn syntax_set() -> &'static SyntaxSet {
62 static SET: OnceLock<SyntaxSet> = OnceLock::new();
63 SET.get_or_init(|| build_syntax_set(None))
64}
65
66/// Build a syntax set: syntect's defaults, the bundled additions, and optionally a
67/// directory of user `.sublime-syntax` files.
68///
69/// A malformed bundled definition is a bug in this crate and panics. A malformed *user*
70/// definition is reported and skipped, because one bad file in a directory should not
71/// stop a site from building.
72fn build_syntax_set(user_dir: Option<&camino::Utf8Path>) -> SyntaxSet {
73 let mut builder = SyntaxSet::load_defaults_newlines().into_builder();
74 for (name, source) in BUNDLED_SYNTAXES {
75 let definition =
76 syntect::parsing::SyntaxDefinition::load_from_str(source, true, Some(name))
77 .unwrap_or_else(|e| panic!("bundled {name} syntax is malformed: {e}"));
78 builder.add(definition);
79 }
80 if let Some(dir) = user_dir.filter(|d| d.is_dir()) {
81 if let Err(e) = builder.add_from_folder(dir, true) {
82 eprintln!("warning: ignoring syntax definitions in {dir}: {e}");
83 }
84 }
85 builder.build()
86}
87
88/// A syntax set including a user directory of `.sublime-syntax` files.
89///
90/// Cached per directory: a build highlights many blocks, and rebuilding the set for each
91/// would cost more than the highlighting.
92fn syntax_set_with(user_dir: &camino::Utf8Path) -> &'static SyntaxSet {
93 use std::collections::HashMap;
94 use std::sync::Mutex;
95 static SETS: OnceLock<Mutex<HashMap<camino::Utf8PathBuf, &'static SyntaxSet>>> =
96 OnceLock::new();
97 let sets = SETS.get_or_init(|| Mutex::new(HashMap::new()));
98 let mut sets = sets.lock().expect("syntax set cache");
99 sets.entry(user_dir.to_owned())
100 .or_insert_with(|| Box::leak(Box::new(build_syntax_set(Some(user_dir)))))
101}
102
103fn theme_set() -> &'static ThemeSet {
104 static THEMES: OnceLock<ThemeSet> = OnceLock::new();
105 THEMES.get_or_init(ThemeSet::load_defaults)
106}
107
108/// The stylesheet the emitted highlight classes refer to, for a named syntect theme.
109/// Highlighting emits CSS classes rather than inline styles (spec §3.2), so a build must
110/// also emit this. `None` means the theme name is not one syntect ships — the caller
111/// reports that rather than quietly emitting an empty stylesheet, which would look like
112/// highlighting is broken.
113pub fn syntax_css(theme: &str) -> Option<String> {
114 let theme = theme_set().themes.get(theme)?;
115 css_for_theme_with_class_style(theme, CLASS_STYLE).ok()
116}
117
118/// Every theme name [`syntax_css`] accepts, for error messages and documentation.
119pub fn available_themes() -> Vec<&'static str> {
120 theme_set().themes.keys().map(String::as_str).collect()
121}
122
123/// The whole stylesheet a build writes: `highlight.theme`'s rules, and — when
124/// `highlight.theme_dark` is set — a second theme's, each behind the
125/// `prefers-color-scheme` query it belongs to. One file, both schemes, no JavaScript and
126/// nothing extra for a layout to link.
127///
128/// The two themes are *separated* rather than stacked, because stacking does not work:
129/// syntect writes a rule per scope its theme names, and the themes name different ones.
130/// A light theme's language-specific selector (`.source.python .keyword`) outranks a
131/// dark theme's plain `.keyword`, so a dark theme appended after a light one would leave
132/// light colours on some tokens — the half-themed look this setting exists to avoid.
133/// The cost is that a browser too old to know `prefers-color-scheme` matches neither
134/// query and renders code unhighlighted; a site that sets one theme is untouched by this
135/// and keeps its unconditional rules.
136///
137/// Only token colours change with the scheme. A code block's *surface* is the page's,
138/// set by whatever stylesheet the layout links, so a dark theme needs a dark background
139/// there to sit on.
140pub fn syntax_stylesheet(cfg: &crate::config::Highlight) -> anyhow::Result<String> {
141 let light = theme_css_or_error(&cfg.theme, "highlight.theme")?;
142 if cfg.theme_dark.is_empty() {
143 return Ok(light);
144 }
145 let dark = theme_css_or_error(&cfg.theme_dark, "highlight.theme_dark")?;
146 Ok(format!(
147 "@media (prefers-color-scheme: light) {{\n{light}}}\n\n\
148 @media (prefers-color-scheme: dark) {{\n{dark}}}\n"
149 ))
150}
151
152fn theme_css_or_error(theme: &str, key: &str) -> anyhow::Result<String> {
153 syntax_css(theme).ok_or_else(|| {
154 anyhow::anyhow!(
155 "unknown {key} {theme:?}. Available: {}",
156 available_themes().join(", ")
157 )
158 })
159}
160
161/// The v1 highlighter: syntect tokenizing to CSS-class spans (spec §3.2, §4.2). A block
162/// whose language syntect does not know falls back to escaped `<pre><code>`.
163pub struct SyntectHighlighter {
164 syntaxes: &'static SyntaxSet,
165}
166
167impl SyntectHighlighter {
168 pub fn new() -> Self {
169 SyntectHighlighter {
170 syntaxes: syntax_set(),
171 }
172 }
173
174 /// A highlighter that also knows the `.sublime-syntax` files in `dir`, for languages
175 /// neither syntect nor this crate bundles.
176 pub fn with_syntaxes(dir: Option<&camino::Utf8Path>) -> Self {
177 match dir {
178 Some(dir) if dir.is_dir() => SyntectHighlighter {
179 syntaxes: syntax_set_with(dir),
180 },
181 _ => Self::new(),
182 }
183 }
184}
185
186/// Every language the highlighter recognises, for documentation and error messages.
187pub fn available_languages() -> Vec<&'static str> {
188 let mut names: Vec<&str> = syntax_set()
189 .syntaxes()
190 .iter()
191 .map(|s| s.name.as_str())
192 .collect();
193 names.sort_unstable();
194 names.dedup();
195 names
196}
197
198impl Default for SyntectHighlighter {
199 fn default() -> Self {
200 Self::new()
201 }
202}
203
204impl Highlighter for SyntectHighlighter {
205 fn highlight(&self, code: &str, lang: Option<&str>) -> Html {
206 let Some(syntax) = lang.and_then(|l| self.syntaxes.find_syntax_by_token(l)) else {
207 return Html(plain_code(code, lang));
208 };
209 let mut generator =
210 ClassedHTMLGenerator::new_with_class_style(syntax, self.syntaxes, CLASS_STYLE);
211 for line in LinesWithEndings::from(code) {
212 if generator
213 .parse_html_for_line_which_includes_newline(line)
214 .is_err()
215 {
216 return Html(plain_code(code, lang));
217 }
218 }
219 Html(format!(
220 "<pre><code class=\"{} highlight\">{}</code></pre>\n",
221 language_class(lang),
222 generator.finalize()
223 ))
224 }
225}
226
227fn plain_code(code: &str, lang: Option<&str>) -> String {
228 format!(
229 "<pre><code class=\"{}\">{}</code></pre>\n",
230 language_class(lang),
231 escape_html(code)
232 )
233}
234
235fn language_class(lang: Option<&str>) -> String {
236 match lang {
237 Some(l) => format!("language-{}", escape_attr(l)),
238 None => "language-none".to_string(),
239 }
240}
241
242/// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4).
243struct Renderer<'a> {
244 hl: &'a dyn Highlighter,
245 opts: RenderOptions,
246 /// Block footnote definitions, keyed by label (collected before the walk).
247 block_defs: HashMap<String, Vec<Element>>,
248 /// Inline footnote definitions discovered at reference sites.
249 inline_defs: HashMap<String, Vec<Object>>,
250 /// Reference keys in order of first appearance — drives numbering and note order.
251 order: Vec<String>,
252 /// Counter per heading depth, for section numbers.
253 counters: Vec<usize>,
254 /// Subtracted from every heading level so the document's shallowest heading renders
255 /// as level 1. Org exports levels *relative* to a file's own outline, so a file
256 /// written entirely under `**` is not a file of subsections.
257 headline_offset: u8,
258 /// Captioned figures seen so far, for `Figure N:`.
259 figures: usize,
260 /// Captioned tables seen so far, for `Table N:`.
261 tables: usize,
262}
263
264/// Options affecting how the tree becomes HTML. Presentation choices that belong to the
265/// site rather than to the document.
266#[derive(Debug, Clone, Copy)]
267pub struct RenderOptions {
268 /// Added to every heading's level, so a level-1 org heading can render as `<h2>`
269 /// beneath a page title supplied by the layout. See
270 /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset).
271 pub heading_offset: u8,
272 /// Prefix headings with `1.`, `1.1.`, … See
273 /// [`HtmlOutput::section_numbers`](crate::config::HtmlOutput::section_numbers).
274 pub section_numbers: bool,
275 /// Convert `--`, `---` and `...` in prose. See
276 /// [`HtmlOutput::special_strings`](crate::config::HtmlOutput::special_strings).
277 pub special_strings: bool,
278 /// Whether `x^2` and `a_{b}` become `<sup>`/`<sub>`. See
279 /// [`HtmlOutput::sub_superscript`](crate::config::HtmlOutput::sub_superscript).
280 pub sub_superscript: SubSuperscript,
281 /// Whether `\alpha` becomes `α`. See
282 /// [`HtmlOutput::entities`](crate::config::HtmlOutput::entities).
283 pub entities: bool,
284}
285
286impl Default for RenderOptions {
287 fn default() -> Self {
288 let html = crate::config::HtmlOutput::default();
289 RenderOptions {
290 heading_offset: html.heading_offset,
291 section_numbers: html.section_numbers,
292 special_strings: html.special_strings,
293 sub_superscript: html.sub_superscript,
294 entities: html.entities,
295 }
296 }
297}
298
299/// The site's render options with the document's own `#+OPTIONS:` applied on top.
300///
301/// Org's per-file switches are the author's override of a site-wide setting, and they
302/// belong here rather than at each call site — otherwise rendering one document two ways
303/// depends on which caller remembered to read its keywords.
304fn document_options(keywords: &crate::model::Keywords, opts: &RenderOptions) -> RenderOptions {
305 RenderOptions {
306 heading_offset: opts.heading_offset,
307 section_numbers: option_enabled(keywords, "num", opts.section_numbers),
308 special_strings: option_enabled(keywords, "-", opts.special_strings),
309 // `^:` has three values rather than two — `nil`, `{}` or on — so it is read
310 // directly instead of through the boolean helper.
311 sub_superscript: match export_options(keywords).get("^") {
312 Some(value) => SubSuperscript::from_option(value),
313 None => opts.sub_superscript,
314 },
315 entities: option_enabled(keywords, "e", opts.entities),
316 }
317}
318
319/// How much to subtract from every heading level, so a document's shallowest heading
320/// renders as level 1.
321///
322/// Org exports outline levels *relative to the file*: a document written entirely under
323/// `**` is a document of top-level sections that happen to be indented, not a document of
324/// subsections. Emacs computes this from the shallowest top-level headline, which is what
325/// makes the same subtree export identically whether it was cut from a larger file or
326/// written on its own.
327fn headline_offset(root: &Section) -> u8 {
328 root.children
329 .iter()
330 .filter_map(|s| s.heading.as_ref())
331 .map(|h| h.level)
332 .min()
333 .map(|min| min.saturating_sub(1))
334 .unwrap_or(0)
335}
336
337/// Render a resolved document to an HTML fragment, with default options.
338pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
339 render_with(doc, highlighter, &RenderOptions::default())
340}
341
342/// Render a resolved document to an HTML fragment.
343pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html {
344 let mut r = Renderer {
345 hl: highlighter,
346 opts: document_options(&doc.document.keywords, opts),
347 block_defs: HashMap::new(),
348 inline_defs: HashMap::new(),
349 order: Vec::new(),
350 counters: Vec::new(),
351 headline_offset: headline_offset(&doc.document.root),
352 figures: 0,
353 tables: 0,
354 };
355 r.collect_defs(&doc.document.root);
356 let mut out = String::new();
357 r.render_section(&doc.document.root, &mut out);
358 r.emit_footnotes(&mut out);
359 Html(out)
360}
361
362impl Renderer<'_> {
363 /// First footnote pass: gather every block definition in the tree by label.
364 fn collect_defs(&mut self, section: &Section) {
365 collect_defs_in(§ion.content, &mut self.block_defs);
366 for child in §ion.children {
367 self.collect_defs(child);
368 }
369 }
370
371 fn render_section(&mut self, section: &Section, out: &mut String) {
372 if let Some(h) = §ion.heading {
373 let relative = h.level.saturating_sub(self.headline_offset).max(1);
374 let level = relative.saturating_add(self.opts.heading_offset).clamp(1, 6);
375 let anchor = heading_anchor(h);
376 // A heading with no title text has no meaningful slug; emit no `id` at all
377 // rather than a run of duplicate empty ones.
378 if anchor.is_empty() {
379 out.push_str(&format!("<h{}>", level));
380 } else {
381 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor)));
382 }
383 if self.opts.section_numbers {
384 let number = self.next_section_number(relative);
385 out.push_str(&format!(
386 "<span class=\"section-number-{}\">{number}</span> ",
387 level
388 ));
389 }
390 // Keyword/priority markup mirrors Emacs' own HTML export classes, so output
391 // stays diffable against an `emacs --batch` oracle.
392 if let Some(todo) = &h.todo {
393 out.push_str(&format!(
394 "<span class=\"{} {}\">{}</span> ",
395 if todo.done { "done" } else { "todo" },
396 escape_attr(&todo.name),
397 escape_html(&todo.name)
398 ));
399 }
400 if let Some(priority) = h.priority {
401 out.push_str(&format!(
402 "<span class=\"priority\">[#{}]</span> ",
403 escape_html(&priority.to_string())
404 ));
405 }
406 self.render_objects(&h.title, out);
407 for tag in &h.tags {
408 out.push_str(&format!(" <span class=\"tag\">{}</span>", escape_html(tag)));
409 }
410 out.push_str(&format!("</h{}>\n", level));
411 }
412 for element in §ion.content {
413 self.render_element(element, out);
414 }
415 for child in §ion.children {
416 self.render_section(child, out);
417 }
418 }
419
420 /// The next section number at `level`, e.g. `1.`, `1.1.`, `2.`.
421 ///
422 /// Deeper levels reset when a shallower one advances, and a document that skips a
423 /// level (a `***` under a `*`) simply starts the missing levels at 1 rather than
424 /// being treated as malformed.
425 fn next_section_number(&mut self, level: u8) -> String {
426 let depth = usize::from(level).max(1);
427 self.counters.truncate(depth);
428 while self.counters.len() < depth {
429 self.counters.push(0);
430 }
431 self.counters[depth - 1] += 1;
432 let parts: Vec<String> = self.counters.iter().map(usize::to_string).collect();
433 format!("{}.", parts.join("."))
434 }
435
436 fn render_element(&mut self, element: &Element, out: &mut String) {
437 match element {
438 Element::Paragraph(objs) => {
439 out.push_str("<p>");
440 self.render_objects(objs, out);
441 out.push_str("</p>\n");
442 }
443 Element::List(list) => self.render_list(list, out),
444 Element::Table(table) => self.render_table(table, out),
445 Element::SrcBlock { lang, code, .. } => {
446 let Html(h) = self.hl.highlight(code, lang.as_deref());
447 out.push_str(&h);
448 }
449 Element::ExampleBlock(code) => {
450 out.push_str(&format!("<pre>{}</pre>\n", escape_html(code)));
451 }
452 Element::QuoteBlock(inner) => {
453 out.push_str("<blockquote>\n");
454 for el in inner {
455 self.render_element(el, out);
456 }
457 out.push_str("</blockquote>\n");
458 }
459 Element::CenterBlock(inner) => {
460 out.push_str("<div class=\"center\">\n");
461 for el in inner {
462 self.render_element(el, out);
463 }
464 out.push_str("</div>\n");
465 }
466 // An `html` export block is verbatim output by definition; every other
467 // backend is out of scope and drops (§"Not supported").
468 Element::ExportBlock { backend, raw } => {
469 if backend.eq_ignore_ascii_case("html") {
470 out.push_str(raw);
471 out.push('\n');
472 }
473 }
474 Element::Figure {
475 link,
476 caption,
477 attrs,
478 } => {
479 out.push_str("<figure>");
480 out.push_str(&image_tag(link, attrs, &plain_text(caption)));
481 if !caption.is_empty() {
482 self.figures += 1;
483 out.push_str(&format!(
484 "<figcaption><span class=\"figure-number\">Figure {}: </span>",
485 self.figures
486 ));
487 self.render_objects(caption, out);
488 out.push_str("</figcaption>");
489 }
490 out.push_str("</figure>\n");
491 }
492 Element::SpecialBlock { name, content } => {
493 out.push_str(&format!("<div class=\"{}\">\n", escape_attr(name)));
494 for child in content {
495 self.render_element(child, out);
496 }
497 out.push_str("</div>\n");
498 }
499 Element::VerseBlock(lines) => {
500 out.push_str("<p class=\"verse\">\n");
501 for (i, line) in lines.iter().enumerate() {
502 if i > 0 {
503 out.push_str("<br>\n");
504 }
505 self.render_objects(&crate::parser::inline(line), out);
506 }
507 out.push_str("\n</p>\n");
508 }
509 Element::HorizontalRule => out.push_str("<hr>\n"),
510 // Definitions are emitted in the footnotes section, not inline.
511 Element::FootnoteDefinition { .. } => {}
512 // Out of scope (generic drawers, stray keywords, comments): emitted as
513 // nothing rather than crashing.
514 Element::Drawer { .. } | Element::Keyword { .. } | Element::Comment(_) => {}
515 }
516 }
517
518 fn render_list(&mut self, list: &crate::model::List, out: &mut String) {
519 if list.kind == ListKind::Description {
520 out.push_str("<dl>\n");
521 for item in &list.items {
522 out.push_str("<dt>");
523 if let Some(term) = &item.term {
524 self.render_objects(term, out);
525 }
526 out.push_str("</dt>\n<dd>");
527 self.render_item_content(&item.content, out);
528 out.push_str("</dd>\n");
529 }
530 out.push_str("</dl>\n");
531 return;
532 }
533 let tag = if list.kind == ListKind::Ordered {
534 "ol"
535 } else {
536 "ul"
537 };
538 out.push_str(&format!("<{}>\n", tag));
539 for item in &list.items {
540 // Org writes a checkbox as literal text, which keeps the third state — `[-]`,
541 // partially done — that a disabled <input> cannot express. The state goes on
542 // the item, where it can style the whole line.
543 let class = item.checkbox.as_ref().map(|cb| match cb {
544 Checkbox::On => "on",
545 Checkbox::Off => "off",
546 Checkbox::Trans => "trans",
547 });
548 out.push_str("<li");
549 if let Some(class) = class {
550 out.push_str(&format!(" class=\"{class}\""));
551 }
552 // `[@4]` restarts the numbering, and HTML says so with `value`.
553 if let Some(n) = item.counter {
554 out.push_str(&format!(" value=\"{n}\""));
555 }
556 out.push('>');
557 if let Some(cb) = &item.checkbox {
558 out.push_str(match cb {
559 Checkbox::On => "<code>[X]</code> ",
560 Checkbox::Off => "<code>[ ]</code> ",
561 Checkbox::Trans => "<code>[-]</code> ",
562 });
563 }
564 self.render_item_content(&item.content, out);
565 out.push_str("</li>\n");
566 }
567 out.push_str(&format!("</{}>\n", tag));
568 }
569
570 /// A single-paragraph item renders its text bare — `<li>text<ul>…` rather than
571 /// `<li><p>text</p><ul>…` — which is what org does and what makes a nested list read
572 /// as a continuation of its parent item. An item holding *several* paragraphs wraps
573 /// them all, so they do not run together.
574 fn render_item_content(&mut self, content: &[Element], out: &mut String) {
575 let lead_is_bare = matches!(content.first(), Some(Element::Paragraph(_)))
576 && !content[1..]
577 .iter()
578 .any(|el| matches!(el, Element::Paragraph(_)));
579 let mut rest = content;
580 if lead_is_bare {
581 if let Some((Element::Paragraph(objs), tail)) = content.split_first() {
582 self.render_objects(objs, out);
583 rest = tail;
584 }
585 }
586 for el in rest {
587 self.render_element(el, out);
588 }
589 }
590
591 /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`.
592 fn render_table(&mut self, table: &crate::model::Table, out: &mut String) {
593 let table = strip_special_column(table);
594 let table = &table;
595 let rule_at = table
596 .rows
597 .iter()
598 .position(|r| matches!(r, TableRow::Rule));
599 out.push_str("<table>\n");
600 if !table.caption.is_empty() {
601 self.tables += 1;
602 out.push_str(&format!(
603 "<caption><span class=\"table-number\">Table {}: </span>",
604 self.tables
605 ));
606 let caption = table.caption.clone();
607 self.render_objects(&caption, out);
608 out.push_str("</caption>\n");
609 }
610 let mut wrote_body = false;
611 let mut in_body = rule_at.is_none();
612 for (idx, row) in table.rows.iter().enumerate() {
613 match row {
614 TableRow::Rule => {
615 if Some(idx) == rule_at {
616 in_body = true;
617 }
618 continue;
619 }
620 TableRow::Cells(cells) => {
621 let (open, cell_tag) = if in_body {
622 if !wrote_body {
623 wrote_body = true;
624 ("<tbody>\n<tr>", "td")
625 } else {
626 ("<tr>", "td")
627 }
628 } else {
629 ("<thead>\n<tr>", "th")
630 };
631 out.push_str(open);
632 for cell in cells {
633 out.push_str(&format!("<{}>", cell_tag));
634 self.render_objects(cell, out);
635 out.push_str(&format!("</{}>", cell_tag));
636 }
637 out.push_str("</tr>\n");
638 // Close the header band right after its last row.
639 if !in_body
640 && rule_at.map(|r| idx + 1 == r).unwrap_or(false)
641 {
642 out.push_str("</thead>\n");
643 }
644 }
645 }
646 }
647 if wrote_body {
648 out.push_str("</tbody>\n");
649 }
650 out.push_str("</table>\n");
651 }
652
653 /// Plain text as HTML: escaped, then org's export-time text conversions.
654 ///
655 /// Both run on the *escaped* string so their output tags survive, and both are
656 /// reachable only from [`Object::Text`] — verbatim, code and source blocks are
657 /// different objects, which is what keeps `--verbose` in a shell transcript intact.
658 fn text_html(&self, t: &str) -> String {
659 let escaped = escape_html(t);
660 let mut out = String::with_capacity(escaped.len());
661 // LaTeX is passed through untouched — `$x^2$` is math for a typesetter, not a
662 // superscript for us, and an em dash inside a formula is not what was meant.
663 for (span, is_latex) in latex_split(&escaped) {
664 if is_latex {
665 out.push_str(span);
666 continue;
667 }
668 let with_strings = if self.opts.special_strings {
669 special_strings(span)
670 } else {
671 span.to_string()
672 };
673 out.push_str(&sub_superscript(&with_strings, self.opts.sub_superscript));
674 }
675 out
676 }
677
678 fn render_objects(&mut self, objs: &[Object], out: &mut String) {
679 for obj in objs {
680 self.render_object(obj, out);
681 }
682 }
683
684 fn render_object(&mut self, obj: &Object, out: &mut String) {
685 match obj {
686 Object::Text(t) => out.push_str(&self.text_html(t)),
687 // The table's HTML column is an entity reference, so it is emitted raw.
688 Object::Entity(name) => match self.opts.entities {
689 true => out.push_str(crate::entities::lookup(name).unwrap_or(name)),
690 false => out.push_str(&escape_html(&format!("\\{name}"))),
691 },
692 Object::Bold(inner) => self.wrap(out, "strong", inner),
693 Object::Italic(inner) => self.wrap(out, "em", inner),
694 Object::Underline(inner) => self.wrap(out, "u", inner),
695 Object::StrikeThrough(inner) => self.wrap(out, "del", inner),
696 Object::Verbatim(s) => {
697 out.push_str(&format!("<code class=\"verbatim\">{}</code>", escape_html(s)))
698 }
699 Object::Code(s) => out.push_str(&format!("<code>{}</code>", escape_html(s))),
700 // A description-less link to an image is the image itself, not a link to it.
701 Object::Link(link) if link.description.is_none() && is_image_target(&link.target) => {
702 out.push_str(&image_tag(link, "", ""));
703 }
704 Object::Link(link) => {
705 let href = link_href(&link.target);
706 out.push_str(&format!("<a href=\"{}\">", escape_attr(&href)));
707 match &link.description {
708 Some(desc) => self.render_objects(desc, out),
709 None => out.push_str(&escape_html(&link_text(&link.target))),
710 }
711 out.push_str("</a>");
712 }
713 Object::FootnoteRef { label, inline } => {
714 let key = if label.is_empty() {
715 format!("__anon{}", self.order.len() + 1)
716 } else {
717 label.clone()
718 };
719 if let Some(objs) = inline {
720 self.inline_defs.insert(key.clone(), objs.clone());
721 }
722 if !self.order.contains(&key) {
723 self.order.push(key.clone());
724 }
725 let num = self.order.iter().position(|l| l == &key).unwrap() + 1;
726 out.push_str(&format!(
727 "<sup class=\"footnote-ref\"><a id=\"fnr-{n}\" href=\"#fn-{n}\">{n}</a></sup>",
728 n = num
729 ));
730 }
731 Object::LineBreak => out.push_str("<br>\n"),
732 Object::Timestamp(ts) => out.push_str(×tamp_html(ts)),
733 }
734 }
735
736 fn wrap(&mut self, out: &mut String, tag: &str, inner: &[Object]) {
737 out.push_str(&format!("<{}>", tag));
738 self.render_objects(inner, out);
739 out.push_str(&format!("</{}>", tag));
740 }
741
742 /// Second footnote pass: emit the numbered, back-linked notes section (spec §2.4).
743 fn emit_footnotes(&mut self, out: &mut String) {
744 if self.order.is_empty() {
745 return;
746 }
747 let order = self.order.clone();
748 let inline_defs = self.inline_defs.clone();
749 let block_defs = self.block_defs.clone();
750 // Named, because a `<hr>` is a picture of a section break rather than a section:
751 // without the label this landmark is announced as "section" and the reader has to
752 // guess what they have reached.
753 out.push_str(
754 "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n",
755 );
756 for (idx, label) in order.iter().enumerate() {
757 let n = idx + 1;
758 out.push_str(&format!("<li id=\"fn-{n}\">"));
759 if let Some(objs) = inline_defs.get(label) {
760 self.render_objects(objs, out);
761 } else if let Some(els) = block_defs.get(label) {
762 for el in els {
763 self.render_element(el, out);
764 }
765 }
766 // The glyph is the whole visible link, so it is also the whole accessible
767 // name: a screen reader would otherwise announce "left arrow with hook",
768 // identically, once per note.
769 out.push_str(&format!(
770 " <a class=\"footnote-back\" href=\"#fnr-{n}\" \
771 aria-label=\"Back to reference {n}\">↩</a></li>\n"
772 ));
773 }
774 out.push_str("</ol>\n</section>\n");
775 }
776}
777
778fn collect_defs_in(elements: &[Element], defs: &mut HashMap<String, Vec<Element>>) {
779 for el in elements {
780 if let Element::FootnoteDefinition { label, content } = el {
781 defs.entry(label.clone()).or_insert_with(|| content.clone());
782 }
783 }
784}
785
786/// An `<img>` for an image link, carrying any `#+ATTR_HTML:` attributes and falling back
787/// to the caption for alt text — but only when the author did not write an `:alt` of
788/// their own, since two `alt` attributes on one tag is invalid HTML.
789fn image_tag(link: &Link, attrs: &str, alt: &str) -> String {
790 let mut pairs = attr_html(attrs);
791 if !pairs.iter().any(|(k, _)| k.eq_ignore_ascii_case("alt")) {
792 pairs.insert(0, ("alt".to_string(), alt.to_string()));
793 }
794 let attributes: String = pairs
795 .iter()
796 .map(|(k, v)| format!(" {}=\"{}\"", escape_attr(k), escape_attr(v)))
797 .collect();
798 format!(
799 "<img src=\"{}\"{}>",
800 escape_attr(&link_href(&link.target)),
801 attributes
802 )
803}
804
805/// `#+ATTR_HTML: :width 400 :class hero` → `[(width, 400), (class, hero)]`. Values run to
806/// the next `:key` token and may be double-quoted to include spaces. A malformed spec
807/// contributes nothing rather than emitting broken markup.
808fn attr_html(spec: &str) -> Vec<(String, String)> {
809 let mut out = Vec::new();
810 let mut key: Option<&str> = None;
811 let mut value = String::new();
812 let mut quoted: Option<String> = None;
813
814 let flush = |out: &mut Vec<(String, String)>, key: &mut Option<&str>, value: &mut String| {
815 if let Some(k) = key.take() {
816 out.push((k.to_string(), value.trim().to_string()));
817 }
818 value.clear();
819 };
820
821 for token in spec.split_whitespace() {
822 // Inside a quoted value, everything up to the closing quote is literal.
823 if let Some(buf) = &mut quoted {
824 buf.push(' ');
825 buf.push_str(token.trim_end_matches('"'));
826 if token.ends_with('"') {
827 value = quoted.take().expect("quoted value in progress");
828 }
829 continue;
830 }
831 if let Some(k) = token.strip_prefix(':') {
832 flush(&mut out, &mut key, &mut value);
833 if !k.is_empty() {
834 key = Some(k);
835 }
836 continue;
837 }
838 if key.is_none() {
839 continue;
840 }
841 if let Some(rest) = token.strip_prefix('"') {
842 if let Some(inner) = rest.strip_suffix('"') {
843 value = inner.to_string();
844 } else {
845 quoted = Some(rest.to_string());
846 }
847 continue;
848 }
849 if !value.is_empty() {
850 value.push(' ');
851 }
852 value.push_str(token);
853 }
854 if let Some(buf) = quoted {
855 value = buf;
856 }
857 flush(&mut out, &mut key, &mut value);
858 out
859}
860
861/// `<time>` markup for a timestamp. A range emits both endpoints; a same-day range
862/// abbreviates its end to just the time.
863fn timestamp_html(ts: &crate::model::Timestamp) -> String {
864 let class = if ts.active {
865 "timestamp"
866 } else {
867 "timestamp inactive"
868 };
869 let one = |dt: &chrono::NaiveDateTime, text: String| {
870 let attr = if ts.has_time {
871 dt.format("%Y-%m-%dT%H:%M").to_string()
872 } else {
873 dt.format("%Y-%m-%d").to_string()
874 };
875 format!(
876 "<time class=\"{class}\" datetime=\"{}\">{}</time>",
877 escape_attr(&attr),
878 escape_html(&text)
879 )
880 };
881 let text_of = |dt: &chrono::NaiveDateTime| {
882 if ts.has_time {
883 dt.format("%Y-%m-%d %H:%M").to_string()
884 } else {
885 dt.format("%Y-%m-%d").to_string()
886 }
887 };
888
889 let mut out = one(&ts.start, text_of(&ts.start));
890 if let Some(end) = &ts.end {
891 out.push_str("–");
892 let text = if end.date() == ts.start.date() && ts.has_time {
893 end.format("%H:%M").to_string()
894 } else {
895 text_of(end)
896 };
897 out.push_str(&one(end, text));
898 }
899 out
900}
901
902/// Best-effort URL for a link target. After RESOLVE, internal targets have been
903/// rewritten to `External` with their final URL; anything still internal here is an
904/// unresolved link, rendered to a plausible anchor so the page stays self-consistent.
905fn link_href(target: &LinkTarget) -> String {
906 match target {
907 LinkTarget::External(s) => s.clone(),
908 LinkTarget::CustomId(id) => format!("#{}", id),
909 LinkTarget::Id(id) => format!("#{}", id),
910 LinkTarget::Heading(text) => format!("#{}", slugify(text)),
911 LinkTarget::File { path, .. } => path.to_string(),
912 }
913}
914
915fn link_text(target: &LinkTarget) -> String {
916 match target {
917 LinkTarget::External(s) => s.clone(),
918 LinkTarget::CustomId(id) | LinkTarget::Id(id) => id.clone(),
919 LinkTarget::Heading(text) => text.clone(),
920 LinkTarget::File { path, .. } => path.to_string(),
921 }
922}
923
924/// Drop org's special column and its marker rows.
925///
926/// A table's first column may hold export markers rather than data — `/` marks a column
927/// group, `#` a row to recalculate, `!` a row of names. Rows marked `/ ! ^ _ $` are
928/// instructions to org and never appear in the output; the column itself disappears when
929/// *every* row uses it that way, which is what stops a table of formulas from publishing
930/// with a stray column of hashes.
931fn strip_special_column(table: &crate::model::Table) -> crate::model::Table {
932 let first_cell = |row: &TableRow| -> Option<String> {
933 match row {
934 TableRow::Cells(cells) => Some(plain_text(cells.first()?).trim().to_string()),
935 TableRow::Rule => None,
936 }
937 };
938 let data_rows = || table.rows.iter().filter(|r| matches!(r, TableRow::Cells(_)));
939 let column_is_special = data_rows().count() > 0
940 && data_rows().all(|row| {
941 matches!(
942 first_cell(row).as_deref(),
943 Some("" | "/" | "#" | "!" | "^" | "_" | "$" | "*")
944 )
945 });
946
947 let rows = table
948 .rows
949 .iter()
950 .filter(|row| {
951 // A marker row is an instruction, not content.
952 !matches!(
953 first_cell(row).as_deref(),
954 Some("/" | "!" | "^" | "_" | "$")
955 )
956 })
957 .map(|row| match (row, column_is_special) {
958 (TableRow::Cells(cells), true) => TableRow::Cells(cells[1.min(cells.len())..].to_vec()),
959 _ => row.clone(),
960 })
961 .collect();
962 crate::model::Table {
963 rows,
964 caption: table.caption.clone(),
965 }
966}
967
968/// Split text into alternating prose and LaTeX spans, `(text, is_latex)`.
969///
970/// orgo does not typeset LaTeX — it passes it through for MathJax or a reader's eyes —
971/// but it must know where a fragment *is*, because the export-time text conversions would
972/// otherwise rewrite the mathematics: `x^2` inside `$…$` is not a superscript to be
973/// marked up, and `--` inside one is a minus sign twice.
974fn latex_split(s: &str) -> Vec<(&str, bool)> {
975 let bytes = s.as_bytes();
976 let mut spans = Vec::new();
977 let mut plain_from = 0;
978 let mut i = 0;
979 while i < s.len() {
980 if !s.is_char_boundary(i) {
981 i += 1;
982 continue;
983 }
984 let end = match bytes[i] {
985 b'\\' => latex_backslash_end(s, i),
986 b'$' => latex_dollar_end(s, i),
987 _ => None,
988 };
989 if let Some(end) = end {
990 if plain_from < i {
991 spans.push((&s[plain_from..i], false));
992 }
993 spans.push((&s[i..end], true));
994 plain_from = end;
995 i = end;
996 continue;
997 }
998 i += 1;
999 }
1000 if plain_from < s.len() {
1001 spans.push((&s[plain_from..], false));
1002 }
1003 spans
1004}
1005
1006/// End of a `\(…\)`, `\[…\]` or `\begin{env}…\end{env}` fragment starting at `i`.
1007fn latex_backslash_end(s: &str, i: usize) -> Option<usize> {
1008 let rest = &s[i..];
1009 for (open, close) in [("\\(", "\\)"), ("\\[", "\\]")] {
1010 if let Some(body) = rest.strip_prefix(open) {
1011 return body.find(close).map(|k| i + open.len() + k + close.len());
1012 }
1013 }
1014 let after = rest.strip_prefix("\\begin{")?;
1015 let name_end = after.find('}')?;
1016 let end_tag = format!("\\end{{{}}}", &after[..name_end]);
1017 let k = rest.find(&end_tag)?;
1018 Some(i + k + end_tag.len())
1019}
1020
1021/// End of a `$…$` fragment starting at `i`, or `None` when this `$` is just a dollar sign.
1022///
1023/// The body may not begin or end with whitespace, which is what keeps "it cost $5 or $6"
1024/// out — the same heuristic org uses, and the same one that means a sentence with two
1025/// unrelated dollar amounts and no space between them will be read as math.
1026fn latex_dollar_end(s: &str, i: usize) -> Option<usize> {
1027 let body = &s[i + 1..];
1028 let close = body.find('$')?;
1029 if close == 0 {
1030 return None;
1031 }
1032 let inner = &body[..close];
1033 if inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace) {
1034 return None;
1035 }
1036 if inner.contains('\n') {
1037 return None;
1038 }
1039 Some(i + 1 + close + 1)
1040}
1041
1042/// Org's special strings: `--` becomes an en dash, `---` an em dash, `...` an ellipsis,
1043/// and `\-` a soft hyphen.
1044///
1045/// A dash run only converts when a non-dash follows it, matching Emacs — so a `---` at the
1046/// very end of a line, or the `-----` someone drew as a rule, is left alone.
1047fn special_strings(s: &str) -> String {
1048 let chars: Vec<char> = s.chars().collect();
1049 let mut out = String::with_capacity(s.len());
1050 let mut i = 0;
1051 while i < chars.len() {
1052 let rest = &chars[i..];
1053 let followed_by_dash = |n: usize| matches!(rest.get(n), Some('-') | None);
1054 if rest.starts_with(&['\\', '-']) {
1055 out.push('\u{00ad}');
1056 i += 2;
1057 } else if rest.starts_with(&['-', '-', '-']) && !followed_by_dash(3) {
1058 out.push('\u{2014}');
1059 i += 3;
1060 } else if rest.starts_with(&['-', '-']) && !followed_by_dash(2) {
1061 out.push('\u{2013}');
1062 i += 2;
1063 } else if rest.starts_with(&['.', '.', '.']) {
1064 out.push('\u{2026}');
1065 i += 3;
1066 } else {
1067 out.push(chars[i]);
1068 i += 1;
1069 }
1070 }
1071 out
1072}
1073
1074/// Org's `_` and `^` conversions: `H_{2}O`, `x^2`.
1075///
1076/// Both require a non-whitespace character before the marker, which is what separates a
1077/// subscript from `_underlined text_` — the parser has already taken the emphasis, since
1078/// that form requires whitespace *before* the marker.
1079fn sub_superscript(s: &str, mode: SubSuperscript) -> String {
1080 if mode == SubSuperscript::No || !s.contains(['_', '^']) {
1081 return s.to_string();
1082 }
1083 let chars: Vec<char> = s.chars().collect();
1084 let mut out = String::with_capacity(s.len());
1085 let mut i = 0;
1086 while i < chars.len() {
1087 let c = chars[i];
1088 let prev_ok = i > 0 && !chars[i - 1].is_whitespace();
1089 if (c == '_' || c == '^') && prev_ok {
1090 if let Some((body, next)) = script_body(&chars, i + 1, mode) {
1091 let tag = if c == '_' { "sub" } else { "sup" };
1092 out.push_str(&format!("<{tag}>{body}</{tag}>"));
1093 i = next;
1094 continue;
1095 }
1096 }
1097 out.push(c);
1098 i += 1;
1099 }
1100 out
1101}
1102
1103/// The scripted text after a `_`/`^`: either `{...}`, or — unless braces are required —
1104/// a run of alphanumerics ending in one, so `x^2` and `a_b1` convert but `a_ ` does not.
1105fn script_body(chars: &[char], start: usize, mode: SubSuperscript) -> Option<(String, usize)> {
1106 if chars.get(start) == Some(&'{') {
1107 let mut depth = 1;
1108 let mut j = start + 1;
1109 while j < chars.len() {
1110 match chars[j] {
1111 '{' => depth += 1,
1112 '}' => {
1113 depth -= 1;
1114 if depth == 0 {
1115 return Some((chars[start + 1..j].iter().collect(), j + 1));
1116 }
1117 }
1118 _ => {}
1119 }
1120 j += 1;
1121 }
1122 return None;
1123 }
1124 if mode == SubSuperscript::Braces {
1125 return None;
1126 }
1127 let mut j = start;
1128 if matches!(chars.get(j), Some('+') | Some('-')) {
1129 j += 1;
1130 }
1131 let mut last_alnum = None;
1132 while let Some(&c) = chars.get(j) {
1133 if c.is_alphanumeric() {
1134 last_alnum = Some(j);
1135 } else if !matches!(c, '.' | ',' | '\\') {
1136 break;
1137 }
1138 j += 1;
1139 }
1140 let end = last_alnum? + 1;
1141 Some((chars[start..end].iter().collect(), end))
1142}
1143
1144fn escape_html(s: &str) -> String {
1145 let mut out = String::with_capacity(s.len());
1146 for c in s.chars() {
1147 match c {
1148 '&' => out.push_str("&"),
1149 '<' => out.push_str("<"),
1150 '>' => out.push_str(">"),
1151 _ => out.push(c),
1152 }
1153 }
1154 out
1155}
1156
1157fn escape_attr(s: &str) -> String {
1158 let mut out = escape_html(s);
1159 out = out.replace('"', """);
1160 out
1161}