krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.20.1: src/render.rs · raw

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