M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,532 @@
|
||||
//! Core renderer: sequences, commands, scripts, fractions, accents.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use super::cursor::Cursor;
|
||||
use super::environments::render_environment;
|
||||
use super::math_box::MathBox;
|
||||
use super::symbols::{
|
||||
map_mathbb, map_mathbf, map_mathcal, map_mathfrak, symbol, to_subscript, to_superscript,
|
||||
};
|
||||
use super::{MAX_DEPTH, Mode};
|
||||
|
||||
/// Render an atom's source to a flat (single-line) Unicode string.
|
||||
///
|
||||
/// Atoms are arguments to commands (fraction sides, script bodies, accent
|
||||
/// targets); they always render flat — multi-row content inside them joins
|
||||
/// with `; `.
|
||||
pub(super) fn render_atom(atom: &str, depth: usize, mode: Mode) -> String {
|
||||
let mut cursor = Cursor::new(atom);
|
||||
let mut out = MathBox::new(true);
|
||||
render_sequence(&mut cursor, &mut out, depth + 1, mode, None);
|
||||
out.into_lines().concat()
|
||||
}
|
||||
|
||||
/// Core renderer: walks `cursor`, appending Unicode to `out`.
|
||||
///
|
||||
/// `stop_at` optionally terminates the sequence at an unbalanced `}` (used
|
||||
/// when rendering inside a group whose `{` was consumed by the caller).
|
||||
pub(super) fn render_sequence(
|
||||
cursor: &mut Cursor<'_>,
|
||||
out: &mut MathBox,
|
||||
depth: usize,
|
||||
mode: Mode,
|
||||
stop_at: Option<char>,
|
||||
) {
|
||||
while let Some(ch) = cursor.peek() {
|
||||
if Some(ch) == stop_at {
|
||||
cursor.bump();
|
||||
return;
|
||||
}
|
||||
match ch {
|
||||
'\\' => {
|
||||
cursor.bump();
|
||||
render_command(cursor, out, depth, mode);
|
||||
}
|
||||
'{' => {
|
||||
cursor.bump();
|
||||
if depth >= MAX_DEPTH {
|
||||
// Too deep: render the group body flat, without recursing.
|
||||
out.push_str(cursor.read_group_body());
|
||||
} else {
|
||||
// Render the group body into the same box so environments
|
||||
// inside groups keep their 2D layout.
|
||||
let body = cursor.read_group_body();
|
||||
let mut sub = Cursor::new(body);
|
||||
render_sequence(&mut sub, out, depth + 1, mode, None);
|
||||
}
|
||||
}
|
||||
'}' => {
|
||||
// Unbalanced closing brace: drop it.
|
||||
cursor.bump();
|
||||
}
|
||||
'^' => {
|
||||
cursor.bump();
|
||||
render_script(cursor, out, depth, mode, Script::Super);
|
||||
}
|
||||
'_' => {
|
||||
cursor.bump();
|
||||
render_script(cursor, out, depth, mode, Script::Sub);
|
||||
}
|
||||
'~' => {
|
||||
cursor.bump();
|
||||
out.push(' ');
|
||||
}
|
||||
'&' => {
|
||||
// Alignment marker outside an environment: drop.
|
||||
cursor.bump();
|
||||
}
|
||||
'$' => {
|
||||
// Stray math delimiter inside math: drop.
|
||||
cursor.bump();
|
||||
}
|
||||
'-' if mode == Mode::Math => {
|
||||
cursor.bump();
|
||||
out.push('−');
|
||||
}
|
||||
'\'' if mode == Mode::Math => {
|
||||
cursor.bump();
|
||||
out.push('′');
|
||||
}
|
||||
c if c.is_whitespace() => {
|
||||
cursor.skip_ws();
|
||||
// TeX collapses whitespace runs (including newlines) to
|
||||
// nothing semantically; keep a single space for readability.
|
||||
if !out.at_line_start() && !out.ends_with_space() {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
c => {
|
||||
cursor.bump();
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which script position is being rendered.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
enum Script {
|
||||
Super,
|
||||
Sub,
|
||||
}
|
||||
|
||||
/// Render `^atom` / `_atom` using Unicode script chars when every char of
|
||||
/// the rendered atom has a script form; otherwise `^x` / `^(...)` fallback.
|
||||
///
|
||||
/// Word-like atoms take the fallback even when fully mappable: labels such as
|
||||
/// `p_{\text{torso}}` or `x_{max}` would otherwise become long modifier-letter
|
||||
/// runs (`pₜₒᵣₛₒ`) that are hard to read and render with visible gaps in
|
||||
/// terminal fonts lacking those glyphs. Index-like atoms (`x_{ij}`,
|
||||
/// `T_{i+1}`, `n^{th}`) keep the compact Unicode form.
|
||||
fn render_script(
|
||||
cursor: &mut Cursor<'_>,
|
||||
out: &mut MathBox,
|
||||
depth: usize,
|
||||
mode: Mode,
|
||||
kind: Script,
|
||||
) {
|
||||
let Some(atom) = cursor.read_atom() else {
|
||||
out.push(match kind {
|
||||
Script::Super => '^',
|
||||
Script::Sub => '_',
|
||||
});
|
||||
return;
|
||||
};
|
||||
let rendered = render_atom(atom, depth, mode);
|
||||
let mapped: Option<String> = if script_atom_is_wordlike(atom, &rendered) {
|
||||
None
|
||||
} else {
|
||||
rendered
|
||||
.chars()
|
||||
.map(|c| match kind {
|
||||
Script::Super => to_superscript(c),
|
||||
Script::Sub => to_subscript(c),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
match mapped {
|
||||
Some(s) if !s.is_empty() => out.push_str(&s),
|
||||
_ => {
|
||||
out.push(match kind {
|
||||
Script::Super => '^',
|
||||
Script::Sub => '_',
|
||||
});
|
||||
if rendered.chars().count() > 1 {
|
||||
let _ = write!(out, "({rendered})");
|
||||
} else {
|
||||
out.push_str(&rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if a script atom is a word-like label rather than indices.
|
||||
///
|
||||
/// Two signals, checked on the atom *source* and its rendered form:
|
||||
///
|
||||
/// - the source routes through a text-family command (`\text{…}`, `\mathrm{…}`,
|
||||
/// `\operatorname{…}`, …): the author explicitly marked the content as a
|
||||
/// word;
|
||||
/// - the rendered form contains a run of 3+ ASCII letters: multi-letter runs
|
||||
/// read as words (`max`, `torso`), while 1–2 letter runs are index
|
||||
/// juxtapositions (`ij`, `th`) that stay compact.
|
||||
fn script_atom_is_wordlike(atom: &str, rendered: &str) -> bool {
|
||||
// `\text` also catches `\textrm`/`\textbf`/`\textit`/`\textsf`/`\texttt`/
|
||||
// `\textnormal` by prefix; `\math…` variants and box commands likewise.
|
||||
const TEXT_MARKERS: [&str; 8] = [
|
||||
"\\text",
|
||||
"\\mathrm",
|
||||
"\\mathsf",
|
||||
"\\mathtt",
|
||||
"\\mathit",
|
||||
"\\operatorname",
|
||||
"\\mbox",
|
||||
"\\hbox",
|
||||
];
|
||||
if TEXT_MARKERS.iter().any(|m| atom.contains(m)) {
|
||||
return true;
|
||||
}
|
||||
let mut run = 0usize;
|
||||
for c in rendered.chars() {
|
||||
if c.is_ascii_alphabetic() {
|
||||
run += 1;
|
||||
if run >= 3 {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Render a `\command` whose backslash was already consumed.
|
||||
fn render_command(cursor: &mut Cursor<'_>, out: &mut MathBox, depth: usize, mode: Mode) {
|
||||
let name = cursor.read_command_name();
|
||||
match name {
|
||||
// ── Structure ────────────────────────────────────────────────────
|
||||
"" => out.push('\\'),
|
||||
"\\" => out.push('\n'),
|
||||
"begin" => render_environment(cursor, out, depth, mode),
|
||||
"end" => {
|
||||
// Stray \end without matching \begin: drop its argument.
|
||||
let _ = take_brace_arg(cursor);
|
||||
}
|
||||
"left" | "right" => {
|
||||
// Keep the delimiter that follows; `.` means "no delimiter".
|
||||
cursor.skip_ws();
|
||||
match cursor.peek() {
|
||||
Some('.') => {
|
||||
cursor.bump();
|
||||
}
|
||||
Some('\\') => {
|
||||
cursor.bump();
|
||||
render_command(cursor, out, depth, mode);
|
||||
}
|
||||
Some(c) => {
|
||||
cursor.bump();
|
||||
out.push(c);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fractions / binomials / roots ────────────────────────────────
|
||||
"frac" | "dfrac" | "tfrac" | "cfrac" => {
|
||||
let num = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
|
||||
let den = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
|
||||
match (num, den) {
|
||||
(Some(n), Some(d)) => out.push_str(&format_fraction(&n, &d)),
|
||||
(Some(n), None) => out.push_str(&n),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"binom" | "tbinom" | "dbinom" => {
|
||||
let n = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
|
||||
let k = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
|
||||
if let (Some(n), Some(k)) = (n, k) {
|
||||
let _ = write!(out, "C({n}, {k})");
|
||||
}
|
||||
}
|
||||
"sqrt" => {
|
||||
cursor.skip_ws();
|
||||
let index = if cursor.peek() == Some('[') {
|
||||
cursor.bump();
|
||||
let start = cursor.pos;
|
||||
while let Some(c) = cursor.peek() {
|
||||
if c == ']' {
|
||||
break;
|
||||
}
|
||||
cursor.bump();
|
||||
}
|
||||
let idx = &cursor.src[start..cursor.pos];
|
||||
cursor.bump(); // consume `]`
|
||||
Some(render_atom(idx, depth, mode))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let radical = match index.as_deref() {
|
||||
None | Some("2") => "√",
|
||||
Some("3") => "∛",
|
||||
Some("4") => "∜",
|
||||
Some(other) => {
|
||||
// ⁿ√ style prefix for other indices.
|
||||
let sup: Option<String> = other.chars().map(to_superscript).collect();
|
||||
out.push_str(&sup.unwrap_or_else(|| format!("({other})")));
|
||||
"√"
|
||||
}
|
||||
};
|
||||
out.push_str(radical);
|
||||
if let Some(arg) = cursor.read_atom() {
|
||||
let rendered = render_atom(arg, depth, mode);
|
||||
// Parenthesize any multi-char radicand: `√ab` would read as
|
||||
// `(√a)b`.
|
||||
if rendered.chars().count() > 1 {
|
||||
let _ = write!(out, "({rendered})");
|
||||
} else {
|
||||
out.push_str(&rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boxes (frame dropped; content preserved) ─────────────────────
|
||||
"boxed" => {
|
||||
if let Some(arg) = take_brace_arg(cursor) {
|
||||
out.push_str(&render_atom(arg, depth, mode));
|
||||
}
|
||||
}
|
||||
"fbox" | "framebox" => {
|
||||
if let Some(arg) = take_brace_arg(cursor) {
|
||||
out.push_str(&render_atom(arg, depth, Mode::Text));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text / alphabets ─────────────────────────────────────────────
|
||||
"text" | "textrm" | "textit" | "textbf" | "textsf" | "texttt" | "textnormal" | "mbox"
|
||||
| "hbox" => {
|
||||
if let Some(arg) = take_brace_arg(cursor) {
|
||||
out.push_str(&render_atom(arg, depth, Mode::Text));
|
||||
}
|
||||
}
|
||||
"mathrm" | "operatorname" | "mathit" | "mathsf" | "mathtt" | "mathnormal" => {
|
||||
if let Some(arg) = take_brace_arg(cursor) {
|
||||
out.push_str(&render_atom(arg, depth, Mode::Text));
|
||||
}
|
||||
}
|
||||
"mathbb" => render_mapped_alphabet(cursor, out, depth, mode, map_mathbb),
|
||||
"mathcal" | "mathscr" => render_mapped_alphabet(cursor, out, depth, mode, map_mathcal),
|
||||
"mathfrak" => render_mapped_alphabet(cursor, out, depth, mode, map_mathfrak),
|
||||
"mathbf" | "boldsymbol" | "bm" | "bold" => {
|
||||
render_mapped_alphabet(cursor, out, depth, mode, map_mathbf)
|
||||
}
|
||||
|
||||
// ── Accents (combining marks) ────────────────────────────────────
|
||||
"hat" | "widehat" => render_accent(cursor, out, depth, mode, '\u{0302}'),
|
||||
"bar" | "overline" => render_accent(cursor, out, depth, mode, '\u{0304}'),
|
||||
"tilde" | "widetilde" => render_accent(cursor, out, depth, mode, '\u{0303}'),
|
||||
"vec" => render_accent(cursor, out, depth, mode, '\u{20D7}'),
|
||||
"dot" => render_accent(cursor, out, depth, mode, '\u{0307}'),
|
||||
"ddot" => render_accent(cursor, out, depth, mode, '\u{0308}'),
|
||||
"check" => render_accent(cursor, out, depth, mode, '\u{030C}'),
|
||||
"breve" => render_accent(cursor, out, depth, mode, '\u{0306}'),
|
||||
"acute" => render_accent(cursor, out, depth, mode, '\u{0301}'),
|
||||
"grave" => render_accent(cursor, out, depth, mode, '\u{0300}'),
|
||||
"mathring" => render_accent(cursor, out, depth, mode, '\u{030A}'),
|
||||
"underline" => render_accent(cursor, out, depth, mode, '\u{0332}'),
|
||||
|
||||
// ── Negation ─────────────────────────────────────────────────────
|
||||
"not" => {
|
||||
if let Some(atom) = cursor.read_atom() {
|
||||
let rendered = render_atom(atom, depth, mode);
|
||||
match rendered.as_str() {
|
||||
"∈" => out.push('∉'),
|
||||
"=" => out.push('≠'),
|
||||
"<" => out.push('≮'),
|
||||
">" => out.push('≯'),
|
||||
"≡" => out.push('≢'),
|
||||
"⊂" => out.push('⊄'),
|
||||
"⊆" => out.push('⊈'),
|
||||
"∃" => out.push('∄'),
|
||||
other => {
|
||||
out.push_str(other);
|
||||
// Combining long solidus overlay on the last char.
|
||||
if !other.is_empty() {
|
||||
out.push('\u{0338}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decorations rendered as base + script ────────────────────────
|
||||
"overset" | "stackrel" => {
|
||||
let over = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
|
||||
let base = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
|
||||
if let (Some(over), Some(base)) = (over, base) {
|
||||
out.push_str(&base);
|
||||
let sup: Option<String> = over.chars().map(to_superscript).collect();
|
||||
match sup {
|
||||
Some(s) if !s.is_empty() => out.push_str(&s),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"underset" => {
|
||||
let under = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
|
||||
let base = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
|
||||
if let (Some(under), Some(base)) = (under, base) {
|
||||
out.push_str(&base);
|
||||
let sub: Option<String> = under.chars().map(to_subscript).collect();
|
||||
match sub {
|
||||
Some(s) if !s.is_empty() => out.push_str(&s),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modular arithmetic ───────────────────────────────────────────
|
||||
"pmod" => {
|
||||
if let Some(arg) = take_brace_arg(cursor) {
|
||||
if !out.at_line_start() && !out.ends_with_space() {
|
||||
out.push(' ');
|
||||
}
|
||||
let _ = write!(out, "(mod {})", render_atom(arg, depth, mode));
|
||||
}
|
||||
}
|
||||
"bmod" => {
|
||||
if !out.at_line_start() && !out.ends_with_space() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str("mod ");
|
||||
}
|
||||
|
||||
// ── Spacing ──────────────────────────────────────────────────────
|
||||
"," | ";" | ":" | ">" | " " | "space" | "thinspace" | "medspace" | "thickspace"
|
||||
| "enspace" => {
|
||||
if !out.at_line_start() && !out.ends_with_space() {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
"quad" => out.push_str(" "),
|
||||
"qquad" => out.push_str(" "),
|
||||
"!" | "negthinspace" | "negmedspace" | "negthickspace" => {}
|
||||
|
||||
// ── No-ops (sizing/styling/structure hints) ──────────────────────
|
||||
"limits" | "nolimits" | "displaystyle" | "textstyle" | "scriptstyle"
|
||||
| "scriptscriptstyle" | "big" | "Big" | "bigg" | "Bigg" | "bigl" | "Bigl" | "biggl"
|
||||
| "Biggl" | "bigr" | "Bigr" | "biggr" | "Biggr" | "bigm" | "Bigm" | "biggm" | "Biggm"
|
||||
| "mathstrut" | "strut" | "allowbreak" | "nonumber" | "notag" | "mathopen"
|
||||
| "mathclose" | "mathbin" | "mathrel" | "mathord" | "mathpunct" | "mathinner"
|
||||
| "mathop" | "ensuremath" | "label" | "tag" => {
|
||||
// \label/\tag carry non-visual arguments: drop them.
|
||||
if matches!(name, "label" | "tag") {
|
||||
let _ = take_brace_arg(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Symbol table ─────────────────────────────────────────────────
|
||||
_ => {
|
||||
if let Some(sym) = symbol(name) {
|
||||
out.push_str(sym);
|
||||
} else {
|
||||
// Unknown command: keep its name as plain text.
|
||||
out.push_str(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume `{...}` (after optional whitespace) and return the body source.
|
||||
pub(super) fn take_brace_arg<'a>(cursor: &mut Cursor<'a>) -> Option<&'a str> {
|
||||
cursor.skip_ws();
|
||||
if cursor.peek() == Some('{') {
|
||||
cursor.bump();
|
||||
Some(cursor.read_group_body())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if a fraction/root operand needs parentheses for readability.
|
||||
fn needs_parens(s: &str) -> bool {
|
||||
s.chars().count() > 1 && s.contains([' ', '+', '−', '-', '=', '/'])
|
||||
}
|
||||
|
||||
/// Format `num/den`, mapping common numeric fractions to vulgar fractions.
|
||||
fn format_fraction(num: &str, den: &str) -> String {
|
||||
let vulgar = match (num, den) {
|
||||
("1", "2") => Some('½'),
|
||||
("1", "3") => Some('⅓'),
|
||||
("2", "3") => Some('⅔'),
|
||||
("1", "4") => Some('¼'),
|
||||
("3", "4") => Some('¾'),
|
||||
("1", "5") => Some('⅕'),
|
||||
("2", "5") => Some('⅖'),
|
||||
("3", "5") => Some('⅗'),
|
||||
("4", "5") => Some('⅘'),
|
||||
("1", "6") => Some('⅙'),
|
||||
("5", "6") => Some('⅚'),
|
||||
("1", "7") => Some('⅐'),
|
||||
("1", "8") => Some('⅛'),
|
||||
("3", "8") => Some('⅜'),
|
||||
("5", "8") => Some('⅝'),
|
||||
("7", "8") => Some('⅞'),
|
||||
("1", "9") => Some('⅑'),
|
||||
("1", "10") => Some('⅒'),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(v) = vulgar {
|
||||
return v.to_string();
|
||||
}
|
||||
let n = if needs_parens(num) {
|
||||
format!("({num})")
|
||||
} else {
|
||||
num.to_string()
|
||||
};
|
||||
let d = if needs_parens(den) {
|
||||
format!("({den})")
|
||||
} else {
|
||||
den.to_string()
|
||||
};
|
||||
format!("{n}/{d}")
|
||||
}
|
||||
|
||||
/// Render an alphabet-mapping command (`\mathbb{R}` etc.): map chars that
|
||||
/// have a styled form, keep the rest as rendered.
|
||||
fn render_mapped_alphabet(
|
||||
cursor: &mut Cursor<'_>,
|
||||
out: &mut MathBox,
|
||||
depth: usize,
|
||||
mode: Mode,
|
||||
map: fn(char) -> Option<char>,
|
||||
) {
|
||||
let Some(atom) = cursor.read_atom() else {
|
||||
return;
|
||||
};
|
||||
let rendered = render_atom(atom, depth, mode);
|
||||
for c in rendered.chars() {
|
||||
out.push(map(c).unwrap_or(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Render an accent command by appending a combining mark to each char of
|
||||
/// the argument.
|
||||
fn render_accent(
|
||||
cursor: &mut Cursor<'_>,
|
||||
out: &mut MathBox,
|
||||
depth: usize,
|
||||
mode: Mode,
|
||||
combining: char,
|
||||
) {
|
||||
let Some(atom) = cursor.read_atom() else {
|
||||
return;
|
||||
};
|
||||
let rendered = render_atom(atom, depth, mode);
|
||||
for c in rendered.chars() {
|
||||
out.push(c);
|
||||
if !c.is_whitespace() {
|
||||
out.push(combining);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Byte cursor over TeX source.
|
||||
|
||||
/// Byte cursor over the TeX source.
|
||||
pub(super) struct Cursor<'a> {
|
||||
pub(super) src: &'a str,
|
||||
pub(super) pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
pub(super) fn new(src: &'a str) -> Self {
|
||||
Self { src, pos: 0 }
|
||||
}
|
||||
|
||||
pub(super) fn peek(&self) -> Option<char> {
|
||||
self.src[self.pos..].chars().next()
|
||||
}
|
||||
|
||||
pub(super) fn bump(&mut self) -> Option<char> {
|
||||
let ch = self.peek()?;
|
||||
self.pos += ch.len_utf8();
|
||||
Some(ch)
|
||||
}
|
||||
|
||||
/// Consume `\command` (alphabetic name) or `\<single char>`; the leading
|
||||
/// backslash must already be consumed. Returns the command name.
|
||||
///
|
||||
/// Unlike TeX we do NOT consume trailing whitespace: the caller's
|
||||
/// whitespace collapsing keeps `\to 0` rendering as `→ 0`.
|
||||
pub(super) fn read_command_name(&mut self) -> &'a str {
|
||||
let start = self.pos;
|
||||
match self.peek() {
|
||||
Some(c) if c.is_ascii_alphabetic() => {
|
||||
while matches!(self.peek(), Some(c) if c.is_ascii_alphabetic()) {
|
||||
self.bump();
|
||||
}
|
||||
&self.src[start..self.pos]
|
||||
}
|
||||
Some(_) => {
|
||||
self.bump();
|
||||
&self.src[start..self.pos]
|
||||
}
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip whitespace (TeX collapses it; meaning comes from commands).
|
||||
pub(super) fn skip_ws(&mut self) {
|
||||
while matches!(self.peek(), Some(c) if c.is_whitespace()) {
|
||||
self.bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a balanced `{...}` group body, assuming `{` was already consumed.
|
||||
/// Returns the inner source (without braces). Unbalanced input returns
|
||||
/// the remainder of the source.
|
||||
pub(super) fn read_group_body(&mut self) -> &'a str {
|
||||
let start = self.pos;
|
||||
let mut depth = 1usize;
|
||||
while let Some(ch) = self.bump() {
|
||||
match ch {
|
||||
'\\' => {
|
||||
// Skip escaped char so `\{`/`\}` don't affect depth.
|
||||
self.bump();
|
||||
}
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return &self.src[start..self.pos - 1];
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
&self.src[start..self.pos]
|
||||
}
|
||||
|
||||
/// Read the next "atom": a `{...}` group body, a `\command` (returned
|
||||
/// with backslash), or a single char. Skips leading whitespace.
|
||||
pub(super) fn read_atom(&mut self) -> Option<&'a str> {
|
||||
self.skip_ws();
|
||||
let start = self.pos;
|
||||
match self.peek()? {
|
||||
'{' => {
|
||||
self.bump();
|
||||
Some(self.read_group_body())
|
||||
}
|
||||
'\\' => {
|
||||
self.bump();
|
||||
self.read_command_name();
|
||||
Some(&self.src[start..self.pos])
|
||||
}
|
||||
_ => {
|
||||
self.bump();
|
||||
Some(&self.src[start..self.pos])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! `\\begin{env}...\\end{env}` environments: matrices, cases, alignments.
|
||||
|
||||
use crate::buffers::unicode_display_width;
|
||||
|
||||
use super::Mode;
|
||||
use super::commands::{render_atom, take_brace_arg};
|
||||
use super::cursor::Cursor;
|
||||
use super::math_box::MathBox;
|
||||
|
||||
/// Render `\begin{env}...\end{env}`. The `\begin` name was already consumed.
|
||||
pub(super) fn render_environment(
|
||||
cursor: &mut Cursor<'_>,
|
||||
out: &mut MathBox,
|
||||
depth: usize,
|
||||
mode: Mode,
|
||||
) {
|
||||
let Some(env_name) = take_brace_arg(cursor) else {
|
||||
return;
|
||||
};
|
||||
let env_name = env_name.trim().trim_end_matches('*');
|
||||
|
||||
// Capture body source until the matching `\end{name}`, tracking nesting
|
||||
// of same-named environments. Scans raw source from the cursor.
|
||||
let body_start = cursor.pos;
|
||||
let mut body_end = cursor.src.len();
|
||||
let mut resume = cursor.src.len();
|
||||
let mut nest = 0usize;
|
||||
let mut search = cursor.pos;
|
||||
while search < cursor.src.len() {
|
||||
let rest = &cursor.src[search..];
|
||||
let Some(rel) = rest.find('\\') else {
|
||||
break;
|
||||
};
|
||||
let bs_pos = search + rel;
|
||||
let after_bs = &cursor.src[bs_pos + 1..];
|
||||
let kw_len = if command_at(after_bs, "begin") {
|
||||
"begin".len()
|
||||
} else if command_at(after_bs, "end") {
|
||||
"end".len()
|
||||
} else {
|
||||
// Not begin/end: skip the backslash and the char after it (so
|
||||
// `\\` and `\{` never confuse the scan).
|
||||
let skip = after_bs.chars().next().map_or(0, char::len_utf8);
|
||||
search = bs_pos + 1 + skip.max(1);
|
||||
continue;
|
||||
};
|
||||
let is_begin = kw_len == "begin".len();
|
||||
let mut probe = Cursor {
|
||||
src: cursor.src,
|
||||
pos: bs_pos + 1 + kw_len,
|
||||
};
|
||||
let arg = take_brace_arg(&mut probe).map(|a| a.trim().trim_end_matches('*'));
|
||||
if arg == Some(env_name) {
|
||||
if is_begin {
|
||||
nest += 1;
|
||||
} else if nest == 0 {
|
||||
body_end = bs_pos;
|
||||
resume = probe.pos;
|
||||
break;
|
||||
} else {
|
||||
nest -= 1;
|
||||
}
|
||||
}
|
||||
search = probe.pos.max(bs_pos + 1 + kw_len);
|
||||
}
|
||||
cursor.pos = resume;
|
||||
let mut body = &cursor.src[body_start..body_end.min(cursor.src.len())];
|
||||
|
||||
// Optional column spec for array environments: `\begin{array}{ll}`.
|
||||
if env_name == "array" || env_name == "alignat" {
|
||||
let mut probe = Cursor::new(body);
|
||||
probe.skip_ws();
|
||||
if probe.peek() == Some('{') {
|
||||
probe.bump();
|
||||
let _ = probe.read_group_body();
|
||||
body = &body[probe.pos..];
|
||||
}
|
||||
}
|
||||
let rows = env_rows_to_strings(body, env_name, out.flat, depth, mode);
|
||||
out.hcat_rows(rows);
|
||||
}
|
||||
|
||||
/// `true` if `rest` starts with command word `word` NOT followed by another
|
||||
/// ASCII letter (so `\endx` is not mistaken for `\end`).
|
||||
fn command_at(rest: &str, word: &str) -> bool {
|
||||
rest.starts_with(word)
|
||||
&& !rest[word.len()..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
/// Split an environment body into rows (`\\`) and cells (`&`) at brace and
|
||||
/// environment depth 0, render each cell, then lay the rows out according to
|
||||
/// the environment. Returns one string per visual row; the caller attaches
|
||||
/// them as a box. In `flat` mode, matrix/cases environments render as a
|
||||
/// single row with `; ` between matrix rows.
|
||||
fn env_rows_to_strings(
|
||||
body: &str,
|
||||
env_name: &str,
|
||||
flat: bool,
|
||||
depth: usize,
|
||||
mode: Mode,
|
||||
) -> Vec<String> {
|
||||
let mut rows: Vec<Vec<String>> = Vec::new();
|
||||
let mut row: Vec<String> = Vec::new();
|
||||
let mut cell_start = 0usize;
|
||||
let mut brace_depth = 0usize;
|
||||
let mut env_depth = 0usize;
|
||||
let bytes = body.as_bytes();
|
||||
let mut i = 0usize;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'\\' => {
|
||||
if bytes.get(i + 1) == Some(&b'\\') {
|
||||
if brace_depth == 0 && env_depth == 0 {
|
||||
row.push(body[cell_start..i].to_string());
|
||||
rows.push(std::mem::take(&mut row));
|
||||
i += 2;
|
||||
cell_start = i;
|
||||
continue;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let rest = &body[i + 1..];
|
||||
if command_at(rest, "begin") {
|
||||
env_depth += 1;
|
||||
} else if command_at(rest, "end") {
|
||||
env_depth = env_depth.saturating_sub(1);
|
||||
}
|
||||
// Skip the backslash plus the char after it so escaped
|
||||
// delimiters (`\&`, `\{`, `\}`) never affect depth/splits.
|
||||
let skip = rest.chars().next().map_or(0, char::len_utf8);
|
||||
i += 1 + skip.max(1);
|
||||
continue;
|
||||
}
|
||||
b'{' => brace_depth += 1,
|
||||
b'}' => brace_depth = brace_depth.saturating_sub(1),
|
||||
b'&' if brace_depth == 0 && env_depth == 0 => {
|
||||
row.push(body[cell_start..i].to_string());
|
||||
cell_start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
row.push(body[cell_start.min(bytes.len())..].to_string());
|
||||
rows.push(row);
|
||||
|
||||
// Render each cell, drop fully-empty rows.
|
||||
let mut rendered_rows: Vec<Vec<String>> = rows
|
||||
.into_iter()
|
||||
.map(|cells| {
|
||||
cells
|
||||
.into_iter()
|
||||
.map(|c| render_atom(c.trim(), depth, mode).trim().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
rendered_rows.retain(|cells| cells.iter().any(|c| !c.is_empty()));
|
||||
if rendered_rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let is_matrix = matches!(
|
||||
env_name,
|
||||
"matrix"
|
||||
| "pmatrix"
|
||||
| "bmatrix"
|
||||
| "Bmatrix"
|
||||
| "vmatrix"
|
||||
| "Vmatrix"
|
||||
| "smallmatrix"
|
||||
| "array"
|
||||
);
|
||||
let n_rows = rendered_rows.len();
|
||||
|
||||
if is_matrix {
|
||||
// Flat (inline) mode: one row, single delimiter pair, rows joined
|
||||
// with `; ` — `(1 2; 3 4)`.
|
||||
if flat {
|
||||
let inner = rendered_rows
|
||||
.iter()
|
||||
.map(|cells| cells.join(" "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
// Single-row delimiter pair; plain `matrix` has none (' ').
|
||||
let (l, r) = matrix_delims(env_name, 0, 1);
|
||||
let mut s = String::new();
|
||||
if l != ' ' {
|
||||
s.push(l);
|
||||
}
|
||||
s.push_str(&inner);
|
||||
if r != ' ' {
|
||||
s.push(r);
|
||||
}
|
||||
return vec![s];
|
||||
}
|
||||
// Pad columns to equal width so rows align.
|
||||
let n_cols = rendered_rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let mut widths = vec![0usize; n_cols];
|
||||
for cells in &rendered_rows {
|
||||
for (i, cell) in cells.iter().enumerate() {
|
||||
widths[i] = widths[i].max(unicode_display_width(cell));
|
||||
}
|
||||
}
|
||||
rendered_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(row_idx, cells)| {
|
||||
let mut content = String::new();
|
||||
for (i, cell) in cells.iter().enumerate() {
|
||||
if i > 0 {
|
||||
content.push_str(" ");
|
||||
}
|
||||
content.push_str(cell);
|
||||
if i + 1 < cells.len() {
|
||||
let pad = widths[i].saturating_sub(unicode_display_width(cell));
|
||||
content.push_str(&" ".repeat(pad));
|
||||
}
|
||||
}
|
||||
let (l, r) = matrix_delims(env_name, row_idx, n_rows);
|
||||
format!("{l}{content}{r}")
|
||||
})
|
||||
.collect()
|
||||
} else if env_name == "cases" {
|
||||
if flat {
|
||||
let inner = rendered_rows
|
||||
.iter()
|
||||
.map(|cells| cells.join(" "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return vec![format!("{{{inner}}}")];
|
||||
}
|
||||
rendered_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(row_idx, cells)| {
|
||||
let brace = cases_brace(row_idx, n_rows);
|
||||
format!("{brace} {}", cells.join(" "))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
// aligned/align/gather/split/equation/…: `&` is an invisible
|
||||
// alignment marker; rejoin cells with a single space. One string per
|
||||
// row; the caller's box attachment (or flat `; ` join) handles the
|
||||
// rest.
|
||||
rendered_rows
|
||||
.iter()
|
||||
.map(|cells| {
|
||||
let mut s = cells
|
||||
.iter()
|
||||
.filter(|c| !c.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
// Collapse any double spaces introduced around markers.
|
||||
while s.contains(" ") {
|
||||
s = s.replace(" ", " ");
|
||||
}
|
||||
s
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-row delimiters for matrix-family environments.
|
||||
fn matrix_delims(env: &str, row: usize, n_rows: usize) -> (char, char) {
|
||||
let single = n_rows == 1;
|
||||
let first = row == 0;
|
||||
let last = row + 1 == n_rows;
|
||||
match env {
|
||||
"pmatrix" => {
|
||||
if single {
|
||||
('(', ')')
|
||||
} else if first {
|
||||
('⎛', '⎞')
|
||||
} else if last {
|
||||
('⎝', '⎠')
|
||||
} else {
|
||||
('⎜', '⎟')
|
||||
}
|
||||
}
|
||||
"bmatrix" | "array" => {
|
||||
if single {
|
||||
('[', ']')
|
||||
} else if first {
|
||||
('⎡', '⎤')
|
||||
} else if last {
|
||||
('⎣', '⎦')
|
||||
} else {
|
||||
('⎢', '⎥')
|
||||
}
|
||||
}
|
||||
"Bmatrix" => {
|
||||
if single {
|
||||
('{', '}')
|
||||
} else if first {
|
||||
('⎧', '⎫')
|
||||
} else if last {
|
||||
('⎩', '⎭')
|
||||
} else {
|
||||
('⎨', '⎬')
|
||||
}
|
||||
}
|
||||
"vmatrix" | "Vmatrix" => ('│', '│'),
|
||||
_ => (' ', ' '),
|
||||
}
|
||||
}
|
||||
|
||||
/// Left-brace column char for `cases` rows.
|
||||
fn cases_brace(row: usize, n_rows: usize) -> char {
|
||||
if n_rows == 1 {
|
||||
'{'
|
||||
} else if row == 0 {
|
||||
'⎧'
|
||||
} else if row + 1 == n_rows {
|
||||
'⎩'
|
||||
} else if row == n_rows / 2 {
|
||||
'⎨'
|
||||
} else {
|
||||
'⎪'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Two-dimensional math layout box.
|
||||
|
||||
use crate::buffers::unicode_display_width;
|
||||
|
||||
/// Two-dimensional text box with an anchor row where horizontal flow
|
||||
/// attaches.
|
||||
///
|
||||
/// Multi-row content (matrix-family environments) extends above/below the
|
||||
/// anchor row; subsequent output continues on the anchor row. This keeps a
|
||||
/// prefix, a matrix, and a suffix aligned:
|
||||
///
|
||||
/// ```text
|
||||
/// A = ⎛1 2⎞, det(A) = −2
|
||||
/// ⎝3 4⎠
|
||||
/// ```
|
||||
pub(super) struct MathBox {
|
||||
lines: Vec<String>,
|
||||
/// Row index that horizontal flow currently appends to.
|
||||
anchor: usize,
|
||||
/// First row belonging to the current visual line. Rows before `floor`
|
||||
/// are completed lines from earlier `\\` breaks and must never be
|
||||
/// touched by box attachment.
|
||||
floor: usize,
|
||||
/// Flat mode (inline math): vertical layout is impossible, so row breaks
|
||||
/// render as `; ` and environments render single-row.
|
||||
pub(super) flat: bool,
|
||||
}
|
||||
|
||||
impl MathBox {
|
||||
pub(super) fn new(flat: bool) -> Self {
|
||||
Self {
|
||||
lines: vec![String::new()],
|
||||
anchor: 0,
|
||||
floor: 0,
|
||||
flat,
|
||||
}
|
||||
}
|
||||
|
||||
fn cur(&mut self) -> &mut String {
|
||||
&mut self.lines[self.anchor]
|
||||
}
|
||||
|
||||
/// `true` when nothing has been emitted on the current flow row yet.
|
||||
pub(super) fn at_line_start(&self) -> bool {
|
||||
self.lines[self.anchor].is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn ends_with_space(&self) -> bool {
|
||||
self.lines[self.anchor].ends_with(' ')
|
||||
}
|
||||
|
||||
pub(super) fn push(&mut self, c: char) {
|
||||
if c == '\n' {
|
||||
self.vbreak();
|
||||
} else {
|
||||
self.cur().push(c);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push_str(&mut self, s: &str) {
|
||||
if s.contains('\n') {
|
||||
self.hcat_rows(s.split('\n').map(str::to_string).collect());
|
||||
} else {
|
||||
self.cur().push_str(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// End the current visual line; flow continues on a fresh row below all
|
||||
/// existing rows. Flat mode renders the break as `; `.
|
||||
fn vbreak(&mut self) {
|
||||
if self.flat {
|
||||
if !self.at_line_start() {
|
||||
let cur = self.cur();
|
||||
while cur.ends_with(' ') {
|
||||
cur.pop();
|
||||
}
|
||||
cur.push_str("; ");
|
||||
}
|
||||
} else {
|
||||
self.lines.push(String::new());
|
||||
self.anchor = self.lines.len() - 1;
|
||||
self.floor = self.anchor;
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach `rows` as a box at the current flow position, anchored at the
|
||||
/// box's upper-middle row. All box rows start at the same column; flow
|
||||
/// resumes on the anchor row past the box's widest row.
|
||||
pub(super) fn hcat_rows(&mut self, rows: Vec<String>) {
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.flat || rows.len() == 1 {
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
if i > 0 {
|
||||
self.vbreak();
|
||||
}
|
||||
self.cur().push_str(row);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let box_anchor = (rows.len() - 1) / 2;
|
||||
let attach_col = unicode_display_width(&self.lines[self.anchor]);
|
||||
let box_width = rows
|
||||
.iter()
|
||||
.map(|r| unicode_display_width(r))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Ensure enough rows above the anchor within the current visual line.
|
||||
let have_above = self.anchor - self.floor;
|
||||
if box_anchor > have_above {
|
||||
let add = box_anchor - have_above;
|
||||
for _ in 0..add {
|
||||
self.lines.insert(self.floor, String::new());
|
||||
}
|
||||
self.anchor += add;
|
||||
}
|
||||
// Ensure enough rows below the anchor.
|
||||
let below = rows.len() - box_anchor - 1;
|
||||
let have_below = self.lines.len() - self.anchor - 1;
|
||||
if below > have_below {
|
||||
for _ in 0..(below - have_below) {
|
||||
self.lines.push(String::new());
|
||||
}
|
||||
}
|
||||
// Place the box rows, left-padded to the attach column.
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let target = self.anchor - box_anchor + i;
|
||||
let line = &mut self.lines[target];
|
||||
let cur_w = unicode_display_width(line);
|
||||
if cur_w < attach_col {
|
||||
line.push_str(&" ".repeat(attach_col - cur_w));
|
||||
}
|
||||
line.push_str(row);
|
||||
}
|
||||
// Flow resumes past the box's widest row.
|
||||
let frontier = attach_col + box_width;
|
||||
let cur_w = unicode_display_width(&self.lines[self.anchor]);
|
||||
if cur_w < frontier {
|
||||
let pad = frontier - cur_w;
|
||||
self.lines[self.anchor].push_str(&" ".repeat(pad));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn into_lines(self) -> Vec<String> {
|
||||
self.lines
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Write for MathBox {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
self.push_str(s);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Best-effort LaTeX math → Unicode plain-text conversion.
|
||||
//!
|
||||
//! Converts TeX math source (the content of `$...$`, `$$...$$`, `\(...\)`,
|
||||
//! `\[...\]`) into a readable Unicode approximation for terminal display:
|
||||
//!
|
||||
//! - Greek letters and symbol commands (`\alpha` → `α`, `\le` → `≤`, …)
|
||||
//! - Superscripts/subscripts via Unicode script characters (`x^2` → `x²`,
|
||||
//! `a_1` → `a₁`) with `^(...)`/`_(...)` fallback when a char has no
|
||||
//! Unicode script form
|
||||
//! - Fractions (`\frac{1}{2}` → `½`, `\frac{a+b}{c}` → `(a+b)/c`)
|
||||
//! - Roots (`\sqrt{x}` → `√x`, `\sqrt[3]{x}` → `∛x`)
|
||||
//! - Alphabets (`\mathbb{R}` → `ℝ`, `\mathcal{L}` → `ℒ`, `\mathbf{v}` → `𝐯`)
|
||||
//! - Accents via combining marks (`\hat{x}` → `x̂`, `\vec{v}` → `v⃗`)
|
||||
//! - Environments (`aligned`, `cases`, `pmatrix`, …) → multi-line layout
|
||||
//!
|
||||
//! The converter is total: it never panics and always produces *some* output
|
||||
//! (unknown commands degrade to their bare name). Callers decide whether to
|
||||
//! use the conversion or fall back to raw TeX source.
|
||||
|
||||
mod commands;
|
||||
mod cursor;
|
||||
mod environments;
|
||||
mod math_box;
|
||||
mod symbols;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use commands::render_sequence;
|
||||
use cursor::Cursor;
|
||||
use math_box::MathBox;
|
||||
|
||||
/// Inputs larger than this are not converted (callers fall back to raw
|
||||
/// display). Guards the streaming hot path: the tail is re-rendered on every
|
||||
/// chunk, so conversion cost must stay trivially small.
|
||||
pub(crate) const MAX_MATH_SOURCE_LEN: usize = 4096;
|
||||
|
||||
/// Hard cap on group-nesting recursion. Inputs deeper than this render their
|
||||
/// remaining content flatly rather than recursing further.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
/// Convert inline math to a single-line Unicode string.
|
||||
///
|
||||
/// Row separators (`\\`) collapse to `; ` and multi-row environments render
|
||||
/// single-row, so inline math never introduces a line break mid-paragraph.
|
||||
/// Returns `None` when the source is too large to convert (see
|
||||
/// [`MAX_MATH_SOURCE_LEN`]).
|
||||
pub(crate) fn latex_to_unicode_inline(src: &str) -> Option<String> {
|
||||
if src.len() > MAX_MATH_SOURCE_LEN {
|
||||
return None;
|
||||
}
|
||||
let lines = convert(src, true);
|
||||
let joined = lines
|
||||
.iter()
|
||||
.map(|l| l.trim())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
Some(joined)
|
||||
}
|
||||
|
||||
/// Convert display math to one or more Unicode lines.
|
||||
///
|
||||
/// Lines come from `\\` row separators and multi-row environments, which lay
|
||||
/// out as 2D boxes anchored to the surrounding flow (see [`MathBox`]).
|
||||
/// Leading whitespace is structural (box alignment) and preserved; only line
|
||||
/// ends are trimmed. Returns `None` when the source is too large to convert,
|
||||
/// and an empty `Vec` when the math has no visible content (callers should
|
||||
/// fall back in both cases).
|
||||
pub(crate) fn latex_to_unicode_display(src: &str) -> Option<Vec<String>> {
|
||||
if src.len() > MAX_MATH_SOURCE_LEN {
|
||||
return None;
|
||||
}
|
||||
let lines: Vec<String> = convert(src, false)
|
||||
.into_iter()
|
||||
.map(|l| l.trim_end().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
Some(lines)
|
||||
}
|
||||
|
||||
/// Run the converter and return the output lines.
|
||||
fn convert(src: &str, flat: bool) -> Vec<String> {
|
||||
let mut cursor = Cursor::new(src);
|
||||
let mut out = MathBox::new(flat);
|
||||
render_sequence(&mut cursor, &mut out, 0, Mode::Math, None);
|
||||
out.into_lines()
|
||||
}
|
||||
|
||||
/// Rendering mode: math mode applies typographic substitutions (`-` → `−`,
|
||||
/// `'` → `′`) that text fragments (`\text{...}`) must not receive.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
Math,
|
||||
Text,
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Character and symbol mapping tables.
|
||||
|
||||
pub(super) fn to_superscript(c: char) -> Option<char> {
|
||||
Some(match c {
|
||||
'0' => '⁰',
|
||||
'1' => '¹',
|
||||
'2' => '²',
|
||||
'3' => '³',
|
||||
'4' => '⁴',
|
||||
'5' => '⁵',
|
||||
'6' => '⁶',
|
||||
'7' => '⁷',
|
||||
'8' => '⁸',
|
||||
'9' => '⁹',
|
||||
'+' => '⁺',
|
||||
'-' | '−' => '⁻',
|
||||
'=' => '⁼',
|
||||
'(' => '⁽',
|
||||
')' => '⁾',
|
||||
'a' => 'ᵃ',
|
||||
'b' => 'ᵇ',
|
||||
'c' => 'ᶜ',
|
||||
'd' => 'ᵈ',
|
||||
'e' => 'ᵉ',
|
||||
'f' => 'ᶠ',
|
||||
'g' => 'ᵍ',
|
||||
'h' => 'ʰ',
|
||||
'i' => 'ⁱ',
|
||||
'j' => 'ʲ',
|
||||
'k' => 'ᵏ',
|
||||
'l' => 'ˡ',
|
||||
'm' => 'ᵐ',
|
||||
'n' => 'ⁿ',
|
||||
'o' => 'ᵒ',
|
||||
'p' => 'ᵖ',
|
||||
'r' => 'ʳ',
|
||||
's' => 'ˢ',
|
||||
't' => 'ᵗ',
|
||||
'u' => 'ᵘ',
|
||||
'v' => 'ᵛ',
|
||||
'w' => 'ʷ',
|
||||
'x' => 'ˣ',
|
||||
'y' => 'ʸ',
|
||||
'z' => 'ᶻ',
|
||||
'T' => 'ᵀ',
|
||||
'∗' | '*' => '*',
|
||||
'′' | '\'' => '′',
|
||||
' ' => ' ',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn to_subscript(c: char) -> Option<char> {
|
||||
Some(match c {
|
||||
'0' => '₀',
|
||||
'1' => '₁',
|
||||
'2' => '₂',
|
||||
'3' => '₃',
|
||||
'4' => '₄',
|
||||
'5' => '₅',
|
||||
'6' => '₆',
|
||||
'7' => '₇',
|
||||
'8' => '₈',
|
||||
'9' => '₉',
|
||||
'+' => '₊',
|
||||
'-' | '−' => '₋',
|
||||
'=' => '₌',
|
||||
'(' => '₍',
|
||||
')' => '₎',
|
||||
'a' => 'ₐ',
|
||||
'e' => 'ₑ',
|
||||
'h' => 'ₕ',
|
||||
'i' => 'ᵢ',
|
||||
'j' => 'ⱼ',
|
||||
'k' => 'ₖ',
|
||||
'l' => 'ₗ',
|
||||
'm' => 'ₘ',
|
||||
'n' => 'ₙ',
|
||||
'o' => 'ₒ',
|
||||
'p' => 'ₚ',
|
||||
'r' => 'ᵣ',
|
||||
's' => 'ₛ',
|
||||
't' => 'ₜ',
|
||||
'u' => 'ᵤ',
|
||||
'v' => 'ᵥ',
|
||||
'x' => 'ₓ',
|
||||
' ' => ' ',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_mathbb(c: char) -> Option<char> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_mathcal(c: char) -> Option<char> {
|
||||
Some(match c {
|
||||
'B' => 'ℬ',
|
||||
'E' => 'ℰ',
|
||||
'F' => 'ℱ',
|
||||
'H' => 'ℋ',
|
||||
'I' => 'ℐ',
|
||||
'L' => 'ℒ',
|
||||
'M' => 'ℳ',
|
||||
'R' => 'ℛ',
|
||||
'e' => 'ℯ',
|
||||
'g' => 'ℊ',
|
||||
'o' => 'ℴ',
|
||||
'A'..='Z' => char::from_u32(0x1D49C + (c as u32 - 'A' as u32))?,
|
||||
'a'..='z' => char::from_u32(0x1D4B6 + (c as u32 - 'a' as u32))?,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_mathfrak(c: char) -> Option<char> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_mathbf(c: char) -> Option<char> {
|
||||
Some(match c {
|
||||
'A'..='Z' => char::from_u32(0x1D400 + (c as u32 - 'A' as u32))?,
|
||||
'a'..='z' => char::from_u32(0x1D41A + (c as u32 - 'a' as u32))?,
|
||||
'0'..='9' => char::from_u32(0x1D7CE + (c as u32 - '0' as u32))?,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Symbol command table (commands with no arguments).
|
||||
pub(super) fn symbol(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
// Greek lowercase
|
||||
"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 uppercase
|
||||
"Gamma" => "Γ",
|
||||
"Delta" => "Δ",
|
||||
"Theta" => "Θ",
|
||||
"Lambda" => "Λ",
|
||||
"Xi" => "Ξ",
|
||||
"Pi" => "Π",
|
||||
"Sigma" => "Σ",
|
||||
"Upsilon" => "Υ",
|
||||
"Phi" => "Φ",
|
||||
"Psi" => "Ψ",
|
||||
"Omega" => "Ω",
|
||||
// Big operators
|
||||
"sum" => "∑",
|
||||
"prod" => "∏",
|
||||
"coprod" => "∐",
|
||||
"int" => "∫",
|
||||
"iint" => "∬",
|
||||
"iiint" => "∭",
|
||||
"oint" => "∮",
|
||||
"bigcup" => "⋃",
|
||||
"bigcap" => "⋂",
|
||||
"bigvee" => "⋁",
|
||||
"bigwedge" => "⋀",
|
||||
"bigoplus" => "⨁",
|
||||
"bigotimes" => "⨂",
|
||||
"bigodot" => "⨀",
|
||||
"biguplus" => "⨄",
|
||||
// Named operators (render as plain words)
|
||||
"lim" => "lim",
|
||||
"limsup" => "lim sup",
|
||||
"liminf" => "lim inf",
|
||||
"sin" => "sin",
|
||||
"cos" => "cos",
|
||||
"tan" => "tan",
|
||||
"cot" => "cot",
|
||||
"sec" => "sec",
|
||||
"csc" => "csc",
|
||||
"arcsin" => "arcsin",
|
||||
"arccos" => "arccos",
|
||||
"arctan" => "arctan",
|
||||
"sinh" => "sinh",
|
||||
"cosh" => "cosh",
|
||||
"tanh" => "tanh",
|
||||
"coth" => "coth",
|
||||
"log" => "log",
|
||||
"ln" => "ln",
|
||||
"lg" => "lg",
|
||||
"exp" => "exp",
|
||||
"max" => "max",
|
||||
"min" => "min",
|
||||
"sup" => "sup",
|
||||
"inf" => "inf",
|
||||
"det" => "det",
|
||||
"dim" => "dim",
|
||||
"ker" => "ker",
|
||||
"deg" => "deg",
|
||||
"arg" => "arg",
|
||||
"gcd" => "gcd",
|
||||
"hom" => "hom",
|
||||
"Pr" => "Pr",
|
||||
// Binary operators
|
||||
"times" => "×",
|
||||
"cdot" => "⋅",
|
||||
"div" => "÷",
|
||||
"pm" => "±",
|
||||
"mp" => "∓",
|
||||
"ast" => "∗",
|
||||
"star" => "⋆",
|
||||
"circ" => "∘",
|
||||
"bullet" => "•",
|
||||
"oplus" => "⊕",
|
||||
"ominus" => "⊖",
|
||||
"otimes" => "⊗",
|
||||
"oslash" => "⊘",
|
||||
"odot" => "⊙",
|
||||
"wedge" | "land" => "∧",
|
||||
"vee" | "lor" => "∨",
|
||||
"cap" => "∩",
|
||||
"cup" => "∪",
|
||||
"setminus" => "∖",
|
||||
"smallsetminus" => "∖",
|
||||
"uplus" => "⊎",
|
||||
"sqcap" => "⊓",
|
||||
"sqcup" => "⊔",
|
||||
"triangleleft" => "◁",
|
||||
"triangleright" => "▷",
|
||||
"wr" => "≀",
|
||||
"diamond" => "⋄",
|
||||
"dagger" => "†",
|
||||
"ddagger" => "‡",
|
||||
"amalg" => "⨿",
|
||||
// Relations
|
||||
"le" | "leq" | "leqslant" => "≤",
|
||||
"ge" | "geq" | "geqslant" => "≥",
|
||||
"ne" | "neq" => "≠",
|
||||
"ll" => "≪",
|
||||
"gg" => "≫",
|
||||
"approx" => "≈",
|
||||
"sim" => "∼",
|
||||
"simeq" => "≃",
|
||||
"cong" => "≅",
|
||||
"equiv" => "≡",
|
||||
"doteq" => "≐",
|
||||
"propto" => "∝",
|
||||
"prec" => "≺",
|
||||
"succ" => "≻",
|
||||
"preceq" => "⪯",
|
||||
"succeq" => "⪰",
|
||||
"asymp" => "≍",
|
||||
"in" => "∈",
|
||||
"ni" | "owns" => "∋",
|
||||
"notin" => "∉",
|
||||
"subset" => "⊂",
|
||||
"supset" => "⊃",
|
||||
"subseteq" => "⊆",
|
||||
"supseteq" => "⊇",
|
||||
"subsetneq" => "⊊",
|
||||
"supsetneq" => "⊋",
|
||||
"sqsubseteq" => "⊑",
|
||||
"sqsupseteq" => "⊒",
|
||||
"vdash" => "⊢",
|
||||
"dashv" => "⊣",
|
||||
"models" | "vDash" => "⊨",
|
||||
"perp" => "⊥",
|
||||
"parallel" => "∥",
|
||||
"nparallel" => "∦",
|
||||
"mid" => "∣",
|
||||
"nmid" => "∤",
|
||||
"smile" => "⌣",
|
||||
"frown" => "⌢",
|
||||
"bowtie" => "⋈",
|
||||
// Arrows
|
||||
"to" | "rightarrow" => "→",
|
||||
"leftarrow" | "gets" => "←",
|
||||
"leftrightarrow" => "↔",
|
||||
"Rightarrow" => "⇒",
|
||||
"Leftarrow" => "⇐",
|
||||
"Leftrightarrow" => "⇔",
|
||||
"implies" => "⟹",
|
||||
"impliedby" => "⟸",
|
||||
"iff" => "⟺",
|
||||
"longrightarrow" => "⟶",
|
||||
"longleftarrow" => "⟵",
|
||||
"longmapsto" => "⟼",
|
||||
"mapsto" => "↦",
|
||||
"uparrow" => "↑",
|
||||
"downarrow" => "↓",
|
||||
"updownarrow" => "↕",
|
||||
"Uparrow" => "⇑",
|
||||
"Downarrow" => "⇓",
|
||||
"nearrow" => "↗",
|
||||
"searrow" => "↘",
|
||||
"swarrow" => "↙",
|
||||
"nwarrow" => "↖",
|
||||
"hookrightarrow" => "↪",
|
||||
"hookleftarrow" => "↩",
|
||||
"rightharpoonup" => "⇀",
|
||||
"leftharpoonup" => "↼",
|
||||
"rightleftharpoons" => "⇌",
|
||||
// Logic / sets / misc letters
|
||||
"forall" => "∀",
|
||||
"exists" => "∃",
|
||||
"nexists" => "∄",
|
||||
"neg" | "lnot" => "¬",
|
||||
"emptyset" | "varnothing" => "∅",
|
||||
"infty" => "∞",
|
||||
"nabla" => "∇",
|
||||
"partial" => "∂",
|
||||
"hbar" => "ℏ",
|
||||
"ell" => "ℓ",
|
||||
"Re" => "ℜ",
|
||||
"Im" => "ℑ",
|
||||
"aleph" => "ℵ",
|
||||
"beth" => "ℶ",
|
||||
"wp" => "℘",
|
||||
"imath" => "ı",
|
||||
"jmath" => "ȷ",
|
||||
"top" => "⊤",
|
||||
"bot" => "⊥",
|
||||
"angle" => "∠",
|
||||
"measuredangle" => "∡",
|
||||
"triangle" => "△",
|
||||
"square" | "Box" => "□",
|
||||
"blacksquare" => "■",
|
||||
"diamondsuit" => "♦",
|
||||
"heartsuit" => "♥",
|
||||
"clubsuit" => "♣",
|
||||
"spadesuit" => "♠",
|
||||
"flat" => "♭",
|
||||
"natural" => "♮",
|
||||
"sharp" => "♯",
|
||||
"checkmark" => "✓",
|
||||
"degree" => "°",
|
||||
"prime" => "′",
|
||||
"dprime" => "″",
|
||||
"therefore" => "∴",
|
||||
"because" => "∵",
|
||||
"dots" | "ldots" | "dotsc" | "dotso" | "dotsb" | "dotsm" => "…",
|
||||
"cdots" => "⋯",
|
||||
"vdots" => "⋮",
|
||||
"ddots" => "⋱",
|
||||
"surd" => "√",
|
||||
"AA" => "Å",
|
||||
// Delimiters
|
||||
"langle" => "⟨",
|
||||
"rangle" => "⟩",
|
||||
"lceil" => "⌈",
|
||||
"rceil" => "⌉",
|
||||
"lfloor" => "⌊",
|
||||
"rfloor" => "⌋",
|
||||
"lbrace" => "{",
|
||||
"rbrace" => "}",
|
||||
"lbrack" => "[",
|
||||
"rbrack" => "]",
|
||||
"vert" => "|",
|
||||
"Vert" | "|" => "‖",
|
||||
"backslash" => "\\",
|
||||
"setbslash" => "∖",
|
||||
// Escaped literals
|
||||
"{" => "{",
|
||||
"}" => "}",
|
||||
"%" => "%",
|
||||
"$" => "$",
|
||||
"&" => "&",
|
||||
"#" => "#",
|
||||
"_" => "_",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
use super::*;
|
||||
|
||||
fn inline(src: &str) -> String {
|
||||
latex_to_unicode_inline(src).expect("within size limit")
|
||||
}
|
||||
|
||||
fn display(src: &str) -> Vec<String> {
|
||||
latex_to_unicode_display(src).expect("within size limit")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_expression_passes_through() {
|
||||
assert_eq!(inline("E = mc"), "E = mc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscripts_map_to_unicode() {
|
||||
assert_eq!(inline("E = mc^2"), "E = mc²");
|
||||
assert_eq!(inline("x^{10}"), "x¹⁰");
|
||||
assert_eq!(inline("e^{-x}"), "e⁻ˣ");
|
||||
assert_eq!(inline("x^T"), "xᵀ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscripts_map_to_unicode() {
|
||||
assert_eq!(inline("a_1 + a_2"), "a₁ + a₂");
|
||||
assert_eq!(inline("x_{ij}"), "xᵢⱼ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_fallback_uses_parens() {
|
||||
// φ has no superscript form → fall back to ^(...)
|
||||
assert_eq!(inline("x^{\\alpha\\beta}"), "x^(αβ)");
|
||||
assert_eq!(inline("x^\\alpha"), "x^α");
|
||||
// Single unmappable subscript char.
|
||||
assert_eq!(inline("a_q"), "a_q");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wordlike_scripts_fall_back_to_parens() {
|
||||
// Text-family commands mark the atom as a word → no modifier-letter runs
|
||||
// (`pₜₒᵣₛₒ` is unreadable and gappy in many terminal fonts).
|
||||
assert_eq!(inline("p_{\\text{torso}}"), "p_(torso)");
|
||||
assert_eq!(inline("z_{\\mathrm{draft}}"), "z_(draft)");
|
||||
assert_eq!(inline("x^{\\text{opt}}"), "x^(opt)");
|
||||
// 3+ letter runs read as words even without \text.
|
||||
assert_eq!(inline("x_{max}"), "x_(max)");
|
||||
assert_eq!(inline("z_{torso}"), "z_(torso)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexlike_scripts_keep_unicode_forms() {
|
||||
// 1–2 letter runs are index juxtapositions, not words.
|
||||
assert_eq!(inline("x_{ij}"), "xᵢⱼ");
|
||||
assert_eq!(inline("T_{i+1}"), "Tᵢ₊₁");
|
||||
assert_eq!(inline("n^{th}"), "nᵗʰ");
|
||||
assert_eq!(inline("\\sum_{i=0}^{2} \\gamma^{i}"), "∑ᵢ₌₀² γⁱ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxed_renders_content_without_frame() {
|
||||
assert_eq!(inline("\\boxed{x = 1}"), "x = 1");
|
||||
assert_eq!(inline("\\boxed{\\mathcal{L}}"), "ℒ");
|
||||
assert_eq!(inline("\\fbox{done}"), "done");
|
||||
// Math typography applies inside \boxed (math mode) …
|
||||
assert_eq!(inline("\\boxed{a - b}"), "a − b");
|
||||
// … but not inside \fbox (text mode).
|
||||
assert_eq!(inline("\\fbox{a-b}"), "a-b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtp_loss_equation_converts_fully() {
|
||||
// A complex real-world equation: every command must
|
||||
// convert — no literal command names in the output.
|
||||
let src = "\\boxed{\n\\mathcal{L}_{\\text{MTP}}\n=\n\\sum_{i=0}^{2}\n\\gamma^{i}\\,\n\\mathbb{E}_{\\text{positions, mask}}\n\\Big[\n\\mathrm{KL}\\big(\n \\mathrm{softmax}(z_{\\text{torso}}^{(s_i)})\n \\;\\big\\|\\;\n \\mathrm{softmax}(z_{\\text{draft}}^{(i)})\n\\big)\n\\Big]\n}";
|
||||
let joined = inline(src);
|
||||
assert!(joined.contains("ℒ_(MTP)"), "got: {joined}");
|
||||
assert!(joined.contains("∑ᵢ₌₀²"), "got: {joined}");
|
||||
assert!(joined.contains("𝔼_(positions, mask)"), "got: {joined}");
|
||||
assert!(joined.contains("softmax(z_(torso)"), "got: {joined}");
|
||||
assert!(joined.contains("‖"), "got: {joined}");
|
||||
assert!(!joined.contains("boxed"), "got: {joined}");
|
||||
assert!(!joined.contains('\\'), "got: {joined}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greek_letters() {
|
||||
assert_eq!(inline("\\alpha + \\beta = \\Gamma"), "α + β = Γ");
|
||||
assert_eq!(inline("\\varepsilon \\varphi"), "ε φ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relations_and_operators() {
|
||||
assert_eq!(inline("a \\le b \\ne c \\times d"), "a ≤ b ≠ c × d");
|
||||
assert_eq!(inline("x \\in A \\cup B"), "x ∈ A ∪ B");
|
||||
assert_eq!(inline("p \\implies q"), "p ⟹ q");
|
||||
assert_eq!(inline("f: A \\to B"), "f: A → B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vulgar_and_general_fractions() {
|
||||
assert_eq!(inline("\\frac{1}{2}"), "½");
|
||||
assert_eq!(inline("\\frac{3}{4}"), "¾");
|
||||
assert_eq!(inline("\\frac{dy}{dx}"), "dy/dx");
|
||||
assert_eq!(inline("\\frac{a+b}{c}"), "(a+b)/c");
|
||||
assert_eq!(inline("\\frac{x}{y - z}"), "x/(y − z)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roots() {
|
||||
assert_eq!(inline("\\sqrt{x}"), "√x");
|
||||
assert_eq!(inline("\\sqrt{a + b}"), "√(a + b)");
|
||||
assert_eq!(inline("\\sqrt[3]{x}"), "∛x");
|
||||
assert_eq!(inline("\\sqrt[4]{x}"), "∜x");
|
||||
assert_eq!(inline("\\sqrt[n]{x}"), "ⁿ√x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_commands_pass_content_through() {
|
||||
assert_eq!(inline("\\text{if } x > 0"), "if x > 0");
|
||||
assert_eq!(inline("\\mathrm{d}x"), "dx");
|
||||
assert_eq!(inline("\\operatorname{softmax}(z)"), "softmax(z)");
|
||||
// Text mode must not map `-` to minus.
|
||||
assert_eq!(inline("\\text{x-ray}"), "x-ray");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alphabets() {
|
||||
assert_eq!(inline("\\mathbb{R}^n"), "ℝⁿ");
|
||||
assert_eq!(inline("\\mathbb{N} \\mathbb{Z} \\mathbb{Q}"), "ℕ ℤ ℚ");
|
||||
assert_eq!(inline("\\mathcal{L}"), "ℒ");
|
||||
assert_eq!(inline("\\mathcal{O}(n)"), "𝒪(n)");
|
||||
assert_eq!(inline("\\mathfrak{g}"), "𝔤");
|
||||
assert_eq!(inline("\\mathbf{v}"), "𝐯");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accents_use_combining_marks() {
|
||||
assert_eq!(inline("\\hat{x}"), "x\u{0302}");
|
||||
assert_eq!(inline("\\bar{y}"), "y\u{0304}");
|
||||
assert_eq!(inline("\\vec{v}"), "v\u{20D7}");
|
||||
assert_eq!(inline("\\dot{q}"), "q\u{0307}");
|
||||
assert_eq!(inline("\\tilde\\theta"), "θ\u{0303}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_right_and_spacing() {
|
||||
assert_eq!(inline("\\left( \\frac{1}{2} \\right)"), "( ½ )".to_string());
|
||||
assert_eq!(inline("\\left. x \\right|_0^1"), "x |₀¹");
|
||||
assert_eq!(inline("\\int f(x)\\,dx"), "∫ f(x) dx");
|
||||
assert_eq!(inline("a\\!b"), "ab");
|
||||
assert_eq!(inline("a \\quad b"), "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_function_operators() {
|
||||
assert_eq!(inline("\\sin(x) + \\cos(y)"), "sin(x) + cos(y)");
|
||||
assert_eq!(inline("\\lim_{x \\to 0} f(x)"), "lim_(x → 0) f(x)");
|
||||
assert_eq!(inline("\\log n"), "log n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrals_and_sums_with_bounds() {
|
||||
assert_eq!(inline("\\int_0^\\infty e^{-x} dx"), "∫₀^∞ e⁻ˣ dx");
|
||||
assert_eq!(inline("\\sum_{i=1}^{n} a_i"), "∑ᵢ₌₁ⁿ aᵢ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minus_and_prime_typography() {
|
||||
assert_eq!(inline("a - b"), "a − b");
|
||||
assert_eq!(inline("f'(x)"), "f′(x)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_negates_known_relations() {
|
||||
assert_eq!(inline("a \\not= b"), "a ≠ b");
|
||||
assert_eq!(inline("x \\not\\in S"), "x ∉ S");
|
||||
assert_eq!(inline("a \\not\\sim b"), "a ∼\u{0338} b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binomials_and_mod() {
|
||||
assert_eq!(inline("\\binom{n}{k}"), "C(n, k)");
|
||||
assert_eq!(inline("a \\equiv b \\pmod{m}"), "a ≡ b (mod m)");
|
||||
assert_eq!(inline("a \\bmod b"), "a mod b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_breaks_join_inline_and_split_display() {
|
||||
assert_eq!(inline("a \\\\ b"), "a; b");
|
||||
assert_eq!(display("a \\\\ b"), vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aligned_environment_strips_markers() {
|
||||
let lines = display("\\begin{aligned} x &= y + 1 \\\\ y &= 2 \\end{aligned}");
|
||||
assert_eq!(lines, vec!["x = y + 1", "y = 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cases_environment_renders_brace_column() {
|
||||
let lines = display("f(x) = \\begin{cases} x & x > 0 \\\\ 0 & \\text{otherwise} \\end{cases}");
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].starts_with("f(x) = ⎧ x"), "got {lines:?}");
|
||||
assert!(lines[1].trim_start().starts_with("⎩ 0"), "got {lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pmatrix_pads_columns() {
|
||||
let lines = display("\\begin{pmatrix} 1 & 22 \\\\ 333 & 4 \\end{pmatrix}");
|
||||
assert_eq!(lines, vec!["⎛1 22⎞", "⎝333 4⎠"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bmatrix_single_row_uses_flat_brackets() {
|
||||
assert_eq!(
|
||||
display("\\begin{bmatrix} a & b \\end{bmatrix}"),
|
||||
vec!["[a b]"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vmatrix_uses_bars() {
|
||||
let lines = display("\\begin{vmatrix} a & b \\\\ c & d \\end{vmatrix}");
|
||||
assert_eq!(lines, vec!["│a b│", "│c d│"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_with_prefix_aligns_as_box() {
|
||||
// The prefix must stay on the anchor row with the matrix body
|
||||
// aligned beneath — not glued to the first row only.
|
||||
let lines = display("A = \\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}");
|
||||
assert_eq!(lines, vec!["A = ⎛1 2⎞", " ⎝3 4⎠"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_with_prefix_and_suffix_flows_on_anchor_row() {
|
||||
let lines =
|
||||
display("A = \\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}, \\quad \\det(A) = -2");
|
||||
assert_eq!(lines, vec!["A = ⎛1 2⎞, det(A) = −2", " ⎝3 4⎠"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_row_matrix_anchors_on_middle_row() {
|
||||
let lines = display("v = \\begin{pmatrix} 1 \\\\ 2 \\\\ 3 \\end{pmatrix} x");
|
||||
assert_eq!(lines, vec![" ⎛1⎞", "v = ⎜2⎟ x", " ⎝3⎠"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cases_with_prefix_aligns_as_box() {
|
||||
let lines = display("f(x) = \\begin{cases} x & x > 0 \\\\ 0 & e \\end{cases}");
|
||||
assert_eq!(lines, vec!["f(x) = ⎧ x x > 0", " ⎩ 0 e"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_matrix_renders_flat() {
|
||||
assert_eq!(
|
||||
inline("\\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}"),
|
||||
"(1 2; 3 4)"
|
||||
);
|
||||
assert_eq!(inline("\\begin{bmatrix} a \\\\ b \\end{bmatrix}"), "[a; b]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_cases_renders_flat() {
|
||||
assert_eq!(
|
||||
inline("\\begin{cases} x & x > 0 \\\\ 0 & e \\end{cases}"),
|
||||
"{x x > 0; 0 e}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_matrices_on_one_line_share_rows() {
|
||||
let lines = display(
|
||||
"\\begin{pmatrix} 1 \\\\ 2 \\end{pmatrix} + \\begin{pmatrix} 3 \\\\ 4 \\end{pmatrix}",
|
||||
);
|
||||
assert_eq!(lines, vec!["⎛1⎞ + ⎛3⎞", "⎝2⎠ ⎝4⎠"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_break_then_matrix_does_not_disturb_previous_line() {
|
||||
let lines = display("a \\\\ B = \\begin{pmatrix} 1 \\\\ 2 \\end{pmatrix}");
|
||||
assert_eq!(lines, vec!["a", "B = ⎛1⎞", " ⎝2⎠"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_environment_renders_rows() {
|
||||
let lines = display("\\begin{foo} a \\\\ b \\end{foo}");
|
||||
assert_eq!(lines, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_environment_resolves_matching_end() {
|
||||
let lines = display(
|
||||
"\\begin{aligned} A &= \\begin{pmatrix} 1 \\end{pmatrix} \\\\ B &= 2 \\end{aligned}",
|
||||
);
|
||||
assert_eq!(lines, vec!["A = (1)", "B = 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_commands_keep_their_name() {
|
||||
assert_eq!(inline("\\foobar x"), "foobar x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overset_and_stackrel() {
|
||||
assert_eq!(inline("a \\overset{!}{=} b"), "a = b");
|
||||
assert_eq!(inline("a \\overset{n}{=} b"), "a =ⁿ b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_input_does_not_panic() {
|
||||
for src in [
|
||||
"",
|
||||
"{",
|
||||
"}",
|
||||
"\\",
|
||||
"\\frac{a}",
|
||||
"\\frac",
|
||||
"\\sqrt[",
|
||||
"\\begin{aligned} x",
|
||||
"\\begin",
|
||||
"\\end{x}",
|
||||
"^",
|
||||
"_",
|
||||
"^{",
|
||||
"a^",
|
||||
"{{{{{{",
|
||||
"\\left",
|
||||
"\\not",
|
||||
"$$$",
|
||||
"\\\\\\",
|
||||
"&&&&",
|
||||
] {
|
||||
let _ = latex_to_unicode_inline(src);
|
||||
let _ = latex_to_unicode_display(src);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_input_is_bounded() {
|
||||
let mut src = String::new();
|
||||
for _ in 0..200 {
|
||||
src.push('{');
|
||||
}
|
||||
src.push('x');
|
||||
for _ in 0..200 {
|
||||
src.push('}');
|
||||
}
|
||||
let _ = latex_to_unicode_inline(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_input_is_rejected() {
|
||||
let big = "x".repeat(MAX_MATH_SOURCE_LEN + 1);
|
||||
assert!(latex_to_unicode_inline(&big).is_none());
|
||||
assert!(latex_to_unicode_display(&big).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_display_is_empty() {
|
||||
assert!(display(" \n ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_literals() {
|
||||
assert_eq!(inline("100\\%"), "100%");
|
||||
assert_eq!(inline("\\{a, b\\}"), "{a, b}");
|
||||
assert_eq!(inline("\\$5"), "$5");
|
||||
}
|
||||
Reference in New Issue
Block a user