diff --git a/README.md b/README.md index 70494a6..0203ab8 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ ink --plain README.md Headings, bold, italic, strikethrough, links, blockquotes, lists, task lists, tables, footnotes, horizontal rules — all rendered with proper styling and colors. +Tables follow their column alignment (`:--`, `:-:`, `--:`), wide CJK and emoji cells included. Superscript `x^2^` (or `x2`) and subscript `H2O` come out as `x²` and `H₂O` (as `^(…)`/`_(…)` when a character has no Unicode form). Single and double tildes, `~x~` and `~~x~~`, strike through as on GitHub. `||spoilers||` stay hidden until you select or copy them, and definition lists (`Term` then `: definition`) get a bold term with its definitions indented underneath. + ### Syntax-highlighted code blocks Language-aware highlighting for every major language. Code blocks get clean borders with the language label shown at the top. @@ -164,7 +166,11 @@ Flowcharts, sequence diagrams, pie charts, and Gantt charts rendered as ASCII ar ### GitHub-style admonitions -`[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, and `[!CAUTION]` blocks render with distinct colors and icons. +`[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, and `[!CAUTION]` blocks render with distinct colors and icons. A custom title (`> [!NOTE] Read this first`) replaces the type name, and Obsidian callouts (`> [!info]- Title`, `[!bug]`, `[!example]`, …) map onto the same five colours, fold markers ignored. + +### Frontmatter + +YAML (`---`), TOML (`+++`) and JSON (`{`) frontmatter is hidden by default. With `--frontmatter` (or `frontmatter = true`) it shows as a small key/value box at the top of the document — lists comma-joined, nested values as written — instead of being read as markdown. ### Wikilinks @@ -243,7 +249,7 @@ On the Linux console, a non-UTF-8 locale, a legacy console font or a screen read ### Math and emoji -Inline `$E=mc^2$` and block `$$...$$` math render in code style, and `:emoji:` shortcodes resolve to their glyph (`:rocket:` → 🚀). +Inline `$E=mc^2$`, block `$$...$$` and ```` ```math ```` render as Unicode text: `$x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}$` reads `x = (-b ± √(b² - 4ac))/2a`. Greek letters, operators, `\mathbb{R}` → ℝ, super- and subscripts, fractions, roots, and matrix, `cases` and `aligned` environments (drawn on several lines with brackets) are covered; anything else is shown as written. A lone `$` in prose (`costs $5 and $10`) stays a dollar sign. With `--ascii` the LaTeX source is shown instead. `:emoji:` shortcodes resolve to their glyph (`:rocket:` → 🚀). ### Presentation mode @@ -257,6 +263,8 @@ ink --slides deck.md Point `ink --plain` at a long document on an interactive terminal and it pages the output through `$PAGER` (default `less -R`) — a drop-in markdown replacement for `cat`/`less`. Piped or redirected output prints straight through, so it stays friendly for scripts, fzf previews, and git. Use `--no-pager` to always print directly. When stdout is not a terminal, `ink file.md` behaves like `ink --plain file.md`, as `bat` and `glow` do. +`--line-range` works like `bat`'s: `ink --line-range 40:80 README.md` renders only those lines of the markdown *source* (1-based, inclusive; also `40:`, `:80`, a single line, or several `--line-range`s). A range that cuts through a fenced code block keeps the block intact and highlighted. It always prints plain output. + Color follows `--color ` (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 (`[![alt](img)](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