` (default `auto`). In `auto` mode, `--plain` and `ink diff` emit color and OSC 8 hyperlinks only when stdout is a terminal, so redirects, pipes, and `git` textconv get clean text. Precedence: an explicit `--color` wins; then `NO_COLOR` turns color off; then `CLICOLOR_FORCE=1` or `FORCE_COLOR` turns it on; then `TERM=dumb` turns it off; then the terminal check. Tools that display ANSI from a pipe need `--color=always`:
```bash
@@ -361,7 +369,7 @@ spacing = "normal"
# Show table of contents on startup
toc = false
-# Show YAML/TOML frontmatter
+# Show YAML/TOML/JSON frontmatter as a metadata box at the top
frontmatter = false
# Behavior
@@ -428,7 +436,9 @@ Options:
--image-protocol auto | kitty | iterm2 | sixel | halfblocks
--list-themes List available themes and exit
--no-pager Never page --plain output, even on a TTY
- --frontmatter Show YAML/TOML frontmatter
+ --frontmatter Show YAML/TOML/JSON frontmatter as a metadata box
+ --line-range Render only these source lines (N, START:, :END;
+ repeatable; implies --plain)
--spacing Line spacing: compact, normal, relaxed
--no-mouse Don't capture the mouse (overrides mouse_capture in config)
--ascii Draw borders, bullets and markers in plain ASCII
diff --git a/docs/index.html b/docs/index.html
index e83c840..a596e5d 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -335,7 +335,7 @@ Open links by keyboard
Math & emoji
-
Renders $math$ in code style and resolves :emoji: shortcodes.
+
Renders $math$ as Unicode text (Greek, fractions, roots, matrices) and resolves :emoji: shortcodes.
Admonitions
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 015f1c2..1946bc5 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -12,15 +12,16 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow
- Bold, italic, strikethrough, inline code
- Links with OSC 8 hyperlink support (Cmd+Click / Ctrl+Click in supported terminals)
- Syntax-highlighted code blocks using syntect (supports all major programming languages)
-- Tables with auto-sized columns and word wrapping
+- Tables with auto-sized columns, word wrapping and column alignment (:--, :-:, --:)
- Blockquotes with nested support and depth-based coloring
- Ordered and unordered lists with nesting
- Task lists with checkbox rendering
- Footnotes
+- Superscript ^x^ and
x, subscript
x (Unicode forms, ^(x)/_(x) fallback); ~x~ and ~~x~~ are strikethrough as on GitHub; ||spoilers||, definition lists (Term / : definition)
- Horizontal rules
-- Inline/block math ($...$, $$...$$) rendered in code style; :emoji: shortcodes resolved to glyphs
-- HTML blocks shown as text; common inline HTML (
,
, , ) handled rather than dropped
-- YAML and TOML frontmatter stripping (optional display)
+- Inline/block math ($...$, $$...$$, ```math) rendered as Unicode text: Greek letters, operators, \mathbb/\mathcal letters, super/subscripts, \frac as a/b, \sqrt, matrix/cases/aligned environments on several lines; unknown commands kept as written; ASCII mode shows the LaTeX source. A lone $ in prose stays a dollar sign. :emoji: shortcodes resolved to glyphs
+- HTML blocks shown as text; common inline HTML (
, , , ) handled rather than dropped, / as Unicode sub/superscripts
+- YAML, TOML and JSON frontmatter: stripped by default; --frontmatter shows it as a key/value box
### Images
- Real pixel rendering via terminal graphics protocols (Kitty graphics, iTerm2 inline, Sixel), auto-detected at startup; --image-protocol auto|kitty|iterm2|sixel|halfblocks
@@ -37,7 +38,8 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow
- Unknown diagram types get fallback rendering
### Admonitions
-- GitHub-style admonition blocks: [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]
+- GitHub-style admonition blocks: [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION], with custom titles ([!NOTE] Title)
+- Obsidian callouts ([!info], [!bug], [!example], ...; +/- fold markers ignored) mapped onto the same colours
- Each type has a distinct color and icon
### Wikilinks
@@ -86,6 +88,7 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow
- --color auto|always|never (default auto): color and OSC 8 links only when stdout is a terminal; precedence is --color, then NO_COLOR, then CLICOLOR_FORCE/FORCE_COLOR, then TERM=dumb, then the TTY check. Use --color=always for fzf previews or less -R; git textconv (`ink --plain`) gets clean text
- `ink -` reads the document from stdin
- Pager mode: on an interactive terminal, --plain pages long output through $PAGER (default less -R), like bat; --no-pager disables it
+- --line-range START:END (also START:, :END, N; repeatable): render only those 1-based source lines; a cut code fence is reopened/closed; implies --plain
- Document outline: `ink outline ` shows heading structure
- Document stats: `ink stats ` shows word count, reading time, element counts
- Markdown diff: `ink diff ` compares two files with a real Myers line diff
diff --git a/docs/llms.txt b/docs/llms.txt
index 38e8c90..cda2302 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -6,19 +6,20 @@ ink is a command-line tool that renders markdown files in the terminal with rich
## What it does
-- Renders markdown with syntax highlighting, tables, blockquotes, lists, task lists, footnotes, and horizontal rules
+- Renders markdown with syntax highlighting, tables (with column alignment), blockquotes, lists, task lists, footnotes, definition lists, superscript (^x^, ) and subscript (), spoilers, and horizontal rules
- Displays inline images using terminal graphics protocols (Kitty, iTerm2, Sixel) where supported, falling back to Unicode half-block characters (any true-color terminal); --image-protocol selects the renderer
- Renders mermaid diagrams (flowcharts, sequence diagrams, pie charts, Gantt charts) as ASCII art
-- Supports GitHub-style admonitions: NOTE, TIP, IMPORTANT, WARNING, CAUTION
+- Supports GitHub-style admonitions (NOTE, TIP, IMPORTANT, WARNING, CAUTION) with custom titles, and Obsidian callouts
+- YAML, TOML and JSON frontmatter: hidden by default, shown as a key/value box with --frontmatter
- Supports wikilink syntax: [[page]] and [[page|display text]]
-- Renders inline/block math ($...$, $$...$$) in code style and resolves :emoji: shortcodes
+- Renders inline/block math ($...$, $$...$$, ```math) as Unicode text (Greek, operators, super/subscripts, fractions, roots, matrices, cases) and resolves :emoji: shortcodes
- Interactive TUI with multi-tab support, in-document search (n/N to cycle matches), keyboard link opening (press f), a help overlay (?), and a table of contents sidebar
- Text selection and clipboard copy: v/V for character- and line-wise visual selection (y copies), mouse drag with double-click word and triple-click line, c to copy a code block's raw source, Y to copy the current section's markdown; delivered over OSC 52 (works across SSH/tmux) and a native helper (pbcopy/wl-copy/xclip/xsel/clip.exe)
- File browser mode for exploring directories of markdown files
- 8 built-in color themes with auto dark/light detection, live theme picker that saves to config, plus custom TOML themes
- Presentation mode (split on --- separators), navigate with arrows/space
- Watch mode for live-reloading on file changes
-- Pager mode: `ink --plain` on a TTY pages long output through $PAGER (like bat)
+- Pager mode: `ink --plain` on a TTY pages long output through $PAGER (like bat); --line-range START:END renders only those source lines
- Honors NO_COLOR and downgrades to 256-color on non-truecolor terminals
- Document statistics, outline view, and a real (Myers) markdown diff
- Vim-style keybindings (j/k/G/Ctrl+f/Ctrl+b), also emacs preset, fully customizable
diff --git a/src/app.rs b/src/app.rs
index df314e5..523784f 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2266,19 +2266,21 @@ fn build_tab(
} else {
None
};
- let (_, stripped) = if args.frontmatter {
- (None, source.to_string())
- } else {
- frontmatter::strip_frontmatter(&source)
- };
+ // Frontmatter is stripped, or (with --frontmatter) swapped for a fenced
+ // block the layout draws as a metadata box.
+ let stripped = frontmatter::prepare(&source, args.frontmatter);
// Pre-process wikilinks before parsing
let content = crate::wikilink::process_wikilinks(&stripped);
- // Frontmatter comes off the top whole, and wikilinks never add or remove
- // a line; if either ever stops holding, the mapping is unknown.
+ // Source line = content line + offset. Only the head of the document
+ // changes: stripping removes whole lines, and the metadata box usually
+ // keeps the line count. If the parsed text has MORE lines than the source
+ // (JSON frontmatter gains fence lines), or wikilinks ever change the line
+ // count, the mapping is unknown and no line is passed to the editor.
let line_offset = source
- .strip_suffix(stripped.as_str())
- .map(|head| head.matches('\n').count())
+ .matches('\n')
+ .count()
+ .checked_sub(stripped.matches('\n').count())
.filter(|_| content.matches('\n').count() == stripped.matches('\n').count());
let arena = Arena::new();
diff --git a/src/cli.rs b/src/cli.rs
index 45169de..f387914 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -71,10 +71,18 @@ pub struct Cli {
#[arg(long)]
pub no_pager: bool,
- /// Show YAML/TOML frontmatter
+ /// Show YAML/TOML/JSON frontmatter as a metadata box at the top
+ /// (hidden by default)
#[arg(long)]
pub frontmatter: bool,
+ /// Render only these SOURCE lines of the markdown (1-based, inclusive):
+ /// `START:END`, `START:`, `:END` or `N`; repeatable. A fenced code block
+ /// cut by a range is reopened/closed so it still highlights. Implies
+ /// --plain
+ #[arg(long, value_name = "START:END", value_parser = parse_line_range)]
+ pub line_range: Vec,
+
/// Line spacing [default: normal]
#[arg(long, value_enum)]
pub spacing: Option,
@@ -120,6 +128,11 @@ fn parse_width(s: &str) -> Result {
}
}
+/// `--line-range` parser.
+fn parse_line_range(s: &str) -> Result {
+ crate::parser::line_range::LineRange::parse(s)
+}
+
/// `--image-protocol` parser (keeps the aliases `ProtocolChoice::parse` accepts).
fn parse_protocol(s: &str) -> Result {
crate::graphics::ProtocolChoice::parse(s)
@@ -357,7 +370,8 @@ pub fn run() -> Result<()> {
slides: cli.slides,
// The interactive reader needs a terminal: with stdout piped or
// redirected (`ink file.md | cat`), render plain output instead.
- plain: cli.plain || !stdout_tty,
+ // `--line-range` has no reader equivalent: it renders plain.
+ plain: cli.plain || !stdout_tty || !cli.line_range.is_empty(),
watch: cli.watch,
toc,
images: if cli.no_images {
@@ -438,8 +452,9 @@ pub fn run() -> Result<()> {
let sources = read_all_inputs(&args)?;
let mut rendered = String::new();
for source in &sources {
+ let source = crate::parser::line_range::select(source, &cli.line_range);
rendered.push_str(&render::plain::render_plain_with_color(
- source, &args, color,
+ &source, &args, color,
)?);
}
emit_plain(&rendered, cli.no_pager);
@@ -671,7 +686,7 @@ pub(crate) const STARTER_CONFIG: &str = r#"# ink configuration
# Show table of contents on startup
# toc = false
-# Show YAML/TOML frontmatter as a code block at the top of the document
+# Show YAML/TOML/JSON frontmatter as a metadata box at the top of the document
# frontmatter = false
[behavior]
diff --git a/src/layout/frontmatter.rs b/src/layout/frontmatter.rs
new file mode 100644
index 0000000..3e46058
--- /dev/null
+++ b/src/layout/frontmatter.rs
@@ -0,0 +1,163 @@
+//! The `--frontmatter` metadata box: the document's frontmatter, which
+//! `parser::frontmatter::prepare` turned into a fenced block, drawn as a
+//! bordered key/value list titled "frontmatter".
+//!
+//! ```text
+//! ╭─ frontmatter ───────────╮
+//! │ title Hello │
+//! │ tags rust, terminal │
+//! ╰─────────────────────────╯
+//! ```
+//!
+//! Keys and values are document text: they reach the terminal only through
+//! the layout's final sanitizer pass, like everything else.
+
+use super::table::{fit_label, wrap_text};
+use super::{add_spacing, CodeBlockSpec, LayoutContext, SpanStyle, StyledLine, StyledSpan};
+use crate::parser::frontmatter::{entries, Format};
+use unicode_width::UnicodeWidthStr;
+
+const LABEL: &str = " frontmatter ";
+/// Columns between the key and value columns.
+const GAP: usize = 2;
+
+/// Lay out the metadata box for a fenced block whose info string is
+/// `ink-frontmatter ` and whose literal is the frontmatter body.
+pub(super) fn layout_frontmatter(
+ format_name: &str,
+ literal: &str,
+ ctx: &LayoutContext,
+ lines: &mut Vec,
+) {
+ let start_line = lines.len();
+ let format = Format::from_name(format_name).unwrap_or(Format::Yaml);
+ let mut rows = entries(format, literal);
+ if rows.is_empty() {
+ // Nothing readable as keys: show the block's lines as they are.
+ rows = literal
+ .lines()
+ .map(|l| (String::new(), vec![l.to_string()]))
+ .collect();
+ }
+
+ let g = crate::glyphs::current();
+ let inner_max = ctx.width.max(LABEL.width() + 6).saturating_sub(4);
+ let max_key = rows.iter().map(|(k, _)| k.width()).max().unwrap_or(0);
+ let key_w = max_key.min(inner_max / 3);
+ let gap = if key_w == 0 { 0 } else { GAP };
+ let max_value = rows
+ .iter()
+ .flat_map(|(_, v)| v.iter().map(|l| l.width()))
+ .max()
+ .unwrap_or(0);
+ let inner = (key_w + gap + max_value)
+ .min(inner_max)
+ .max(LABEL.width() + 1);
+ let value_w = inner.saturating_sub(key_w + gap).max(1);
+ let border_width = inner + 4;
+
+ let border = SpanStyle {
+ fg: Some(ctx.theme.colors.table_border.clone()),
+ ..Default::default()
+ };
+ let key_style = SpanStyle {
+ fg: Some(ctx.theme.colors.table_header.clone()),
+ bold: true,
+ ..Default::default()
+ };
+
+ // ╭─ frontmatter ───╮
+ let mut top = StyledLine::new();
+ ctx.add_margin(&mut top);
+ top.push(StyledSpan {
+ text: format!("{}{}", g.tl, g.h),
+ style: border.clone(),
+ });
+ top.push(StyledSpan {
+ text: LABEL.to_string(),
+ style: SpanStyle {
+ fg: Some(ctx.theme.colors.heading3.clone()),
+ ..Default::default()
+ },
+ });
+ top.push(StyledSpan {
+ text: format!(
+ "{}{}",
+ g.h.repeat(border_width.saturating_sub(LABEL.width() + 3)),
+ g.tr
+ ),
+ style: border.clone(),
+ });
+ lines.push(top);
+
+ for (key, values) in &rows {
+ let mut visual: Vec = Vec::new();
+ let empty = [String::new()];
+ let values = if values.is_empty() {
+ &empty[..]
+ } else {
+ values
+ };
+ for value in values {
+ // Raw nested lines keep their indentation.
+ let lead = value.len() - value.trim_start_matches(' ').len();
+ let lead = lead.min(value_w.saturating_sub(1));
+ for part in wrap_text(value.trim_start_matches(' '), value_w - lead) {
+ visual.push(format!("{}{part}", " ".repeat(lead)));
+ }
+ }
+ for (i, text) in visual.iter().enumerate() {
+ let mut line = StyledLine::new();
+ ctx.add_margin(&mut line);
+ line.push(StyledSpan {
+ text: format!("{} ", g.v),
+ style: border.clone(),
+ });
+ if key_w > 0 {
+ let label = if i == 0 {
+ fit_label(key, key_w)
+ } else {
+ " ".repeat(key_w)
+ };
+ line.push(StyledSpan {
+ text: format!("{label}{}", " ".repeat(gap)),
+ style: key_style.clone(),
+ });
+ }
+ let pad = value_w.saturating_sub(text.width());
+ line.push(StyledSpan {
+ text: text.clone(),
+ style: SpanStyle::default(),
+ });
+ line.push(StyledSpan {
+ text: format!("{} {}", " ".repeat(pad), g.v),
+ style: border.clone(),
+ });
+ lines.push(line);
+ }
+ }
+
+ let mut bottom = StyledLine::new();
+ ctx.add_margin(&mut bottom);
+ bottom.push(StyledSpan {
+ text: format!(
+ "{}{}{}",
+ g.bl,
+ g.h.repeat(border_width.saturating_sub(2)),
+ g.br
+ ),
+ style: border,
+ });
+ lines.push(bottom);
+
+ // `c` copies the frontmatter as written, like any code block.
+ if ctx.record_headings {
+ ctx.code_blocks.borrow_mut().push(CodeBlockSpec {
+ line_index: start_line,
+ rows: lines.len() - start_line,
+ lang: "frontmatter".to_string(),
+ source: literal.trim_end_matches('\n').to_string(),
+ });
+ }
+ add_spacing(ctx, lines);
+}
diff --git a/src/layout/html.rs b/src/layout/html.rs
index c4296c3..df309be 100644
--- a/src/layout/html.rs
+++ b/src/layout/html.rs
@@ -233,6 +233,8 @@ pub(super) fn img_src_alt(attrs: &[(String, String)]) -> Option<(String, String)
pub(super) struct HtmlStyles {
/// (tag name, style inside it, hides its content)
stack: Vec<(String, SpanStyle, bool)>,
+ /// Open ``/`` tags and the span index their text starts at.
+ scripts: Vec<(String, usize)>,
}
impl HtmlStyles {
@@ -312,6 +314,44 @@ impl HtmlStyles {
self.stack.push((name.to_string(), style, hide));
}
+ /// Note where a ``/`` starts in `spans`.
+ fn open_script(&mut self, name: &str, spans: &[StyledSpan]) {
+ if matches!(name, "sub" | "sup") {
+ self.scripts.push((name.to_string(), spans.len()));
+ }
+ }
+
+ /// At ``/``: the text since the opening tag becomes one
+ /// span of Unicode sub/superscript characters, or `_(…)` / `^(…)` when
+ /// a character has no such form (always in ASCII mode), like the
+ /// markdown `^sup^` extension. An unclosed tag leaves its text as is.
+ fn close_script(&mut self, name: &str, spans: &mut Vec) {
+ let Some(idx) = self.scripts.iter().rposition(|(n, _)| n == name) else {
+ return;
+ };
+ let start = self.scripts[idx].1;
+ self.scripts.truncate(idx);
+ if start >= spans.len() {
+ return;
+ }
+ let text: String = spans[start..].iter().map(|s| s.text.as_str()).collect();
+ let text = text.trim();
+ let style = spans[start].style.clone();
+ spans.truncate(start);
+ if text.is_empty() {
+ return;
+ }
+ let ascii = crate::glyphs::current().ascii;
+ spans.push(StyledSpan {
+ text: if name == "sup" {
+ super::scripts::superscript(text, ascii, false)
+ } else {
+ super::scripts::subscript(text, ascii, false)
+ },
+ style,
+ });
+ }
+
/// Close the innermost open tag of this name (and anything left open
/// inside it). A stray closing tag is ignored.
fn close(&mut self, name: &str) {
@@ -353,9 +393,15 @@ pub(super) fn inline_fragment(
spans.push(image_placeholder_span(&src, &alt, styles.style(base), ctx));
}
}
- _ => styles.open(&name, &attrs, base, ctx),
+ _ => {
+ styles.open_script(&name, spans);
+ styles.open(&name, &attrs, base, ctx);
+ }
},
- Token::Close { name } => styles.close(&name),
+ Token::Close { name } => {
+ styles.close_script(&name, spans);
+ styles.close(&name);
+ }
}
}
}
@@ -500,9 +546,11 @@ pub(super) fn layout_html_block(
}
}
}
+ styles.open_script(&name, &block.para);
styles.open(&name, &attrs, &base, ctx);
}
Token::Close { name } => {
+ styles.close_script(&name, &mut block.para);
styles.close(&name);
if is_block_tag(&name) || heading_level(&name).is_some() {
block.flush(lines);
diff --git a/src/layout/math.rs b/src/layout/math.rs
new file mode 100644
index 0000000..13311a6
--- /dev/null
+++ b/src/layout/math.rs
@@ -0,0 +1,1424 @@
+//! LaTeX math to Unicode text: a dependency-free approximation for a
+//! terminal, used for inline `$…$`, display `$$…$$` and ```` ```math ````.
+//!
+//! What it does:
+//! - Greek letters, operators, relations, arrows and big operators become
+//! their Unicode symbols; `\mathbb{R}` → ℝ, `\mathcal{L}` → ℒ, and the
+//! like, where a code point exists.
+//! - `x^2` / `a_{ij}` use Unicode super/subscripts when every character has
+//! one, else `^(…)` / `_(…)` (`^x` / `_x` for a single character).
+//! - `\frac{a}{b}` → `a/b`, a side in parentheses unless it is one token;
+//! `\sqrt{x}` → `√x` / `√(x+1)`, `\sqrt[n]{x}` → `ⁿ√x`.
+//! - `\text{…}`, `\mathrm{…}` and friends → plain text; `\left`/`\right`,
+//! sizing and colour commands dropped; `\, \; \quad` → spaces, `\!` → "".
+//! - `matrix`/`pmatrix`/`bmatrix`/`Bmatrix`/`vmatrix`/`Vmatrix`/`array`,
+//! `cases`, `aligned`/`align`/`split` and `gathered`: rows on their own
+//! lines, columns padded, brackets drawn from the bracket-piece glyphs
+//! (inline math puts them on one line: `(a b; c d)`).
+//! - Unknown commands are kept verbatim with their brace arguments.
+//!
+//! Never panics on malformed input; nesting deeper than [`MAX_DEPTH`] is
+//! shown as its source. In ASCII mode nothing is converted: the source is
+//! shown with `\left`/`\right` and spacing commands removed ([`clean`]).
+
+use super::scripts;
+use unicode_width::UnicodeWidthStr;
+
+/// Deepest group/argument/environment nesting rendered; deeper content is
+/// shown as written. Keeps the recursion bounded on hostile input.
+const MAX_DEPTH: usize = 32;
+
+/// Inline math as one line of text.
+pub fn render_inline(src: &str, ascii: bool) -> String {
+ if ascii {
+ return clean(src).join(" ");
+ }
+ Renderer { inline: true }.top(src).flatten()
+}
+
+/// Display math as one or more lines (environments and `\\` give rows).
+pub fn render_display(src: &str, ascii: bool) -> Vec {
+ if ascii {
+ return clean(src);
+ }
+ Renderer { inline: false }
+ .top(src)
+ .rows
+ .into_iter()
+ .map(|r| r.trim_end().to_string())
+ .collect()
+}
+
+/// The source for ASCII mode: `\left`/`\right`, sizing and spacing commands
+/// removed, whitespace collapsed, one entry per non-blank source line.
+pub fn clean(src: &str) -> Vec {
+ src.lines()
+ .map(|line| {
+ let chars: Vec = line.chars().collect();
+ let mut out = String::new();
+ let mut i = 0;
+ while i < chars.len() {
+ if chars[i] == '\\' && i + 1 < chars.len() {
+ let (name, next) = command_name(&chars, i + 1);
+ match name.as_str() {
+ "," | ":" | ";" | " " | "quad" | "qquad" => {
+ out.push(' ');
+ i = next;
+ continue;
+ }
+ "!" => {
+ i = next;
+ continue;
+ }
+ n if is_dropped(n) => {
+ i = next;
+ // `\left.` / `\right.`: no delimiter at all.
+ if matches!(n, "left" | "right") && chars.get(i) == Some(&'.') {
+ i += 1;
+ }
+ continue;
+ }
+ _ => {
+ out.extend(&chars[i..next]);
+ i = next;
+ continue;
+ }
+ }
+ }
+ out.push(chars[i]);
+ i += 1;
+ }
+ out.split_whitespace().collect::>().join(" ")
+ })
+ .filter(|l| !l.is_empty())
+ .collect()
+}
+
+/// Commands that render as nothing: delimiter sizing, style switches.
+fn is_dropped(name: &str) -> bool {
+ matches!(
+ name,
+ "left"
+ | "right"
+ | "middle"
+ | "big"
+ | "Big"
+ | "bigg"
+ | "Bigg"
+ | "bigl"
+ | "bigr"
+ | "Bigl"
+ | "Bigr"
+ | "biggl"
+ | "biggr"
+ | "Biggl"
+ | "Biggr"
+ | "displaystyle"
+ | "textstyle"
+ | "scriptstyle"
+ | "scriptscriptstyle"
+ | "limits"
+ | "nolimits"
+ | "nonumber"
+ | "notag"
+ | "hline"
+ )
+}
+
+/// The command name starting at `chars[i]` (just after a backslash): a run
+/// of letters, or one other character. Returns it and the index after it.
+fn command_name(chars: &[char], i: usize) -> (String, usize) {
+ match chars.get(i) {
+ Some(c) if c.is_ascii_alphabetic() => {
+ let end = chars[i..]
+ .iter()
+ .position(|c| !c.is_ascii_alphabetic())
+ .map_or(chars.len(), |n| i + n);
+ (chars[i..end].iter().collect(), end)
+ }
+ Some(&c) => (c.to_string(), i + 1),
+ None => (String::new(), i),
+ }
+}
+
+/// A rendered fragment: one or more rows, aligned with neighbours on its
+/// baseline row when placed side by side.
+#[derive(Debug, Clone)]
+struct Block {
+ rows: Vec,
+ base: usize,
+}
+
+impl Block {
+ fn text(s: impl Into) -> Self {
+ Block {
+ rows: vec![s.into()],
+ base: 0,
+ }
+ }
+
+ fn width(&self) -> usize {
+ self.rows.iter().map(|r| r.width()).max().unwrap_or(0)
+ }
+
+ fn is_empty(&self) -> bool {
+ self.rows.iter().all(|r| r.is_empty())
+ }
+
+ /// Place `other` to the right, baselines aligned.
+ fn append(&mut self, other: Block) {
+ if other.rows.len() == 1 && self.rows.len() == 1 {
+ self.rows[0].push_str(&other.rows[0]);
+ return;
+ }
+ let w = self.width();
+ let above = self.base.max(other.base);
+ let below = (self.rows.len() - self.base).max(other.rows.len() - other.base);
+ let mut rows = vec![String::new(); above + below];
+ for (k, r) in self.rows.iter().enumerate() {
+ rows[above - self.base + k] = r.clone();
+ }
+ for row in rows.iter_mut() {
+ let pad = w.saturating_sub(row.width());
+ row.push_str(&" ".repeat(pad));
+ }
+ for (k, r) in other.rows.iter().enumerate() {
+ rows[above - other.base + k].push_str(r);
+ }
+ self.rows = rows;
+ self.base = above;
+ }
+
+ fn push_str(&mut self, s: &str) {
+ self.append(Block::text(s));
+ }
+
+ fn ends_with_space(&self) -> bool {
+ self.rows[self.base].ends_with(' ')
+ }
+
+ /// One line: rows joined with `; ` (a multi-row block met inline or in
+ /// a script).
+ fn flatten(&self) -> String {
+ let rows: Vec<&str> = self
+ .rows
+ .iter()
+ .map(|r| r.trim())
+ .filter(|r| !r.is_empty())
+ .collect();
+ rows.join("; ")
+ }
+}
+
+struct Renderer {
+ inline: bool,
+}
+
+/// A cursor over the characters of one source string.
+struct Cursor {
+ s: Vec,
+ i: usize,
+}
+
+impl Cursor {
+ fn new(src: &str) -> Self {
+ Cursor {
+ s: src.chars().collect(),
+ i: 0,
+ }
+ }
+
+ fn peek(&self) -> Option {
+ self.s.get(self.i).copied()
+ }
+
+ fn skip_ws(&mut self) {
+ while self.peek().is_some_and(char::is_whitespace) {
+ self.i += 1;
+ }
+ }
+
+ /// The raw text of a brace group whose `{` was just consumed, up to its
+ /// matching `}` (consumed too; end of input if unbalanced).
+ fn raw_group(&mut self) -> String {
+ let start = self.i;
+ let mut depth = 1usize;
+ while let Some(c) = self.peek() {
+ self.i += 1;
+ match c {
+ '\\' => self.i += 1, // an escaped brace does not count
+ '{' => depth += 1,
+ '}' => {
+ depth -= 1;
+ if depth == 0 {
+ return self.s[start..self.i - 1].iter().collect();
+ }
+ }
+ _ => {}
+ }
+ }
+ self.i = self.i.min(self.s.len());
+ self.s[start.min(self.s.len())..].iter().collect()
+ }
+
+ /// A raw `{…}` argument after optional whitespace, or the next single
+ /// character when there are no braces.
+ fn raw_arg(&mut self) -> String {
+ self.skip_ws();
+ match self.peek() {
+ Some('{') => {
+ self.i += 1;
+ self.raw_group()
+ }
+ Some(c) => {
+ self.i += 1;
+ c.to_string()
+ }
+ None => String::new(),
+ }
+ }
+}
+
+impl Renderer {
+ /// A whole formula: split into rows on top-level `\\` (display) and
+ /// aligned on `&` when it has any.
+ fn top(&self, src: &str) -> Block {
+ let rows = split_top(src);
+ if rows.len() == 1 && rows[0].len() == 1 {
+ return self.render(&rows[0][0], 0);
+ }
+ if self.inline {
+ let lines: Vec = rows
+ .iter()
+ .map(|cells| {
+ cells
+ .iter()
+ .map(|c| self.render(c, 1).flatten())
+ .collect::>()
+ .join(" ")
+ })
+ .collect();
+ return Block::text(lines.join("; "));
+ }
+ let kind = if rows.iter().any(|r| r.len() > 1) {
+ "aligned"
+ } else {
+ "gathered"
+ };
+ self.grid(kind, &rows, 1)
+ }
+
+ /// Render `src` as a sequence, at nesting `depth`.
+ fn render(&self, src: &str, depth: usize) -> Block {
+ let mut cur = Cursor::new(src);
+ self.seq(&mut cur, depth, false)
+ }
+
+ /// Render up to the end of input, or up to and including the `}` that
+ /// closes the group when `in_group`.
+ fn seq(&self, cur: &mut Cursor, depth: usize, in_group: bool) -> Block {
+ let mut out = Block::text("");
+ while let Some(c) = cur.peek() {
+ match c {
+ '}' => {
+ cur.i += 1;
+ if in_group {
+ return out;
+ }
+ out.push_str("}");
+ }
+ '{' => {
+ cur.i += 1;
+ let g = self.group(cur, depth);
+ out.append(g);
+ }
+ '^' | '_' => {
+ cur.i += 1;
+ let arg = self.arg(cur, depth).flatten();
+ let arg: String = arg.chars().filter(|c| !c.is_whitespace()).collect();
+ out.push_str(&script(&arg, c == '^'));
+ }
+ '\\' => {
+ cur.i += 1;
+ self.command(cur, depth, &mut out);
+ }
+ c if c.is_whitespace() => {
+ cur.skip_ws();
+ if !out.is_empty() && !out.ends_with_space() {
+ out.push_str(" ");
+ }
+ }
+ '&' => {
+ cur.i += 1;
+ if !out.ends_with_space() {
+ out.push_str(" ");
+ }
+ }
+ '\'' => {
+ cur.i += 1;
+ out.push_str("′");
+ }
+ '~' => {
+ cur.i += 1;
+ out.push_str(" ");
+ }
+ _ => {
+ cur.i += 1;
+ out.push_str(c.encode_utf8(&mut [0; 4]));
+ }
+ }
+ }
+ out
+ }
+
+ /// A brace group whose `{` was just consumed. Past the depth cap its
+ /// content is shown as written.
+ fn group(&self, cur: &mut Cursor, depth: usize) -> Block {
+ if depth + 1 > MAX_DEPTH {
+ return Block::text(cur.raw_group());
+ }
+ self.seq(cur, depth + 1, true)
+ }
+
+ /// One argument: a brace group, a command, or a single character.
+ fn arg(&self, cur: &mut Cursor, depth: usize) -> Block {
+ cur.skip_ws();
+ match cur.peek() {
+ Some('{') => {
+ cur.i += 1;
+ self.group(cur, depth)
+ }
+ Some('\\') => {
+ cur.i += 1;
+ let mut out = Block::text("");
+ if depth + 1 > MAX_DEPTH {
+ let (name, next) = command_name(&cur.s, cur.i);
+ cur.i = next;
+ out.push_str(&format!("\\{name}"));
+ } else {
+ self.command(cur, depth + 1, &mut out);
+ }
+ out
+ }
+ Some(c) => {
+ cur.i += 1;
+ Block::text(c.to_string())
+ }
+ None => Block::text(""),
+ }
+ }
+
+ /// A command; its backslash was just consumed.
+ fn command(&self, cur: &mut Cursor, depth: usize, out: &mut Block) {
+ let (name, next) = command_name(&cur.s, cur.i);
+ cur.i = next;
+ match name.as_str() {
+ "" => out.push_str("\\"),
+ "," | ":" | ";" | " " => out.push_str(" "),
+ "quad" => out.push_str(" "),
+ "qquad" => out.push_str(" "),
+ "!" => {}
+ "\\" => out.push_str(" "),
+ "left" | "right" | "middle" => {
+ cur.skip_ws();
+ if cur.peek() == Some('.') {
+ cur.i += 1;
+ }
+ }
+ n if is_dropped(n) => {}
+ "frac" | "dfrac" | "tfrac" | "cfrac" => {
+ let a = self.arg(cur, depth).flatten();
+ let b = self.arg(cur, depth).flatten();
+ out.push_str(&format!("{}/{}", operand(&a), operand(&b)));
+ }
+ "sqrt" => {
+ cur.skip_ws();
+ let index = if cur.peek() == Some('[') {
+ cur.i += 1;
+ let start = cur.i;
+ while cur.peek().is_some_and(|c| c != ']') {
+ cur.i += 1;
+ }
+ let raw: String = cur.s[start..cur.i].iter().collect();
+ cur.i = (cur.i + 1).min(cur.s.len());
+ let n = self.render(&raw, depth + 1).flatten();
+ let n: String = n.chars().filter(|c| !c.is_whitespace()).collect();
+ match scripts::to_superscript(&n) {
+ Some(s) => s,
+ None if n.is_empty() => String::new(),
+ None => format!("({n})"),
+ }
+ } else {
+ String::new()
+ };
+ let x = self.arg(cur, depth).flatten();
+ let x = if is_token(&x) { x } else { format!("({x})") };
+ out.push_str(&format!("{index}√{x}"));
+ }
+ "text" | "textrm" | "textnormal" | "textit" | "textbf" | "texttt" | "textsf"
+ | "textup" | "emph" | "mbox" | "hbox" => {
+ out.push_str(&cur.raw_arg());
+ }
+ "mathrm" | "mathit" | "mathbf" | "mathsf" | "mathtt" | "mathnormal" | "boldsymbol"
+ | "bm" | "operatorname" => {
+ let a = self.arg(cur, depth);
+ out.append(a);
+ }
+ "mathbb" | "Bbb" | "mathcal" | "mathscr" | "mathfrak" => {
+ let a = self.arg(cur, depth).flatten();
+ let map: fn(char) -> Option = match name.as_str() {
+ "mathbb" | "Bbb" => double_struck,
+ "mathfrak" => fraktur,
+ _ => script_letter,
+ };
+ out.push_str(&a.chars().map(|c| map(c).unwrap_or(c)).collect::());
+ }
+ "hat" | "widehat" | "check" | "breve" | "acute" | "grave" | "vec" | "dot" | "ddot"
+ | "tilde" | "widetilde" | "bar" | "overline" | "underline" | "overrightarrow" => {
+ let a = self.arg(cur, depth).flatten();
+ out.push_str(&accent(&name, &a));
+ }
+ "not" => {
+ cur.skip_ws();
+ let next = self.arg(cur, depth).flatten();
+ out.push_str(&match next.as_str() {
+ "=" => "≠".to_string(),
+ "∈" => "∉".to_string(),
+ "⊂" => "⊄".to_string(),
+ "≡" => "≢".to_string(),
+ _ => format!("{next}\u{338}"),
+ });
+ }
+ "pmod" => {
+ let a = self.arg(cur, depth).flatten();
+ out.push_str(&format!(" (mod {a})"));
+ }
+ "bmod" | "mod" => out.push_str(" mod "),
+ "color" => {
+ cur.raw_arg();
+ }
+ "textcolor" | "colorbox" => {
+ cur.raw_arg();
+ let a = self.arg(cur, depth);
+ out.append(a);
+ }
+ "label" => {
+ cur.raw_arg();
+ }
+ "tag" => {
+ let a = cur.raw_arg();
+ out.push_str(&format!(" ({a})"));
+ }
+ "begin" => {
+ let env = cur.raw_arg();
+ let block = self.environment(cur, env.trim(), depth);
+ out.append(block);
+ }
+ _ => {
+ if let Some(sym) = symbol(&name) {
+ out.push_str(sym);
+ } else if is_function(&name) {
+ out.push_str(&name);
+ } else {
+ // Unknown: kept as written, brace arguments included.
+ out.push_str(&format!("\\{name}"));
+ while cur.peek() == Some('{') {
+ cur.i += 1;
+ let g = self.group(cur, depth).flatten();
+ out.push_str(&format!("{{{g}}}"));
+ }
+ }
+ }
+ }
+ }
+
+ /// `\begin{env}` … `\end{env}`; the `\begin{env}` was just consumed.
+ fn environment(&self, cur: &mut Cursor, env: &str, depth: usize) -> Block {
+ // Column spec of `array`, position of `aligned`: not needed.
+ if env == "array" {
+ cur.raw_arg();
+ }
+ cur.skip_ws();
+ if cur.peek() == Some('[') {
+ while cur.peek().is_some_and(|c| c != ']') {
+ cur.i += 1;
+ }
+ cur.i = (cur.i + 1).min(cur.s.len());
+ }
+ let body = end_of_environment(cur);
+ if depth + 1 > MAX_DEPTH {
+ return Block::text(format!("\\begin{{{env}}}{body}\\end{{{env}}}"));
+ }
+ let rows = split_top(&body);
+ if self.inline {
+ let (l, r) = match env {
+ "pmatrix" => ("(", ")"),
+ "bmatrix" => ("[", "]"),
+ "Bmatrix" => ("{", "}"),
+ "vmatrix" => ("|", "|"),
+ "Vmatrix" => ("‖", "‖"),
+ "cases" => ("{ ", ""),
+ _ => ("", ""),
+ };
+ let lines: Vec = rows
+ .iter()
+ .map(|cells| {
+ cells
+ .iter()
+ .map(|c| self.render(c, depth + 1).flatten())
+ .filter(|c| !c.is_empty())
+ .collect::>()
+ .join(" ")
+ })
+ .collect();
+ return Block::text(format!("{l}{}{r}", lines.join("; ")));
+ }
+ self.grid(env, &rows, depth + 1)
+ }
+
+ /// Lay out rows of cells for display: columns padded by the
+ /// environment's alignment, brackets drawn around matrices and cases.
+ fn grid(&self, env: &str, rows: &[Vec], depth: usize) -> Block {
+ let cells: Vec> = rows
+ .iter()
+ .map(|r| r.iter().map(|c| self.render(c, depth).flatten()).collect())
+ .collect();
+ let ncols = cells.iter().map(|r| r.len()).max().unwrap_or(0);
+ let mut widths = vec![0usize; ncols];
+ for r in &cells {
+ for (j, c) in r.iter().enumerate() {
+ widths[j] = widths[j].max(c.width());
+ }
+ }
+ #[derive(Clone, Copy)]
+ enum Align {
+ Left,
+ Right,
+ Center,
+ }
+ let aligned = matches!(
+ env,
+ "aligned"
+ | "align"
+ | "align*"
+ | "alignat"
+ | "alignat*"
+ | "split"
+ | "eqnarray"
+ | "eqnarray*"
+ | "flalign"
+ | "flalign*"
+ );
+ let (align_of, gap): (Box Align>, &str) = if aligned {
+ // `lhs &= rhs`: right, left, right, left, …
+ (
+ Box::new(|j| {
+ if j % 2 == 0 {
+ Align::Right
+ } else {
+ Align::Left
+ }
+ }),
+ " ",
+ )
+ } else if env == "cases" {
+ (Box::new(|_| Align::Left), " ")
+ } else {
+ (Box::new(|_| Align::Center), " ")
+ };
+ let mut lines: Vec = cells
+ .iter()
+ .map(|r| {
+ let mut line = String::new();
+ for (j, w) in widths.iter().enumerate() {
+ let c = r.get(j).map(String::as_str).unwrap_or("");
+ // An empty aligned cell (`&= b` with nothing before) adds
+ // no gap.
+ if j > 0 && !(aligned && c.is_empty()) {
+ line.push_str(gap);
+ }
+ let free = w.saturating_sub(c.width());
+ let (l, rt) = match align_of(j) {
+ Align::Left => (0, free),
+ Align::Right => (free, 0),
+ Align::Center => (free / 2, free - free / 2),
+ };
+ line.push_str(&" ".repeat(l));
+ line.push_str(c);
+ line.push_str(&" ".repeat(rt));
+ }
+ line
+ })
+ .collect();
+ if aligned {
+ // Columns with no content anywhere (`&=` at a line start) leave
+ // leading blanks: remove the common indent.
+ let indent = lines
+ .iter()
+ .map(|l| l.len() - l.trim_start_matches(' ').len())
+ .min()
+ .unwrap_or(0);
+ for l in &mut lines {
+ l.drain(..indent);
+ }
+ }
+ if lines.is_empty() {
+ lines.push(String::new());
+ }
+ let n = lines.len();
+ let brackets = match env {
+ "pmatrix" => Some((["(", "⎛", "⎜", "⎝"], [")", "⎞", "⎟", "⎠"])),
+ "bmatrix" => Some((["[", "⎡", "⎢", "⎣"], ["]", "⎤", "⎥", "⎦"])),
+ "Bmatrix" => Some((["{", "⎧", "⎪", "⎩"], ["}", "⎫", "⎪", "⎭"])),
+ "vmatrix" => Some((["|", "│", "│", "│"], ["|", "│", "│", "│"])),
+ "Vmatrix" => Some((["‖", "‖", "‖", "‖"], ["‖", "‖", "‖", "‖"])),
+ "cases" => Some((["{", "⎧", "⎪", "⎩"], ["", "", "", ""])),
+ _ => None,
+ };
+ let w = lines.iter().map(|l| l.width()).max().unwrap_or(0);
+ let rows = match brackets {
+ None => lines,
+ Some((left, right)) => {
+ let brace = env == "cases" || env == "Bmatrix";
+ lines
+ .iter()
+ .enumerate()
+ .map(|(k, l)| {
+ let piece = |set: [&'static str; 4]| -> &'static str {
+ if n == 1 {
+ set[0]
+ } else if k == 0 {
+ set[1]
+ } else if k == n - 1 {
+ set[3]
+ } else if brace && k == (n - 1) / 2 {
+ // The brace's middle point.
+ match set[1] {
+ "⎧" => "⎨",
+ "⎫" => "⎬",
+ other => other,
+ }
+ } else {
+ set[2]
+ }
+ };
+ let pad = " ".repeat(w.saturating_sub(l.width()));
+ let r = piece(right);
+ if r.is_empty() {
+ format!("{} {l}", piece(left))
+ } else {
+ format!("{} {l}{pad} {r}", piece(left))
+ }
+ })
+ .collect()
+ }
+ };
+ Block {
+ base: (n - 1) / 2,
+ rows,
+ }
+ }
+}
+
+/// Everything up to the `\end{…}` that closes the environment just opened
+/// (nested environments counted); the cursor ends after it. Unclosed: the
+/// rest of the input.
+fn end_of_environment(cur: &mut Cursor) -> String {
+ let start = cur.i;
+ let mut depth = 1usize;
+ while cur.i < cur.s.len() {
+ if cur.s[cur.i] == '\\' {
+ let (name, next) = command_name(&cur.s, cur.i + 1);
+ match name.as_str() {
+ "begin" => depth += 1,
+ "end" => {
+ depth -= 1;
+ if depth == 0 {
+ let body: String = cur.s[start..cur.i].iter().collect();
+ cur.i = next;
+ cur.raw_arg();
+ return body;
+ }
+ }
+ _ => {}
+ }
+ cur.i = next.max(cur.i + 1);
+ continue;
+ }
+ cur.i += 1;
+ }
+ cur.s[start..].iter().collect()
+}
+
+/// Split at top-level `\\` (rows) and `&` (cells); braces and nested
+/// environments are not split. A trailing empty row is dropped.
+fn split_top(src: &str) -> Vec> {
+ let chars: Vec = src.chars().collect();
+ let mut rows: Vec> = Vec::new();
+ let mut cells: Vec = Vec::new();
+ let mut cell = String::new();
+ let (mut braces, mut envs) = (0usize, 0usize);
+ let mut i = 0;
+ while i < chars.len() {
+ let c = chars[i];
+ match c {
+ '\\' => {
+ let (name, next) = command_name(&chars, i + 1);
+ match name.as_str() {
+ "\\" if braces == 0 && envs == 0 => {
+ cells.push(std::mem::take(&mut cell));
+ rows.push(std::mem::take(&mut cells));
+ // `\\[2pt]`: an optional spacing argument.
+ i = next;
+ if chars.get(i) == Some(&'[') {
+ while i < chars.len() && chars[i] != ']' {
+ i += 1;
+ }
+ i += 1;
+ }
+ continue;
+ }
+ "begin" => envs += 1,
+ "end" => envs = envs.saturating_sub(1),
+ _ => {}
+ }
+ cell.extend(&chars[i..next.min(chars.len())]);
+ i = next.max(i + 1);
+ continue;
+ }
+ '{' => braces += 1,
+ '}' => braces = braces.saturating_sub(1),
+ '&' if braces == 0 && envs == 0 => {
+ cells.push(std::mem::take(&mut cell));
+ i += 1;
+ continue;
+ }
+ _ => {}
+ }
+ cell.push(c);
+ i += 1;
+ }
+ cells.push(cell);
+ rows.push(cells);
+ if rows.len() > 1
+ && rows
+ .last()
+ .is_some_and(|r| r.iter().all(|c| c.trim().is_empty()))
+ {
+ rows.pop();
+ }
+ for row in &mut rows {
+ for cell in row.iter_mut() {
+ *cell = cell.trim().to_string();
+ }
+ }
+ rows
+}
+
+/// `x` raised or lowered: Unicode when every character maps, else
+/// `^x` / `^(xy)`.
+fn script(arg: &str, sup: bool) -> String {
+ if arg.is_empty() {
+ return String::new();
+ }
+ if sup && arg.chars().all(|c| c == '′') {
+ return arg.to_string();
+ }
+ if sup {
+ scripts::superscript(arg, false, true)
+ } else {
+ scripts::subscript(arg, false, true)
+ }
+}
+
+/// One token: a run of letters and digits (any script, super/subscripts
+/// included), or something already in parentheses.
+fn is_token(s: &str) -> bool {
+ if s.is_empty() {
+ return true;
+ }
+ if s.starts_with('(') && s.ends_with(')') {
+ // Only when the opening parenthesis closes at the very end.
+ let mut depth = 0i32;
+ for (k, c) in s.char_indices() {
+ match c {
+ '(' => depth += 1,
+ ')' => {
+ depth -= 1;
+ if depth == 0 {
+ return k == s.len() - 1;
+ }
+ }
+ _ => {}
+ }
+ }
+ return false;
+ }
+ s.chars().all(|c| {
+ c.is_alphanumeric()
+ || matches!(c, '.' | '′' | '∞' | '∂' | '∇')
+ || ('\u{300}'..='\u{36f}').contains(&c)
+ || ('\u{20d0}'..='\u{20ff}').contains(&c)
+ || scripts_char(c)
+ })
+}
+
+/// A Unicode super/subscript character (they count as part of a token).
+fn scripts_char(c: char) -> bool {
+ "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎".contains(c)
+}
+
+/// A fraction side: in parentheses unless it is one token.
+fn operand(s: &str) -> String {
+ if is_token(s) {
+ s.to_string()
+ } else {
+ format!("({s})")
+ }
+}
+
+/// An accent: a combining mark on a single character (on every character
+/// for bars and underlines), else `name(text)`.
+fn accent(name: &str, text: &str) -> String {
+ let (mark, every) = match name {
+ "hat" | "widehat" => ('\u{302}', false),
+ "check" => ('\u{30c}', false),
+ "breve" => ('\u{306}', false),
+ "acute" => ('\u{301}', false),
+ "grave" => ('\u{300}', false),
+ "vec" | "overrightarrow" => ('\u{20d7}', false),
+ "dot" => ('\u{307}', false),
+ "ddot" => ('\u{308}', false),
+ "tilde" | "widetilde" => ('\u{303}', false),
+ "bar" => ('\u{304}', false),
+ "overline" => ('\u{305}', true),
+ _ => ('\u{332}', true), // underline
+ };
+ let n = text.chars().count();
+ if n == 1 || (every && n > 0) {
+ text.chars().flat_map(|c| [c, mark]).collect()
+ } else {
+ format!("{name}({text})")
+ }
+}
+
+fn double_struck(c: char) -> Option {
+ Some(match c {
+ 'C' => 'ℂ',
+ 'H' => 'ℍ',
+ 'N' => 'ℕ',
+ 'P' => 'ℙ',
+ 'Q' => 'ℚ',
+ 'R' => 'ℝ',
+ 'Z' => 'ℤ',
+ 'A'..='Z' => char::from_u32(0x1D538 + (c as u32 - 'A' as u32))?,
+ 'a'..='z' => char::from_u32(0x1D552 + (c as u32 - 'a' as u32))?,
+ '0'..='9' => char::from_u32(0x1D7D8 + (c as u32 - '0' as u32))?,
+ _ => return None,
+ })
+}
+
+fn script_letter(c: char) -> Option {
+ Some(match c {
+ 'B' => 'ℬ',
+ 'E' => 'ℰ',
+ 'F' => 'ℱ',
+ 'H' => 'ℋ',
+ 'I' => 'ℐ',
+ 'L' => 'ℒ',
+ 'M' => 'ℳ',
+ 'R' => 'ℛ',
+ 'A'..='Z' => char::from_u32(0x1D49C + (c as u32 - 'A' as u32))?,
+ 'e' => 'ℯ',
+ 'g' => 'ℊ',
+ 'o' => 'ℴ',
+ 'a'..='z' => char::from_u32(0x1D4B6 + (c as u32 - 'a' as u32))?,
+ _ => return None,
+ })
+}
+
+fn fraktur(c: char) -> Option {
+ Some(match c {
+ 'C' => 'ℭ',
+ 'H' => 'ℌ',
+ 'I' => 'ℑ',
+ 'R' => 'ℜ',
+ 'Z' => 'ℨ',
+ 'A'..='Z' => char::from_u32(0x1D504 + (c as u32 - 'A' as u32))?,
+ 'a'..='z' => char::from_u32(0x1D51E + (c as u32 - 'a' as u32))?,
+ _ => return None,
+ })
+}
+
+/// Named functions set upright in LaTeX: shown as their name.
+fn is_function(name: &str) -> bool {
+ matches!(
+ name,
+ "sin"
+ | "cos"
+ | "tan"
+ | "cot"
+ | "sec"
+ | "csc"
+ | "arcsin"
+ | "arccos"
+ | "arctan"
+ | "sinh"
+ | "cosh"
+ | "tanh"
+ | "coth"
+ | "log"
+ | "ln"
+ | "lg"
+ | "exp"
+ | "lim"
+ | "liminf"
+ | "limsup"
+ | "max"
+ | "min"
+ | "sup"
+ | "inf"
+ | "det"
+ | "dim"
+ | "ker"
+ | "gcd"
+ | "deg"
+ | "arg"
+ | "hom"
+ | "Pr"
+ )
+}
+
+/// A symbol command's Unicode character.
+fn symbol(name: &str) -> Option<&'static str> {
+ Some(match name {
+ // Greek, lower case
+ "alpha" => "α",
+ "beta" => "β",
+ "gamma" => "γ",
+ "delta" => "δ",
+ "epsilon" => "ϵ",
+ "varepsilon" => "ε",
+ "zeta" => "ζ",
+ "eta" => "η",
+ "theta" => "θ",
+ "vartheta" => "ϑ",
+ "iota" => "ι",
+ "kappa" => "κ",
+ "lambda" => "λ",
+ "mu" => "μ",
+ "nu" => "ν",
+ "xi" => "ξ",
+ "omicron" => "ο",
+ "pi" => "π",
+ "varpi" => "ϖ",
+ "rho" => "ρ",
+ "varrho" => "ϱ",
+ "sigma" => "σ",
+ "varsigma" => "ς",
+ "tau" => "τ",
+ "upsilon" => "υ",
+ "phi" => "ϕ",
+ "varphi" => "φ",
+ "chi" => "χ",
+ "psi" => "ψ",
+ "omega" => "ω",
+ // Greek, capitals
+ "Gamma" => "Γ",
+ "Delta" => "Δ",
+ "Theta" => "Θ",
+ "Lambda" => "Λ",
+ "Xi" => "Ξ",
+ "Pi" => "Π",
+ "Sigma" => "Σ",
+ "Upsilon" => "Υ",
+ "Phi" => "Φ",
+ "Psi" => "Ψ",
+ "Omega" => "Ω",
+ // Binary operators
+ "times" => "×",
+ "cdot" => "·",
+ "div" => "÷",
+ "pm" => "±",
+ "mp" => "∓",
+ "ast" => "∗",
+ "star" => "⋆",
+ "circ" => "∘",
+ "bullet" => "•",
+ "oplus" => "⊕",
+ "ominus" => "⊖",
+ "otimes" => "⊗",
+ "odot" => "⊙",
+ "wedge" | "land" => "∧",
+ "vee" | "lor" => "∨",
+ "cup" => "∪",
+ "cap" => "∩",
+ "setminus" => "∖",
+ // Relations
+ "leq" | "le" => "≤",
+ "geq" | "ge" => "≥",
+ "neq" | "ne" => "≠",
+ "approx" => "≈",
+ "equiv" => "≡",
+ "sim" => "∼",
+ "simeq" => "≃",
+ "cong" => "≅",
+ "propto" => "∝",
+ "ll" => "≪",
+ "gg" => "≫",
+ "prec" => "≺",
+ "succ" => "≻",
+ "perp" => "⊥",
+ "parallel" => "∥",
+ "mid" => "∣",
+ "in" => "∈",
+ "notin" => "∉",
+ "ni" => "∋",
+ "subset" => "⊂",
+ "subseteq" => "⊆",
+ "supset" => "⊃",
+ "supseteq" => "⊇",
+ "models" => "⊨",
+ "vdash" => "⊢",
+ // Arrows
+ "to" | "rightarrow" => "→",
+ "leftarrow" | "gets" => "←",
+ "leftrightarrow" => "↔",
+ "Rightarrow" => "⇒",
+ "Leftarrow" => "⇐",
+ "Leftrightarrow" => "⇔",
+ "implies" | "Longrightarrow" => "⟹",
+ "impliedby" | "Longleftarrow" => "⟸",
+ "iff" | "Longleftrightarrow" => "⟺",
+ "longrightarrow" => "⟶",
+ "longleftarrow" => "⟵",
+ "mapsto" => "↦",
+ "uparrow" => "↑",
+ "downarrow" => "↓",
+ "nearrow" => "↗",
+ "searrow" => "↘",
+ // Big operators
+ "sum" => "∑",
+ "prod" => "∏",
+ "coprod" => "∐",
+ "int" => "∫",
+ "iint" => "∬",
+ "iiint" => "∭",
+ "oint" => "∮",
+ "bigcup" => "⋃",
+ "bigcap" => "⋂",
+ "bigoplus" => "⨁",
+ "bigotimes" => "⨂",
+ // Logic, sets, misc
+ "forall" => "∀",
+ "exists" => "∃",
+ "nexists" => "∄",
+ "neg" | "lnot" => "¬",
+ "emptyset" | "varnothing" => "∅",
+ "infty" => "∞",
+ "partial" => "∂",
+ "nabla" => "∇",
+ "hbar" => "ℏ",
+ "ell" => "ℓ",
+ "Re" => "ℜ",
+ "Im" => "ℑ",
+ "aleph" => "ℵ",
+ "wp" => "℘",
+ "angle" => "∠",
+ "triangle" => "△",
+ "square" => "□",
+ "degree" => "°",
+ "prime" => "′",
+ "dagger" => "†",
+ "ldots" | "dots" | "dotsc" | "dotsb" => "…",
+ "cdots" => "⋯",
+ "vdots" => "⋮",
+ "ddots" => "⋱",
+ "therefore" => "∴",
+ "because" => "∵",
+ // Delimiters
+ "langle" => "⟨",
+ "rangle" => "⟩",
+ "lceil" => "⌈",
+ "rceil" => "⌉",
+ "lfloor" => "⌊",
+ "rfloor" => "⌋",
+ "lbrace" | "{" => "{",
+ "rbrace" | "}" => "}",
+ "lvert" | "rvert" | "vert" => "|",
+ "lVert" | "rVert" | "Vert" | "|" => "‖",
+ "backslash" => "\\",
+ // Escaped characters
+ "%" => "%",
+ "$" => "$",
+ "#" => "#",
+ "&" => "&",
+ "_" => "_",
+ _ => return None,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn inline(s: &str) -> String {
+ render_inline(s, false)
+ }
+
+ fn display(s: &str) -> Vec {
+ render_display(s, false)
+ }
+
+ #[test]
+ fn greek_letters_and_capitals() {
+ assert_eq!(inline(r"\alpha + \beta = \Gamma"), "α + β = Γ");
+ assert_eq!(inline(r"\omega\Omega\pi"), "ωΩπ");
+ }
+
+ #[test]
+ fn operators_relations_and_arrows() {
+ assert_eq!(
+ inline(r"a \times b \cdot c \div d \pm e \mp f"),
+ "a × b · c ÷ d ± e ∓ f"
+ );
+ assert_eq!(
+ inline(
+ r"\leq \geq \neq \approx \equiv \to \rightarrow \leftarrow \Rightarrow \Leftrightarrow"
+ ),
+ "≤ ≥ ≠ ≈ ≡ → → ← ⇒ ⇔"
+ );
+ assert_eq!(
+ inline(
+ r"\infty \partial \nabla \sum \prod \int \in \notin \subset \subseteq \cup \cap"
+ ),
+ "∞ ∂ ∇ ∑ ∏ ∫ ∈ ∉ ⊂ ⊆ ∪ ∩"
+ );
+ assert_eq!(
+ inline(r"\forall \exists \emptyset \ldots \cdots \dots \langle \rangle \circ \degree"),
+ "∀ ∃ ∅ … ⋯ … ⟨ ⟩ ∘ °"
+ );
+ }
+
+ #[test]
+ fn blackboard_calligraphic_and_fraktur() {
+ assert_eq!(inline(r"\mathbb{R} \mathbb{N} \mathbb{A}"), "ℝ ℕ 𝔸");
+ assert_eq!(inline(r"\mathcal{L} \mathcal{A}"), "ℒ 𝒜");
+ assert_eq!(inline(r"\mathfrak{g}"), "𝔤");
+ // No code point for punctuation: kept.
+ assert_eq!(inline(r"\mathbb{+}"), "+");
+ }
+
+ #[test]
+ fn text_and_roman_are_plain() {
+ assert_eq!(inline(r"\text{if } x > 0"), "if x > 0");
+ assert_eq!(inline(r"\mathrm{d}x"), "dx");
+ assert_eq!(inline(r"\operatorname{rank}(A)"), "rank(A)");
+ // \text keeps its content literal: no math conversion inside.
+ assert_eq!(inline(r"\text{a^b}"), "a^b");
+ }
+
+ #[test]
+ fn left_right_and_spacing_dropped() {
+ assert_eq!(inline(r"\left( x \right)"), "( x )");
+ assert_eq!(inline(r"\left. f \right|_0"), "f |₀");
+ assert_eq!(inline(r"f(x)\,dx"), "f(x) dx");
+ assert_eq!(inline(r"a\quad b\qquad c\!d\;e"), "a b cd e");
+ }
+
+ #[test]
+ fn superscripts_and_subscripts() {
+ assert_eq!(inline("x^2"), "x²");
+ assert_eq!(inline("x_i"), "xᵢ");
+ assert_eq!(inline("x^{n+1}"), "xⁿ⁺¹");
+ assert_eq!(inline("a_{ij}"), "aᵢⱼ");
+ assert_eq!(inline("x^{n + 1}"), "xⁿ⁺¹", "spaces are not significant");
+ // No superscript q / subscript b: the fallback forms.
+ assert_eq!(inline("x^q"), "x^q");
+ assert_eq!(inline("x^{qq}"), "x^(qq)");
+ assert_eq!(inline("y_{ab}"), "y_(ab)");
+ assert_eq!(inline(r"e^{\alpha}"), "eᵅ");
+ assert_eq!(inline(r"f'(x)"), "f′(x)");
+ }
+
+ #[test]
+ fn fractions() {
+ assert_eq!(inline(r"\frac{a}{b}"), "a/b");
+ assert_eq!(inline(r"\frac{a+b}{c}"), "(a+b)/c");
+ assert_eq!(inline(r"\frac{1}{x+1}"), "1/(x+1)");
+ assert_eq!(inline(r"\frac{(a+b)}{(c)}"), "(a+b)/(c)");
+ assert_eq!(inline(r"\frac12"), "1/2");
+ assert_eq!(inline(r"\frac{\partial f}{\partial x}"), "(∂ f)/(∂ x)");
+ assert_eq!(inline(r"\frac{\partial{f}}{\partial{x}}"), "∂f/∂x");
+ }
+
+ #[test]
+ fn roots() {
+ assert_eq!(inline(r"\sqrt{x}"), "√x");
+ assert_eq!(inline(r"\sqrt{x+1}"), "√(x+1)");
+ assert_eq!(inline(r"\sqrt[n]{x}"), "ⁿ√x");
+ assert_eq!(inline(r"\sqrt[3]{8}"), "³√8");
+ assert_eq!(inline(r"\sqrt[q]{8}"), "(q)√8");
+ }
+
+ #[test]
+ fn unknown_commands_are_kept() {
+ assert_eq!(inline(r"\foo x"), r"\foo x");
+ assert_eq!(inline(r"\binom{n}{k}"), r"\binom{n}{k}");
+ assert_eq!(inline(r"\unknown{a^2}"), r"\unknown{a²}");
+ }
+
+ #[test]
+ fn accents_and_negation() {
+ assert_eq!(inline(r"\hat{x}"), "x\u{302}");
+ assert_eq!(inline(r"\vec{v}"), "v\u{20d7}");
+ assert_eq!(inline(r"\overline{AB}"), "A\u{305}B\u{305}");
+ assert_eq!(inline(r"\hat{xy}"), "hat(xy)");
+ assert_eq!(inline(r"a \not= b"), "a ≠ b");
+ assert_eq!(inline(r"a \not\in B"), "a ∉ B");
+ }
+
+ #[test]
+ fn malformed_input_never_panics() {
+ for src in [
+ "",
+ "\\",
+ "{",
+ "}",
+ "x^",
+ "x_",
+ "^",
+ "\\frac",
+ "\\frac{a",
+ "\\sqrt[",
+ "\\sqrt[3",
+ "\\begin",
+ "\\begin{",
+ "\\begin{matrix}",
+ "\\begin{matrix} a & b \\\\",
+ "\\end{matrix}",
+ "\\left",
+ "\\right",
+ "\\text{",
+ "\\mathbb{",
+ "\\not",
+ "a & b",
+ "\\\\",
+ "\\\\[",
+ "x^{",
+ "{{{}",
+ "}}}{",
+ "\\begin{array}",
+ "\\tag{",
+ "é^ü_ß",
+ "\u{0}\u{1b}",
+ ] {
+ let _ = render_inline(src, false);
+ let _ = render_display(src, false);
+ let _ = render_inline(src, true);
+ let _ = render_display(src, true);
+ }
+ }
+
+ #[test]
+ fn deep_nesting_is_capped() {
+ let deep = format!("{}x{}", "{".repeat(10_000), "}".repeat(10_000));
+ assert!(inline(&deep).contains('x'));
+ let fracs = r"\frac{".repeat(10_000);
+ let _ = inline(&fracs);
+ let roots = format!("{}x{}", r"\sqrt{".repeat(5000), "}".repeat(5000));
+ assert!(inline(&roots).contains('x'));
+ let envs = format!(
+ "{}a{}",
+ r"\begin{pmatrix}".repeat(3000),
+ r"\end{pmatrix}".repeat(3000)
+ );
+ assert!(display(&envs).concat().contains('a'));
+ let scripts = "x^".repeat(10_000);
+ let _ = inline(&scripts);
+ }
+
+ #[test]
+ fn ascii_mode_shows_cleaned_source() {
+ assert_eq!(
+ render_inline(r"\left( \frac{a}{b} \right)\,dx \quad \alpha", true),
+ r"( \frac{a}{b} ) dx \alpha"
+ );
+ assert_eq!(render_inline(r"\left. x \right|", true), "x |");
+ assert_eq!(
+ render_display("\\begin{cases}\n a & b \\\\\n c & d\n\\end{cases}", true),
+ ["\\begin{cases}", "a & b \\\\", "c & d", "\\end{cases}"]
+ );
+ }
+
+ // ── Real formulas, exact output ──
+
+ #[test]
+ fn quadratic_formula() {
+ assert_eq!(
+ inline(r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"),
+ "x = (-b ± √(b² - 4ac))/2a"
+ );
+ }
+
+ #[test]
+ fn eulers_identity() {
+ assert_eq!(inline(r"e^{i\pi} + 1 = 0"), "e^(iπ) + 1 = 0");
+ }
+
+ #[test]
+ fn sum_with_limits() {
+ assert_eq!(
+ inline(r"\sum_{i=1}^{n} i = \frac{n(n+1)}{2}"),
+ "∑ᵢ₌₁ⁿ i = (n(n+1))/2"
+ );
+ }
+
+ #[test]
+ fn integral() {
+ assert_eq!(
+ inline(r"\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}"),
+ "∫₀^∞ e^(-x²) dx = (√π)/2"
+ );
+ }
+
+ #[test]
+ fn matrix_display() {
+ assert_eq!(
+ display(r"A = \begin{pmatrix} a & b \\ c & d \end{pmatrix}"),
+ ["A = ⎛ a b ⎞", " ⎝ c d ⎠"]
+ );
+ assert_eq!(
+ display(r"\begin{bmatrix} 1 & 0 & 0 \\ 0 & 10 & 0 \\ 0 & 0 & 1 \end{bmatrix}"),
+ ["⎡ 1 0 0 ⎤", "⎢ 0 10 0 ⎥", "⎣ 0 0 1 ⎦"]
+ );
+ assert_eq!(
+ inline(r"\begin{pmatrix} a & b \\ c & d \end{pmatrix}"),
+ "(a b; c d)"
+ );
+ }
+
+ #[test]
+ fn cases_display() {
+ assert_eq!(
+ display(
+ r"|x| = \begin{cases} x & \text{if } x \geq 0 \\ -x & \text{otherwise} \end{cases}"
+ ),
+ ["|x| = ⎧ x if x ≥ 0", " ⎩ -x otherwise"]
+ );
+ assert_eq!(
+ display(r"\begin{cases} 1 \\ 2 \\ 3 \end{cases}"),
+ ["⎧ 1", "⎨ 2", "⎩ 3"]
+ );
+ }
+
+ #[test]
+ fn aligned_display() {
+ assert_eq!(
+ display(r"\begin{aligned} f(x) &= (x+1)^2 \\ &= x^2 + 2x + 1 \end{aligned}"),
+ ["f(x) = (x+1)²", " = x² + 2x + 1"]
+ );
+ // Top-level rows and alignment without an environment.
+ assert_eq!(display(r"a &= b \\ cc &= d"), [" a = b", "cc = d"]);
+ }
+}
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 6eda69d..7a5e904 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -1,5 +1,8 @@
+mod frontmatter;
pub mod html;
+mod math;
pub mod mermaid;
+mod scripts;
pub mod table;
use crate::image::ImageMode;
@@ -349,6 +352,10 @@ fn layout_node<'a>(node: &'a AstNode<'a>, ctx: &LayoutContext, lines: &mut Vec {
+ drop(data);
+ layout_description_list(node, ctx, lines);
+ }
NodeValue::Table(..) => {
drop(data);
table::layout_table(node, ctx.theme, ctx.width, ctx.margin, lines);
@@ -557,12 +564,79 @@ fn layout_paragraph<'a>(node: &'a AstNode<'a>, ctx: &LayoutContext, lines: &mut
}
}
+ // `$$…$$` gets lines of its own: the paragraph is split around it.
+ let is_display =
+ |n: &'a AstNode<'a>| matches!(&n.data.borrow().value, NodeValue::Math(m) if m.display_math);
+ if node.children().any(is_display) {
+ let mut run: Vec<&'a AstNode<'a>> = Vec::new();
+ let flush = |run: &mut Vec<&'a AstNode<'a>>, lines: &mut Vec| {
+ let mut spans = Vec::new();
+ collect_inline_seq(run.drain(..), ctx, &mut spans, &SpanStyle::default(), 0);
+ // The line breaks around the display block are not text.
+ while spans.first().is_some_and(|s| s.text.trim().is_empty()) {
+ spans.remove(0);
+ }
+ while spans.last().is_some_and(|s| s.text.trim().is_empty()) {
+ spans.pop();
+ }
+ if let Some(first) = spans.first_mut() {
+ first.text = first.text.trim_start().to_string();
+ }
+ if let Some(last) = spans.last_mut() {
+ last.text = last.text.trim_end().to_string();
+ }
+ if !spans.is_empty() {
+ lines.extend(wrap_spans(spans, ctx.width, ctx.indent, ctx.margin));
+ }
+ };
+ for child in node.children() {
+ if is_display(child) {
+ flush(&mut run, lines);
+ if let NodeValue::Math(ref m) = child.data.borrow().value {
+ layout_display_math(&m.literal, ctx, lines);
+ }
+ } else {
+ run.push(child);
+ }
+ }
+ flush(&mut run, lines);
+ add_spacing(ctx, lines);
+ return;
+ }
+
let spans = collect_inline_spans(node, ctx);
let wrapped = wrap_spans(spans, ctx.width, ctx.indent, ctx.margin);
lines.extend(wrapped);
add_spacing(ctx, lines);
}
+/// Display math (`$$…$$`, ```` ```math ````): the Unicode approximation on
+/// lines of its own, indented, in the code colour. Lines too wide for the
+/// view are broken, never clipped.
+fn layout_display_math(latex: &str, ctx: &LayoutContext, lines: &mut Vec) {
+ const INDENT: usize = 4;
+ let style = SpanStyle {
+ fg: Some(ctx.theme.colors.code_fg.clone()),
+ ..Default::default()
+ };
+ let room = ctx.width.saturating_sub(INDENT).max(1);
+ for row in math::render_display(latex, crate::glyphs::current().ascii) {
+ for part in split_by_width(&row, room) {
+ let mut line = StyledLine::new();
+ ctx.add_margin(&mut line);
+ line.push(StyledSpan {
+ text: " ".repeat(INDENT),
+ style: SpanStyle::default(),
+ });
+ line.push(StyledSpan {
+ text: part,
+ style: style.clone(),
+ });
+ lines.push(line);
+ }
+ }
+}
+
/// An image a paragraph can render as a block: source, alt text, and the
/// link destination when the image is wrapped in one (`[](url)`
/// badges/linked screenshots).
@@ -915,6 +989,29 @@ fn layout_code_block(info: &str, literal: &str, ctx: &LayoutContext, lines: &mut
let lang = info.split_whitespace().next().unwrap_or("");
let start_line = lines.len();
+ // `--frontmatter`: the metadata box (see `parser::frontmatter::prepare`).
+ if lang == crate::parser::frontmatter::FENCE_INFO {
+ let format = info.split_whitespace().nth(1).unwrap_or("");
+ frontmatter::layout_frontmatter(format, literal, ctx, lines);
+ return;
+ }
+
+ // ```math: display math. `c` still copies the LaTeX source.
+ if lang == "math" {
+ let start_line = lines.len();
+ layout_display_math(literal, ctx, lines);
+ if ctx.record_headings && lines.len() > start_line {
+ ctx.code_blocks.borrow_mut().push(CodeBlockSpec {
+ line_index: start_line,
+ rows: lines.len() - start_line,
+ lang: "math".to_string(),
+ source: literal.trim_end_matches('\n').to_string(),
+ });
+ }
+ add_spacing(ctx, lines);
+ return;
+ }
+
// Mermaid diagrams get special rendering
if lang == "mermaid" {
let mermaid_lines = mermaid::render_mermaid(literal, ctx.theme, ctx.width, ctx.margin);
@@ -1107,6 +1204,10 @@ fn layout_flattened<'a>(node: &'a AstNode<'a>, ctx: &LayoutContext, lines: &mut
| NodeValue::Item(_)
| NodeValue::TaskItem(_)
| NodeValue::FootnoteDefinition(_)
+ | NodeValue::DescriptionList
+ | NodeValue::DescriptionItem(_)
+ | NodeValue::DescriptionTerm
+ | NodeValue::DescriptionDetails
);
if is_container {
stack.extend(child.reverse_children());
@@ -1126,18 +1227,18 @@ fn layout_blockquote<'a>(
layout_flattened(node, ctx, lines);
return;
}
- // Check first child for admonition pattern [!TYPE]
+ // GitHub alert / Obsidian callout: `[!TYPE]` opening the first paragraph.
let admonition = detect_admonition(node);
// Choose bar color based on depth or admonition type
let bar_color = if let Some(ref adm) = admonition {
- match adm.as_str() {
- "NOTE" => &ctx.theme.colors.admonition_note,
- "TIP" => &ctx.theme.colors.admonition_tip,
- "IMPORTANT" => &ctx.theme.colors.admonition_important,
- "WARNING" => &ctx.theme.colors.admonition_warning,
- "CAUTION" => &ctx.theme.colors.admonition_caution,
- _ => &ctx.theme.colors.blockquote_bar,
+ match adm.kind {
+ AdmonitionKind::Note => &ctx.theme.colors.admonition_note,
+ AdmonitionKind::Tip => &ctx.theme.colors.admonition_tip,
+ AdmonitionKind::Important => &ctx.theme.colors.admonition_important,
+ AdmonitionKind::Warning => &ctx.theme.colors.admonition_warning,
+ AdmonitionKind::Caution => &ctx.theme.colors.admonition_caution,
+ AdmonitionKind::Other => &ctx.theme.colors.blockquote_bar,
}
} else {
// Different colors for nested blockquote depths
@@ -1149,37 +1250,6 @@ fn layout_blockquote<'a>(
}
};
- // Render admonition header if detected
- if let Some(ref adm_type) = admonition {
- let icons = crate::glyphs::current().admonition;
- let icon = match adm_type.as_str() {
- "NOTE" => icons[0],
- "TIP" => icons[1],
- "IMPORTANT" => icons[2],
- "WARNING" => icons[3],
- "CAUTION" => icons[4],
- _ => icons[5],
- };
- let mut header_line = StyledLine::new();
- ctx.add_margin(&mut header_line);
- header_line.push(StyledSpan {
- text: crate::glyphs::current().quote_bar.to_string(),
- style: SpanStyle {
- fg: Some(bar_color.clone()),
- ..Default::default()
- },
- });
- header_line.push(StyledSpan {
- text: format!("{icon}{adm_type}"),
- style: SpanStyle {
- fg: Some(bar_color.clone()),
- bold: true,
- ..Default::default()
- },
- });
- lines.push(header_line);
- }
-
// Render children into temporary buffer
let inner_ctx = LayoutContext {
theme: ctx.theme,
@@ -1201,6 +1271,39 @@ fn layout_blockquote<'a>(
source: ctx.source,
};
let mut inner_lines = Vec::new();
+ // Header: the icon and the custom title, or the type when there is none.
+ if let Some(ref adm) = admonition {
+ let icons = crate::glyphs::current().admonition;
+ let icon = icons[adm.kind as usize];
+ let header_style = SpanStyle {
+ fg: Some(bar_color.clone()),
+ bold: true,
+ ..Default::default()
+ };
+ let mut spans = vec![StyledSpan {
+ text: icon.to_string(),
+ style: header_style.clone(),
+ }];
+ if adm.title_head.is_empty() && adm.title_nodes.is_empty() {
+ spans.push(StyledSpan {
+ text: adm.name.clone(),
+ style: header_style.clone(),
+ });
+ } else {
+ spans.push(StyledSpan {
+ text: adm.title_head.clone(),
+ style: header_style.clone(),
+ });
+ collect_inline_seq(
+ adm.title_nodes.iter().copied(),
+ &inner_ctx,
+ &mut spans,
+ &header_style,
+ 0,
+ );
+ }
+ inner_lines.extend(wrap_spans(spans, inner_ctx.width, 0, 0));
+ }
let mut skip_first = admonition.is_some();
for child in node.children() {
let child_data = child.data.borrow();
@@ -1213,15 +1316,20 @@ fn layout_blockquote<'a>(
if skip_first {
// For admonitions, we render first paragraph without the [!TYPE] prefix
skip_first = false;
- if let NodeValue::Paragraph = &child.data.borrow().value {
- let spans = collect_inline_spans(child, &inner_ctx);
- // Filter out the admonition marker text
- let filtered: Vec = spans
- .into_iter()
- .filter(|s| !s.text.starts_with("[!"))
- .collect();
- if !filtered.is_empty() {
- let wrapped = wrap_spans(filtered, inner_ctx.width, 0, 0);
+ if let (NodeValue::Paragraph, Some(adm)) =
+ (&child.data.borrow().value, admonition.as_ref())
+ {
+ // The paragraph minus its first line (marker and title).
+ let mut spans = Vec::new();
+ collect_inline_seq(
+ child.children().skip(adm.body_skip),
+ &inner_ctx,
+ &mut spans,
+ &SpanStyle::default(),
+ 0,
+ );
+ if !spans.is_empty() {
+ let wrapped = wrap_spans(spans, inner_ctx.width, 0, 0);
inner_lines.extend(wrapped);
add_spacing(&inner_ctx, &mut inner_lines);
}
@@ -1262,26 +1370,101 @@ fn layout_blockquote<'a>(
}
}
-/// Detect GitHub-style admonition: > [!NOTE], > [!WARNING], etc.
-fn detect_admonition<'a>(node: &'a AstNode<'a>) -> Option {
- if let Some(child) = node.children().next() {
- let data = child.data.borrow();
- if let NodeValue::Paragraph = &data.value {
- drop(data);
- if let Some(inline) = child.children().next() {
- let idata = inline.data.borrow();
- if let NodeValue::Text(ref text) = idata.value {
- let trimmed = text.trim();
- if trimmed.starts_with("[!") && trimmed.contains(']') {
- let end = trimmed.find(']').unwrap();
- let adm_type = &trimmed[2..end];
- return Some(adm_type.to_uppercase());
- }
- }
+/// The colour and icon family of an admonition; the discriminant indexes
+/// `Glyphs::admonition`.
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum AdmonitionKind {
+ Note = 0,
+ Tip = 1,
+ Important = 2,
+ Warning = 3,
+ Caution = 4,
+ Other = 5,
+}
+
+impl AdmonitionKind {
+ /// GitHub's five alert types plus Obsidian's callout types and aliases.
+ fn of(name: &str) -> Self {
+ match name.to_ascii_lowercase().as_str() {
+ "note" | "info" | "todo" | "abstract" | "summary" | "tldr" => Self::Note,
+ "tip" | "hint" | "success" | "check" | "done" => Self::Tip,
+ "important" => Self::Important,
+ "warning" | "attention" | "question" | "help" | "faq" => Self::Warning,
+ "caution" | "danger" | "error" | "failure" | "fail" | "missing" | "bug" => {
+ Self::Caution
}
+ _ => Self::Other,
}
}
- None
+}
+
+/// A blockquote opening with `[!TYPE]`: a GitHub alert (`> [!NOTE]`) or an
+/// Obsidian callout (`> [!info]- Custom title`, the fold marker ignored).
+struct Admonition<'a> {
+ kind: AdmonitionKind,
+ /// The type as written, upper-cased: the header when there is no title.
+ name: String,
+ /// The custom title's start: the marker's text node after the marker.
+ title_head: String,
+ /// The inlines after that on the marker's line (the rest of the title).
+ title_nodes: Vec<&'a AstNode<'a>>,
+ /// How many of the paragraph's inlines the header line used, its line
+ /// break included: the body starts after them.
+ body_skip: usize,
+}
+
+/// Detect `> [!TYPE]` / `> [!TYPE]+ Title` at the start of a blockquote.
+fn detect_admonition<'a>(node: &'a AstNode<'a>) -> Option> {
+ let para = node.children().next()?;
+ if !matches!(para.data.borrow().value, NodeValue::Paragraph) {
+ return None;
+ }
+ let first = para.children().next()?;
+ let text = match &first.data.borrow().value {
+ NodeValue::Text(t) => t.clone(),
+ _ => return None,
+ };
+ let rest = text.trim_start().strip_prefix("[!")?;
+ let end = rest.find(']')?;
+ let name = &rest[..end];
+ if name.is_empty()
+ || !name
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
+ {
+ return None;
+ }
+ let after = rest[end + 1..]
+ .strip_prefix(['+', '-'])
+ .unwrap_or(&rest[end + 1..]);
+
+ // The title is everything up to the paragraph's first line break.
+ let mut title_nodes = Vec::new();
+ let mut body_skip = 1;
+ let title_head = after.trim_start().to_string();
+ let mut siblings = para.children().skip(1);
+ let mut ended = false;
+ for sib in siblings.by_ref() {
+ body_skip += 1;
+ if matches!(
+ sib.data.borrow().value,
+ NodeValue::SoftBreak | NodeValue::LineBreak
+ ) {
+ ended = true;
+ break;
+ }
+ title_nodes.push(sib);
+ }
+ if !ended {
+ body_skip = usize::MAX;
+ }
+ Some(Admonition {
+ kind: AdmonitionKind::of(name),
+ name: name.to_uppercase(),
+ title_head,
+ title_nodes,
+ body_skip,
+ })
}
fn layout_list<'a>(
@@ -1403,6 +1586,101 @@ fn layout_list<'a>(
add_spacing(ctx, lines);
}
+/// A definition list (`Term` / `: definition`): each term bold on its own
+/// line, each definition indented under it, its first line marked with the
+/// list pointer (`▸`, `>` in ASCII mode). Nests like a list: past
+/// `MAX_BLOCK_DEPTH` it is flattened.
+fn layout_description_list<'a>(
+ node: &'a AstNode<'a>,
+ ctx: &LayoutContext,
+ lines: &mut Vec,
+) {
+ if ctx.block_depth >= MAX_BLOCK_DEPTH {
+ layout_flattened(node, ctx, lines);
+ add_spacing(ctx, lines);
+ return;
+ }
+ const INDENT: usize = 4;
+ let inner_ctx = LayoutContext {
+ theme: ctx.theme,
+ width: ctx.width.saturating_sub(INDENT),
+ indent: 0,
+ list_depth: ctx.list_depth,
+ block_depth: ctx.block_depth + 1,
+ spacing: ctx.spacing,
+ margin: 0,
+ syntax_set: ctx.syntax_set,
+ theme_set: ctx.theme_set,
+ base_dir: ctx.base_dir,
+ images: ctx.images,
+ headings: ctx.headings,
+ image_specs: ctx.image_specs,
+ code_blocks: ctx.code_blocks,
+ graphics_font: ctx.graphics_font,
+ record_headings: false,
+ source: ctx.source,
+ };
+ let term_style = SpanStyle {
+ bold: true,
+ fg: Some(ctx.theme.colors.bold.clone()),
+ ..Default::default()
+ };
+ let marker_style = SpanStyle {
+ fg: Some(ctx.theme.colors.list_bullet.clone()),
+ ..Default::default()
+ };
+ let marker = format!(" {} ", crate::glyphs::current().pointer);
+ let items: Vec<_> = node.children().collect();
+ for (i, item) in items.iter().enumerate() {
+ for part in item.children() {
+ let is_term = match part.data.borrow().value {
+ NodeValue::DescriptionTerm => true,
+ NodeValue::DescriptionDetails => false,
+ _ => continue,
+ };
+ if is_term {
+ // The term's paragraph(s), bold, at the list's own indent.
+ for para in part.children() {
+ let mut spans = Vec::new();
+ collect_inlines(para, ctx, &mut spans, &term_style, 0);
+ lines.extend(wrap_spans(spans, ctx.width, 0, ctx.margin));
+ }
+ continue;
+ }
+ let mut detail_lines = Vec::new();
+ for child in part.children() {
+ layout_node(child, &inner_ctx, &mut detail_lines);
+ }
+ trim_trailing_blank(&mut detail_lines);
+ for (j, detail) in detail_lines.into_iter().enumerate() {
+ if j > 0 && detail.spans.iter().all(|s| s.text.trim().is_empty()) {
+ lines.push(StyledLine::empty());
+ continue;
+ }
+ let mut line = StyledLine::new();
+ ctx.add_margin(&mut line);
+ line.push(if j == 0 {
+ StyledSpan {
+ text: marker.clone(),
+ style: marker_style.clone(),
+ }
+ } else {
+ StyledSpan {
+ text: " ".repeat(INDENT),
+ style: SpanStyle::default(),
+ }
+ });
+ line.spans.extend(detail.spans);
+ lines.push(line);
+ }
+ }
+ if i + 1 < items.len() {
+ add_spacing(ctx, lines);
+ }
+ }
+ add_spacing(ctx, lines);
+}
+
fn layout_hr(ctx: &LayoutContext, lines: &mut Vec) {
let width = ctx.width.min(60);
let left_pad = (ctx.width.saturating_sub(width)) / 2;
@@ -1465,10 +1743,22 @@ fn collect_inlines<'a>(
}
return;
}
+ collect_inline_seq(node.children(), ctx, spans, outer_style, depth);
+}
+
+/// `collect_inlines` over a run of sibling inlines (all of a node's children,
+/// or the part of a paragraph after an admonition's title line).
+fn collect_inline_seq<'a>(
+ children: impl Iterator- >,
+ ctx: &LayoutContext,
+ spans: &mut Vec,
+ outer_style: &SpanStyle,
+ depth: usize,
+) {
// Inline HTML arrives one tag per node (``, text, ``), so the
// tags open among these siblings style the siblings that follow them.
let mut html_styles = html::HtmlStyles::default();
- for child in node.children() {
+ for child in children {
let parent_style = &html_styles.style(outer_style).clone();
let data = child.data.borrow();
// Inside an unclosed inline