src/render.rs
1185 lines · 46033 bytes
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 self.render_wrapped("<blockquote>\n", inner, "</blockquote>\n", out);
454 }
455 Element::CenterBlock(inner) => {
456 self.render_wrapped("<div class=\"center\">\n", inner, "</div>\n", out);
457 }
458 // An `html` export block is verbatim output by definition; every other
459 // backend is out of scope and drops (§"Not supported").
460 Element::ExportBlock { backend, raw } => {
461 if backend.eq_ignore_ascii_case("html") {
462 out.push_str(raw);
463 out.push('\n');
464 }
465 }
466 Element::Figure {
467 link,
468 caption,
469 attrs,
470 } => self.render_figure(link, caption, attrs, out),
471 Element::SpecialBlock { name, content } => {
472 let open = format!("<div class=\"{}\">\n", escape_attr(name));
473 self.render_wrapped(&open, content, "</div>\n", out);
474 }
475 Element::VerseBlock(lines) => self.render_verse(lines, out),
476 Element::HorizontalRule => out.push_str("<hr>\n"),
477 // Definitions are emitted in the footnotes section, not inline.
478 Element::FootnoteDefinition { .. } => {}
479 // Out of scope (generic drawers, stray keywords, comments): emitted as
480 // nothing rather than crashing.
481 Element::Drawer { .. } | Element::Keyword { .. } | Element::Comment(_) => {}
482 }
483 }
484
485 fn render_wrapped(&mut self, open: &str, inner: &[Element], close: &str, out: &mut String) {
486 out.push_str(open);
487 for el in inner {
488 self.render_element(el, out);
489 }
490 out.push_str(close);
491 }
492
493 fn render_figure(
494 &mut self,
495 link: &Link,
496 caption: &[Object],
497 attrs: &str,
498 out: &mut String,
499 ) {
500 out.push_str("<figure>");
501 out.push_str(&image_tag(link, attrs, &plain_text(caption)));
502 if !caption.is_empty() {
503 self.figures += 1;
504 out.push_str(&format!(
505 "<figcaption><span class=\"figure-number\">Figure {}: </span>",
506 self.figures
507 ));
508 self.render_objects(caption, out);
509 out.push_str("</figcaption>");
510 }
511 out.push_str("</figure>\n");
512 }
513
514 fn render_verse(&mut self, lines: &[String], out: &mut String) {
515 out.push_str("<p class=\"verse\">\n");
516 for (i, line) in lines.iter().enumerate() {
517 if i > 0 {
518 out.push_str("<br>\n");
519 }
520 self.render_objects(&crate::parser::inline(line), out);
521 }
522 out.push_str("\n</p>\n");
523 }
524
525 fn render_list(&mut self, list: &crate::model::List, out: &mut String) {
526 if list.kind == ListKind::Description {
527 self.render_description_list(list, out);
528 return;
529 }
530 let tag = if list.kind == ListKind::Ordered {
531 "ol"
532 } else {
533 "ul"
534 };
535 out.push_str(&format!("<{}>\n", tag));
536 for item in &list.items {
537 self.render_list_item(item, out);
538 }
539 out.push_str(&format!("</{}>\n", tag));
540 }
541
542 fn render_description_list(&mut self, list: &crate::model::List, out: &mut String) {
543 out.push_str("<dl>\n");
544 for item in &list.items {
545 out.push_str("<dt>");
546 if let Some(term) = &item.term {
547 self.render_objects(term, out);
548 }
549 out.push_str("</dt>\n<dd>");
550 self.render_item_content(&item.content, out);
551 out.push_str("</dd>\n");
552 }
553 out.push_str("</dl>\n");
554 }
555
556 fn render_list_item(&mut self, item: &crate::model::ListItem, out: &mut String) {
557 // Org writes a checkbox as literal text, which keeps the third state — `[-]`,
558 // partially done — that a disabled <input> cannot express. The state goes on
559 // the item, where it can style the whole line.
560 let class = item.checkbox.as_ref().map(|cb| match cb {
561 Checkbox::On => "on",
562 Checkbox::Off => "off",
563 Checkbox::Trans => "trans",
564 });
565 out.push_str("<li");
566 if let Some(class) = class {
567 out.push_str(&format!(" class=\"{class}\""));
568 }
569 // `[@4]` restarts the numbering, and HTML says so with `value`.
570 if let Some(n) = item.counter {
571 out.push_str(&format!(" value=\"{n}\""));
572 }
573 out.push('>');
574 if let Some(cb) = &item.checkbox {
575 out.push_str(match cb {
576 Checkbox::On => "<code>[X]</code> ",
577 Checkbox::Off => "<code>[ ]</code> ",
578 Checkbox::Trans => "<code>[-]</code> ",
579 });
580 }
581 self.render_item_content(&item.content, out);
582 out.push_str("</li>\n");
583 }
584
585 /// A single-paragraph item renders its text bare — `<li>text<ul>…` rather than
586 /// `<li><p>text</p><ul>…` — which is what org does and what makes a nested list read
587 /// as a continuation of its parent item. An item holding *several* paragraphs wraps
588 /// them all, so they do not run together.
589 fn render_item_content(&mut self, content: &[Element], out: &mut String) {
590 let lead_is_bare = matches!(content.first(), Some(Element::Paragraph(_)))
591 && !content[1..]
592 .iter()
593 .any(|el| matches!(el, Element::Paragraph(_)));
594 let mut rest = content;
595 if lead_is_bare {
596 if let Some((Element::Paragraph(objs), tail)) = content.split_first() {
597 self.render_objects(objs, out);
598 rest = tail;
599 }
600 }
601 for el in rest {
602 self.render_element(el, out);
603 }
604 }
605
606 /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`.
607 fn render_table(&mut self, table: &crate::model::Table, out: &mut String) {
608 let table = strip_special_column(table);
609 let table = &table;
610 let rule_at = table
611 .rows
612 .iter()
613 .position(|r| matches!(r, TableRow::Rule));
614 out.push_str("<table>\n");
615 if !table.caption.is_empty() {
616 self.render_table_caption(&table.caption, out);
617 }
618 let (head, body) = match rule_at {
619 Some(r) => (&table.rows[..r], &table.rows[r + 1..]),
620 None => (&table.rows[..0], &table.rows[..]),
621 };
622 self.render_band("thead", "th", head, out);
623 self.render_band("tbody", "td", body, out);
624 out.push_str("</table>\n");
625 }
626
627 fn render_table_caption(&mut self, caption: &[Object], out: &mut String) {
628 self.tables += 1;
629 out.push_str(&format!(
630 "<caption><span class=\"table-number\">Table {}: </span>",
631 self.tables
632 ));
633 self.render_objects(caption, out);
634 out.push_str("</caption>\n");
635 }
636
637 /// One `<thead>` or `<tbody>` of `rows`, skipping rule rows. Nothing at all when
638 /// there are no cell rows.
639 fn render_band(&mut self, band: &str, cell_tag: &str, rows: &[TableRow], out: &mut String) {
640 let mut opened = false;
641 for row in rows {
642 let TableRow::Cells(cells) = row else { continue };
643 if !opened {
644 out.push_str(&format!("<{band}>\n"));
645 opened = true;
646 }
647 out.push_str("<tr>");
648 for cell in cells {
649 out.push_str(&format!("<{}>", cell_tag));
650 self.render_objects(cell, out);
651 out.push_str(&format!("</{}>", cell_tag));
652 }
653 out.push_str("</tr>\n");
654 }
655 if opened {
656 out.push_str(&format!("</{band}>\n"));
657 }
658 }
659
660 /// Plain text as HTML: escaped, then org's export-time text conversions.
661 ///
662 /// Both run on the *escaped* string so their output tags survive, and both are
663 /// reachable only from [`Object::Text`] — verbatim, code and source blocks are
664 /// different objects, which is what keeps `--verbose` in a shell transcript intact.
665 fn text_html(&self, t: &str) -> String {
666 let escaped = escape_html(t);
667 let mut out = String::with_capacity(escaped.len());
668 // LaTeX is passed through untouched — `$x^2$` is math for a typesetter, not a
669 // superscript for us, and an em dash inside a formula is not what was meant.
670 for (span, is_latex) in latex_split(&escaped) {
671 if is_latex {
672 out.push_str(span);
673 continue;
674 }
675 let with_strings = if self.opts.special_strings {
676 special_strings(span)
677 } else {
678 span.to_string()
679 };
680 out.push_str(&sub_superscript(&with_strings, self.opts.sub_superscript));
681 }
682 out
683 }
684
685 fn render_objects(&mut self, objs: &[Object], out: &mut String) {
686 for obj in objs {
687 self.render_object(obj, out);
688 }
689 }
690
691 fn render_object(&mut self, obj: &Object, out: &mut String) {
692 match obj {
693 Object::Text(t) => out.push_str(&self.text_html(t)),
694 // The table's HTML column is an entity reference, so it is emitted raw.
695 Object::Entity(name) => match self.opts.entities {
696 true => out.push_str(crate::entities::lookup(name).unwrap_or(name)),
697 false => out.push_str(&escape_html(&format!("\\{name}"))),
698 },
699 Object::Bold(inner) => self.wrap(out, "strong", inner),
700 Object::Italic(inner) => self.wrap(out, "em", inner),
701 Object::Underline(inner) => self.wrap(out, "u", inner),
702 Object::StrikeThrough(inner) => self.wrap(out, "del", inner),
703 Object::Verbatim(s) => {
704 out.push_str(&format!("<code class=\"verbatim\">{}</code>", escape_html(s)))
705 }
706 Object::Code(s) => out.push_str(&format!("<code>{}</code>", escape_html(s))),
707 // A description-less link to an image is the image itself, not a link to it.
708 Object::Link(link) if link.description.is_none() && is_image_target(&link.target) => {
709 out.push_str(&image_tag(link, "", ""));
710 }
711 Object::Link(link) => {
712 let href = link_href(&link.target);
713 out.push_str(&format!("<a href=\"{}\">", escape_attr(&href)));
714 match &link.description {
715 Some(desc) => self.render_objects(desc, out),
716 None => out.push_str(&escape_html(&link_text(&link.target))),
717 }
718 out.push_str("</a>");
719 }
720 Object::FootnoteRef { label, inline } => {
721 let key = if label.is_empty() {
722 format!("__anon{}", self.order.len() + 1)
723 } else {
724 label.clone()
725 };
726 if let Some(objs) = inline {
727 self.inline_defs.insert(key.clone(), objs.clone());
728 }
729 if !self.order.contains(&key) {
730 self.order.push(key.clone());
731 }
732 let num = self.order.iter().position(|l| l == &key).unwrap() + 1;
733 out.push_str(&format!(
734 "<sup class=\"footnote-ref\"><a id=\"fnr-{n}\" href=\"#fn-{n}\">{n}</a></sup>",
735 n = num
736 ));
737 }
738 Object::LineBreak => out.push_str("<br>\n"),
739 Object::Timestamp(ts) => out.push_str(×tamp_html(ts)),
740 }
741 }
742
743 fn wrap(&mut self, out: &mut String, tag: &str, inner: &[Object]) {
744 out.push_str(&format!("<{}>", tag));
745 self.render_objects(inner, out);
746 out.push_str(&format!("</{}>", tag));
747 }
748
749 /// Second footnote pass: emit the numbered, back-linked notes section (spec §2.4).
750 fn emit_footnotes(&mut self, out: &mut String) {
751 if self.order.is_empty() {
752 return;
753 }
754 let order = self.order.clone();
755 let inline_defs = self.inline_defs.clone();
756 let block_defs = self.block_defs.clone();
757 // Named, because a `<hr>` is a picture of a section break rather than a section:
758 // without the label this landmark is announced as "section" and the reader has to
759 // guess what they have reached.
760 out.push_str(
761 "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n",
762 );
763 for (idx, label) in order.iter().enumerate() {
764 let n = idx + 1;
765 out.push_str(&format!("<li id=\"fn-{n}\">"));
766 if let Some(objs) = inline_defs.get(label) {
767 self.render_objects(objs, out);
768 } else if let Some(els) = block_defs.get(label) {
769 for el in els {
770 self.render_element(el, out);
771 }
772 }
773 // The glyph is the whole visible link, so it is also the whole accessible
774 // name: a screen reader would otherwise announce "left arrow with hook",
775 // identically, once per note.
776 out.push_str(&format!(
777 " <a class=\"footnote-back\" href=\"#fnr-{n}\" \
778 aria-label=\"Back to reference {n}\">↩</a></li>\n"
779 ));
780 }
781 out.push_str("</ol>\n</section>\n");
782 }
783}
784
785fn collect_defs_in(elements: &[Element], defs: &mut HashMap<String, Vec<Element>>) {
786 for el in elements {
787 if let Element::FootnoteDefinition { label, content } = el {
788 defs.entry(label.clone()).or_insert_with(|| content.clone());
789 }
790 }
791}
792
793/// An `<img>` for an image link, carrying any `#+ATTR_HTML:` attributes and falling back
794/// to the caption for alt text — but only when the author did not write an `:alt` of
795/// their own, since two `alt` attributes on one tag is invalid HTML.
796fn image_tag(link: &Link, attrs: &str, alt: &str) -> String {
797 let mut pairs = attr_html(attrs);
798 if !pairs.iter().any(|(k, _)| k.eq_ignore_ascii_case("alt")) {
799 pairs.insert(0, ("alt".to_string(), alt.to_string()));
800 }
801 let attributes: String = pairs
802 .iter()
803 .map(|(k, v)| format!(" {}=\"{}\"", escape_attr(k), escape_attr(v)))
804 .collect();
805 format!(
806 "<img src=\"{}\"{}>",
807 escape_attr(&link_href(&link.target)),
808 attributes
809 )
810}
811
812/// `#+ATTR_HTML: :width 400 :class hero` → `[(width, 400), (class, hero)]`. Values run to
813/// the next `:key` token and may be double-quoted to include spaces. A malformed spec
814/// contributes nothing rather than emitting broken markup.
815fn attr_html(spec: &str) -> Vec<(String, String)> {
816 let mut parser = AttrParser::default();
817 for token in spec.split_whitespace() {
818 parser.token(token);
819 }
820 parser.finish()
821}
822
823/// [`attr_html`]'s state between tokens.
824#[derive(Default)]
825struct AttrParser<'a> {
826 out: Vec<(String, String)>,
827 key: Option<&'a str>,
828 value: String,
829 /// A double-quoted value still waiting for its closing quote.
830 quoted: Option<String>,
831}
832
833impl<'a> AttrParser<'a> {
834 fn token(&mut self, token: &'a str) {
835 // Inside a quoted value, everything up to the closing quote is literal.
836 if let Some(buf) = &mut self.quoted {
837 buf.push(' ');
838 buf.push_str(token.trim_end_matches('"'));
839 if token.ends_with('"') {
840 self.value = self.quoted.take().expect("quoted value in progress");
841 }
842 return;
843 }
844 if let Some(k) = token.strip_prefix(':') {
845 self.flush();
846 if !k.is_empty() {
847 self.key = Some(k);
848 }
849 return;
850 }
851 if self.key.is_none() {
852 return;
853 }
854 if let Some(rest) = token.strip_prefix('"') {
855 if let Some(inner) = rest.strip_suffix('"') {
856 self.value = inner.to_string();
857 } else {
858 self.quoted = Some(rest.to_string());
859 }
860 return;
861 }
862 if !self.value.is_empty() {
863 self.value.push(' ');
864 }
865 self.value.push_str(token);
866 }
867
868 fn flush(&mut self) {
869 if let Some(k) = self.key.take() {
870 self.out.push((k.to_string(), self.value.trim().to_string()));
871 }
872 self.value.clear();
873 }
874
875 fn finish(mut self) -> Vec<(String, String)> {
876 if let Some(buf) = self.quoted.take() {
877 self.value = buf;
878 }
879 self.flush();
880 self.out
881 }
882}
883
884/// `<time>` markup for a timestamp. A range emits both endpoints; a same-day range
885/// abbreviates its end to just the time.
886fn timestamp_html(ts: &crate::model::Timestamp) -> String {
887 let class = if ts.active {
888 "timestamp"
889 } else {
890 "timestamp inactive"
891 };
892 let one = |dt: &chrono::NaiveDateTime, text: String| {
893 let attr = if ts.has_time {
894 dt.format("%Y-%m-%dT%H:%M").to_string()
895 } else {
896 dt.format("%Y-%m-%d").to_string()
897 };
898 format!(
899 "<time class=\"{class}\" datetime=\"{}\">{}</time>",
900 escape_attr(&attr),
901 escape_html(&text)
902 )
903 };
904 let text_of = |dt: &chrono::NaiveDateTime| {
905 if ts.has_time {
906 dt.format("%Y-%m-%d %H:%M").to_string()
907 } else {
908 dt.format("%Y-%m-%d").to_string()
909 }
910 };
911
912 let mut out = one(&ts.start, text_of(&ts.start));
913 if let Some(end) = &ts.end {
914 out.push_str("–");
915 let text = if end.date() == ts.start.date() && ts.has_time {
916 end.format("%H:%M").to_string()
917 } else {
918 text_of(end)
919 };
920 out.push_str(&one(end, text));
921 }
922 out
923}
924
925/// Best-effort URL for a link target. After RESOLVE, internal targets have been
926/// rewritten to `External` with their final URL; anything still internal here is an
927/// unresolved link, rendered to a plausible anchor so the page stays self-consistent.
928fn link_href(target: &LinkTarget) -> String {
929 match target {
930 LinkTarget::External(s) => s.clone(),
931 LinkTarget::CustomId(id) => format!("#{}", id),
932 LinkTarget::Id(id) => format!("#{}", id),
933 LinkTarget::Heading(text) => format!("#{}", slugify(text)),
934 LinkTarget::File { path, .. } => path.to_string(),
935 }
936}
937
938fn link_text(target: &LinkTarget) -> String {
939 match target {
940 LinkTarget::External(s) => s.clone(),
941 LinkTarget::CustomId(id) | LinkTarget::Id(id) => id.clone(),
942 LinkTarget::Heading(text) => text.clone(),
943 LinkTarget::File { path, .. } => path.to_string(),
944 }
945}
946
947/// Drop org's special column and its marker rows.
948///
949/// A table's first column may hold export markers rather than data — `/` marks a column
950/// group, `#` a row to recalculate, `!` a row of names. Rows marked `/ ! ^ _ $` are
951/// instructions to org and never appear in the output; the column itself disappears when
952/// *every* row uses it that way, which is what stops a table of formulas from publishing
953/// with a stray column of hashes.
954fn strip_special_column(table: &crate::model::Table) -> crate::model::Table {
955 let first_cell = |row: &TableRow| -> Option<String> {
956 match row {
957 TableRow::Cells(cells) => Some(plain_text(cells.first()?).trim().to_string()),
958 TableRow::Rule => None,
959 }
960 };
961 let data_rows = || table.rows.iter().filter(|r| matches!(r, TableRow::Cells(_)));
962 let column_is_special = data_rows().count() > 0
963 && data_rows().all(|row| {
964 matches!(
965 first_cell(row).as_deref(),
966 Some("" | "/" | "#" | "!" | "^" | "_" | "$" | "*")
967 )
968 });
969
970 let rows = table
971 .rows
972 .iter()
973 .filter(|row| {
974 // A marker row is an instruction, not content.
975 !matches!(
976 first_cell(row).as_deref(),
977 Some("/" | "!" | "^" | "_" | "$")
978 )
979 })
980 .map(|row| match (row, column_is_special) {
981 (TableRow::Cells(cells), true) => TableRow::Cells(cells[1.min(cells.len())..].to_vec()),
982 _ => row.clone(),
983 })
984 .collect();
985 crate::model::Table {
986 rows,
987 caption: table.caption.clone(),
988 }
989}
990
991/// Split text into alternating prose and LaTeX spans, `(text, is_latex)`.
992///
993/// orgo does not typeset LaTeX — it passes it through for MathJax or a reader's eyes —
994/// but it must know where a fragment *is*, because the export-time text conversions would
995/// otherwise rewrite the mathematics: `x^2` inside `$…$` is not a superscript to be
996/// marked up, and `--` inside one is a minus sign twice.
997fn latex_split(s: &str) -> Vec<(&str, bool)> {
998 let bytes = s.as_bytes();
999 let mut spans = Vec::new();
1000 let mut plain_from = 0;
1001 let mut i = 0;
1002 while i < s.len() {
1003 if !s.is_char_boundary(i) {
1004 i += 1;
1005 continue;
1006 }
1007 let end = match bytes[i] {
1008 b'\\' => latex_backslash_end(s, i),
1009 b'$' => latex_dollar_end(s, i),
1010 _ => None,
1011 };
1012 if let Some(end) = end {
1013 if plain_from < i {
1014 spans.push((&s[plain_from..i], false));
1015 }
1016 spans.push((&s[i..end], true));
1017 plain_from = end;
1018 i = end;
1019 continue;
1020 }
1021 i += 1;
1022 }
1023 if plain_from < s.len() {
1024 spans.push((&s[plain_from..], false));
1025 }
1026 spans
1027}
1028
1029/// End of a `\(…\)`, `\[…\]` or `\begin{env}…\end{env}` fragment starting at `i`.
1030fn latex_backslash_end(s: &str, i: usize) -> Option<usize> {
1031 let rest = &s[i..];
1032 for (open, close) in [("\\(", "\\)"), ("\\[", "\\]")] {
1033 if let Some(body) = rest.strip_prefix(open) {
1034 return body.find(close).map(|k| i + open.len() + k + close.len());
1035 }
1036 }
1037 let after = rest.strip_prefix("\\begin{")?;
1038 let name_end = after.find('}')?;
1039 let end_tag = format!("\\end{{{}}}", &after[..name_end]);
1040 let k = rest.find(&end_tag)?;
1041 Some(i + k + end_tag.len())
1042}
1043
1044/// End of a `$…$` fragment starting at `i`, or `None` when this `$` is just a dollar sign.
1045///
1046/// The body may not begin or end with whitespace, which is what keeps "it cost $5 or $6"
1047/// out — the same heuristic org uses, and the same one that means a sentence with two
1048/// unrelated dollar amounts and no space between them will be read as math.
1049fn latex_dollar_end(s: &str, i: usize) -> Option<usize> {
1050 let body = &s[i + 1..];
1051 let close = body.find('$')?;
1052 if close == 0 {
1053 return None;
1054 }
1055 let inner = &body[..close];
1056 if inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace) {
1057 return None;
1058 }
1059 if inner.contains('\n') {
1060 return None;
1061 }
1062 Some(i + 1 + close + 1)
1063}
1064
1065/// Org's special strings: `--` becomes an en dash, `---` an em dash, `...` an ellipsis,
1066/// and `\-` a soft hyphen.
1067///
1068/// A dash run only converts when a non-dash follows it, matching Emacs — so a `---` at the
1069/// very end of a line, or the `-----` someone drew as a rule, is left alone.
1070fn special_strings(s: &str) -> String {
1071 let chars: Vec<char> = s.chars().collect();
1072 let mut out = String::with_capacity(s.len());
1073 let mut i = 0;
1074 while i < chars.len() {
1075 let rest = &chars[i..];
1076 let followed_by_dash = |n: usize| matches!(rest.get(n), Some('-') | None);
1077 if rest.starts_with(&['\\', '-']) {
1078 out.push('\u{00ad}');
1079 i += 2;
1080 } else if rest.starts_with(&['-', '-', '-']) && !followed_by_dash(3) {
1081 out.push('\u{2014}');
1082 i += 3;
1083 } else if rest.starts_with(&['-', '-']) && !followed_by_dash(2) {
1084 out.push('\u{2013}');
1085 i += 2;
1086 } else if rest.starts_with(&['.', '.', '.']) {
1087 out.push('\u{2026}');
1088 i += 3;
1089 } else {
1090 out.push(chars[i]);
1091 i += 1;
1092 }
1093 }
1094 out
1095}
1096
1097/// Org's `_` and `^` conversions: `H_{2}O`, `x^2`.
1098///
1099/// Both require a non-whitespace character before the marker, which is what separates a
1100/// subscript from `_underlined text_` — the parser has already taken the emphasis, since
1101/// that form requires whitespace *before* the marker.
1102fn sub_superscript(s: &str, mode: SubSuperscript) -> String {
1103 if mode == SubSuperscript::No || !s.contains(['_', '^']) {
1104 return s.to_string();
1105 }
1106 let chars: Vec<char> = s.chars().collect();
1107 let mut out = String::with_capacity(s.len());
1108 let mut i = 0;
1109 while i < chars.len() {
1110 let c = chars[i];
1111 let prev_ok = i > 0 && !chars[i - 1].is_whitespace();
1112 if (c == '_' || c == '^') && prev_ok {
1113 if let Some((body, next)) = script_body(&chars, i + 1, mode) {
1114 let tag = if c == '_' { "sub" } else { "sup" };
1115 out.push_str(&format!("<{tag}>{body}</{tag}>"));
1116 i = next;
1117 continue;
1118 }
1119 }
1120 out.push(c);
1121 i += 1;
1122 }
1123 out
1124}
1125
1126/// The scripted text after a `_`/`^`: either `{...}`, or — unless braces are required —
1127/// a run of alphanumerics ending in one, so `x^2` and `a_b1` convert but `a_ ` does not.
1128fn script_body(chars: &[char], start: usize, mode: SubSuperscript) -> Option<(String, usize)> {
1129 if chars.get(start) == Some(&'{') {
1130 let mut depth = 1;
1131 let mut j = start + 1;
1132 while j < chars.len() {
1133 match chars[j] {
1134 '{' => depth += 1,
1135 '}' => {
1136 depth -= 1;
1137 if depth == 0 {
1138 return Some((chars[start + 1..j].iter().collect(), j + 1));
1139 }
1140 }
1141 _ => {}
1142 }
1143 j += 1;
1144 }
1145 return None;
1146 }
1147 if mode == SubSuperscript::Braces {
1148 return None;
1149 }
1150 let mut j = start;
1151 if matches!(chars.get(j), Some('+') | Some('-')) {
1152 j += 1;
1153 }
1154 let mut last_alnum = None;
1155 while let Some(&c) = chars.get(j) {
1156 if c.is_alphanumeric() {
1157 last_alnum = Some(j);
1158 } else if !matches!(c, '.' | ',' | '\\') {
1159 break;
1160 }
1161 j += 1;
1162 }
1163 let end = last_alnum? + 1;
1164 Some((chars[start..end].iter().collect(), end))
1165}
1166
1167fn escape_html(s: &str) -> String {
1168 let mut out = String::with_capacity(s.len());
1169 for c in s.chars() {
1170 match c {
1171 '&' => out.push_str("&"),
1172 '<' => out.push_str("<"),
1173 '>' => out.push_str(">"),
1174 _ => out.push(c),
1175 }
1176 }
1177 out
1178}
1179
1180fn escape_attr(s: &str) -> String {
1181 let mut out = escape_html(s);
1182 out = out.replace('"', """);
1183 out
1184}
1185