···2020serde = { version = "1.0", features = ["derive"] }
2121semver = "1.0"
2222sha2 = "0.10"
2323+unicodeit = "0.2"
+2
README.md
···147147- ✅ TOC sidebar with active section tracking and two-level navigation
148148- ✅ Search with match highlighting, `/`, `Ctrl+F`, and `n` / `N`
149149- ✅ Code blocks `┌─ lang ───┐`
150150+- ✅ LaTeX math rendering — inline `$...$` and display `$$...$$` with Unicode conversion via `unicodeit`
151151+- ✅ LaTeX code blocks `` ```latex `` / `` ```tex `` rendered as formula blocks
150152- ✅ Bold, italic, strikethrough, blockquotes, lists, and horizontal rules
151153- ✅ YAML frontmatter is ignored in both preview and TOC
152154- ✅ Native stdin input with bounded size
+76-1
TESTING.md
···4848- tables with left, center, and right alignment
4949- fenced code blocks with language labels
5050- wide characters such as `東京`
5151+- inline math formulas with `$...$`
5252+- display math blocks with `$$...$$`
51535254### Navigation And Search
5355···90929193## Notes
92949393-The fixture intentionally includes repeated search terms, loose list items, ordered lists starting at non-`1` values, tables, code blocks, and wide characters because those are easy places for terminal Markdown renderers to regress.
9595+The fixture intentionally includes repeated search terms, loose list items, ordered lists starting at non-`1` values, tables, code blocks, wide characters, and math formulas because those are easy places for terminal Markdown renderers to regress.
94969597## Manual Fixture
9698···213215 primary: tokyo-signal
214216 secondary: unicode-width-check
215217```
218218+219219+### Math Inline
220220+221221+The Pythagorean theorem states that $a^2 + b^2 = c^2$ in a right triangle.
222222+223223+Einstein's famous equation $E = mc^2$ relates energy and mass.
224224+225225+This paragraph mixes **bold with $x^2 + y^2$** and *italic with $\alpha + \beta$* and `code` to check style interactions.
226226+227227+The area of a circle is $A = \pi r^2$ and its circumference is $C = 2\pi r$.
228228+229229+A sum $\sum_{i=1}^{n} x_i$ and an integral $\int_0^1 f(x)\,dx$ inline.
230230+231231+### Math Display
232232+233233+$$E = mc^2$$
234234+235235+$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$
236236+237237+$$\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6}$$
238238+239239+$$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$$
240240+241241+$$\nabla \times \vec{E} = -\frac{\partial \vec{B}}{\partial t}$$
242242+243243+### Math in Context
244244+245245+#### Math in Blockquote
246246+247247+> Euler's identity: $e^{i\pi} + 1 = 0$
248248+>
249249+> As a display block:
250250+>
251251+> $$e^{i\pi} + 1 = 0$$
252252+253253+#### Math in List
254254+255255+- Newton's first law: $F = 0 \Rightarrow \Delta v = 0$
256256+- Newton's second law: $F = ma$
257257+- Gravitation: $F = G\frac{m_1 m_2}{r^2}$
258258+259259+1. Quadratic: $ax^2 + bx + c = 0$
260260+2. Solution: $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$
261261+262262+#### Math with Unicode Symbols
263263+264264+Symbols render correctly: ∀x ∈ ℝ, ∃y such that x + y = 0.
265265+266266+Greek letters: α β γ δ ε π σ ω and uppercase Γ Δ Σ Ω.
267267+268268+Superscripts and subscripts: x⁰ x¹ x² x³ x⁴ aₙ = aₙ₋₁ + aₙ₋₂.
269269+270270+Operators: ≤ ≥ ≠ ≈ ± × ÷ → ⇒ ⇔ ∪ ∩ ⊂ ∅ ∞.
271271+272272+#### LaTeX Code Block
273273+274274+```latex
275275+\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
276276+```
277277+278278+```latex
279279+\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6}
280280+281281+\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
282282+```
283283+284284+```tex
285285+\nabla \times \vec{E} = -\frac{\partial \vec{B}}{\partial t}
286286+```
287287+288288+#### Dollar Sign (No Math)
289289+290290+This costs $5.00 and that costs $10 each.
216291217292### Wide Characters
218293
+223
src/markdown/latex.rs
···11+pub(crate) fn to_unicode(text: &str) -> String {
22+ let preprocessed = strip_command_spaces(text);
33+ let converted = unicodeit::replace(&preprocessed);
44+ postprocess(&converted)
55+}
66+77+fn strip_command_spaces(input: &str) -> String {
88+ let mut result = String::with_capacity(input.len());
99+ let chars: Vec<char> = input.chars().collect();
1010+ let len = chars.len();
1111+ let mut i = 0;
1212+1313+ while i < len {
1414+ if chars[i] == '\\' && i + 1 < len && chars[i + 1].is_ascii_alphabetic() {
1515+ result.push('\\');
1616+ i += 1;
1717+ while i < len && chars[i].is_ascii_alphabetic() {
1818+ result.push(chars[i]);
1919+ i += 1;
2020+ }
2121+ if i < len && chars[i] == ' ' {
2222+ let next = chars.get(i + 1).copied().unwrap_or(' ');
2323+ if next.is_ascii_alphabetic() || next == '\\' || next == '{' {
2424+ i += 1;
2525+ }
2626+ }
2727+ continue;
2828+ }
2929+ result.push(chars[i]);
3030+ i += 1;
3131+ }
3232+3333+ result
3434+}
3535+3636+fn postprocess(input: &str) -> String {
3737+ let mut result = String::with_capacity(input.len());
3838+ let mut i = 0;
3939+4040+ while i < input.len() {
4141+ if input[i..].starts_with("\\frac{") {
4242+ if let Some((output, end)) = parse_frac(input, i) {
4343+ result.push_str(&output);
4444+ i = end;
4545+ continue;
4646+ }
4747+ result.push_str("\\frac{");
4848+ i += 6;
4949+ continue;
5050+ }
5151+5252+ if input[i..].starts_with("√{") {
5353+ let brace_start = i + '√'.len_utf8() + 1;
5454+ if let Some((group, end)) = read_brace_group(input, brace_start) {
5555+ result.push('√');
5656+ result.push('(');
5757+ result.push_str(&postprocess(group));
5858+ result.push(')');
5959+ i = end;
6060+ continue;
6161+ }
6262+ }
6363+6464+ if input[i..].starts_with("^{") {
6565+ if let Some((output, end)) = convert_script(input, i + 2, to_superscript) {
6666+ result.push_str(&output);
6767+ i = end;
6868+ continue;
6969+ }
7070+ result.push_str("^{");
7171+ i += 2;
7272+ continue;
7373+ }
7474+7575+ if input[i..].starts_with("_{") {
7676+ if let Some((output, end)) = convert_script(input, i + 2, to_subscript) {
7777+ result.push_str(&output);
7878+ i = end;
7979+ continue;
8080+ }
8181+ result.push_str("_{");
8282+ i += 2;
8383+ continue;
8484+ }
8585+8686+ if input[i..].starts_with('^') && i + 1 < input.len() {
8787+ let next = input[i + 1..].chars().next().unwrap();
8888+ if next != '{' {
8989+ i += 1;
9090+ continue;
9191+ }
9292+ }
9393+9494+ let ch = input[i..].chars().next().unwrap();
9595+ result.push(ch);
9696+ i += ch.len_utf8();
9797+ }
9898+9999+ result
100100+}
101101+102102+fn parse_frac(input: &str, start: usize) -> Option<(String, usize)> {
103103+ let after_frac = start + 6;
104104+ let (num, after_num) = read_brace_group(input, after_frac)?;
105105+ if after_num >= input.len() || input.as_bytes()[after_num] != b'{' {
106106+ return None;
107107+ }
108108+ let (den, after_den) = read_brace_group(input, after_num + 1)?;
109109+ let num = postprocess(num);
110110+ let den = postprocess(den);
111111+ let mut out = String::new();
112112+ wrap_if_multi(&mut out, &num);
113113+ out.push('/');
114114+ wrap_if_multi(&mut out, &den);
115115+ Some((out, after_den))
116116+}
117117+118118+fn wrap_if_multi(out: &mut String, s: &str) {
119119+ if s.chars().count() > 1 {
120120+ out.push('(');
121121+ out.push_str(s);
122122+ out.push(')');
123123+ } else {
124124+ out.push_str(s);
125125+ }
126126+}
127127+128128+fn convert_script(
129129+ input: &str,
130130+ brace_start: usize,
131131+ mapper: fn(char) -> char,
132132+) -> Option<(String, usize)> {
133133+ let (group, end) = read_brace_group(input, brace_start)?;
134134+ let group = postprocess(group);
135135+ let mapped: String = group.chars().map(mapper).collect();
136136+ let all_converted = mapped
137137+ .chars()
138138+ .zip(group.chars())
139139+ .all(|(m, g)| m != g || g.is_ascii_digit());
140140+ if all_converted {
141141+ Some((mapped, end))
142142+ } else {
143143+ Some((format!("({group})"), end))
144144+ }
145145+}
146146+147147+fn read_brace_group(input: &str, start: usize) -> Option<(&str, usize)> {
148148+ let bytes = input.as_bytes();
149149+ let mut depth: u32 = 1;
150150+ let mut i = start;
151151+ while i < bytes.len() && depth > 0 {
152152+ match bytes[i] {
153153+ b'{' => depth += 1,
154154+ b'}' => depth -= 1,
155155+ _ => {}
156156+ }
157157+ if depth > 0 {
158158+ i += 1;
159159+ }
160160+ }
161161+ if depth == 0 {
162162+ Some((&input[start..i], i + 1))
163163+ } else {
164164+ None
165165+ }
166166+}
167167+168168+fn to_superscript(ch: char) -> char {
169169+ match ch {
170170+ '0' => '⁰',
171171+ '1' => '¹',
172172+ '2' => '²',
173173+ '3' => '³',
174174+ '4' => '⁴',
175175+ '5' => '⁵',
176176+ '6' => '⁶',
177177+ '7' => '⁷',
178178+ '8' => '⁸',
179179+ '9' => '⁹',
180180+ '+' => '⁺',
181181+ '-' | '−' => '⁻',
182182+ '=' => '⁼',
183183+ '(' => '⁽',
184184+ ')' => '⁾',
185185+ 'n' => 'ⁿ',
186186+ 'i' => 'ⁱ',
187187+ _ => ch,
188188+ }
189189+}
190190+191191+fn to_subscript(ch: char) -> char {
192192+ match ch {
193193+ '0' => '₀',
194194+ '1' => '₁',
195195+ '2' => '₂',
196196+ '3' => '₃',
197197+ '4' => '₄',
198198+ '5' => '₅',
199199+ '6' => '₆',
200200+ '7' => '₇',
201201+ '8' => '₈',
202202+ '9' => '₉',
203203+ '+' => '₊',
204204+ '-' | '−' => '₋',
205205+ '=' => '₌',
206206+ '(' => '₍',
207207+ ')' => '₎',
208208+ 'a' => 'ₐ',
209209+ 'e' => 'ₑ',
210210+ 'i' => 'ᵢ',
211211+ 'j' => 'ⱼ',
212212+ 'k' => 'ₖ',
213213+ 'n' => 'ₙ',
214214+ 'o' => 'ₒ',
215215+ 'p' => 'ₚ',
216216+ 'r' => 'ᵣ',
217217+ 's' => 'ₛ',
218218+ 't' => 'ₜ',
219219+ 'u' => 'ᵤ',
220220+ 'x' => 'ₓ',
221221+ _ => ch,
222222+ }
223223+}