Files
luma/Sources/LumaCore/Markdown/MathRewriter.swift
Ole André Vadla Ravnåsandtrufae 2e00198952 Add block-level Markdown renderer
Port the mai-ios block parser into LumaCore; render headings,
lists, tables, task lists, blockquotes, code blocks and
footnotes across the SwiftUI and GTK frontends.

Parse inline spans and rewrite LaTeX to Unicode in LumaCore so
both frontends share one parser. Highlight code through
StyledText. Drop the inline-only AttributedString path.

Co-authored-by: trufae <[email protected]>
2026-06-26 16:00:59 +02:00

230 lines
8.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
public enum MathRewriter {
public static func rewrite(_ source: String) -> String {
rewriteDelimited(source)
}
private static func rewriteDelimited(_ source: String) -> String {
var out = ""
out.reserveCapacity(source.count)
let scalars = Array(source)
var i = 0
while i < scalars.count {
if let span = matchDelimiter(scalars, at: i) {
out += normalize(span.body)
i = span.end
continue
}
out.append(scalars[i])
i += 1
}
return out
}
private struct DelimitedSpan {
let body: String
let end: Int
}
private static func matchDelimiter(_ scalars: [Character], at start: Int) -> DelimitedSpan? {
for delimiter in delimiters {
if let span = match(scalars, at: start, open: delimiter.open, close: delimiter.close) {
return span
}
}
return nil
}
private static let delimiters: [(open: [Character], close: [Character])] = [
(["$", "$"], ["$", "$"]),
(["$"], ["$"]),
(["\\", "("], ["\\", ")"]),
(["\\", "["], ["\\", "]"]),
]
private static func match(
_ scalars: [Character],
at start: Int,
open: [Character],
close: [Character]
) -> DelimitedSpan? {
guard hasSequence(scalars, at: start, sequence: open) else { return nil }
if isEscaped(scalars, at: start) { return nil }
var cursor = start + open.count
var body = ""
while cursor < scalars.count {
if hasSequence(scalars, at: cursor, sequence: close) {
return DelimitedSpan(body: body, end: cursor + close.count)
}
if scalars[cursor] == "\n" { return nil }
body.append(scalars[cursor])
cursor += 1
}
return nil
}
private static func hasSequence(_ scalars: [Character], at index: Int, sequence: [Character]) -> Bool {
guard index + sequence.count <= scalars.count else { return false }
for offset in 0..<sequence.count where scalars[index + offset] != sequence[offset] {
return false
}
return true
}
private static func isEscaped(_ scalars: [Character], at index: Int) -> Bool {
var slashes = 0
var cursor = index - 1
while cursor >= 0, scalars[cursor] == "\\" {
slashes += 1
cursor -= 1
}
return slashes % 2 == 1
}
private static func normalize(_ math: String) -> String {
var text = math
text = replaceFractions(text)
text = replaceRoots(text)
text = replaceCommands(text)
text = replaceScripts(text)
text = text.replacingOccurrences(of: "\\,", with: " ")
text = text.replacingOccurrences(of: "\\quad", with: " ")
text = text.replacingOccurrences(of: "{", with: "")
text = text.replacingOccurrences(of: "}", with: "")
return text.trimmingCharacters(in: .whitespaces)
}
private static func replaceFractions(_ text: String) -> String {
rewriteBracedPairs(text, command: "\\frac") { "\($0)/\($1)" }
}
private static func replaceRoots(_ text: String) -> String {
rewriteBracedSingle(text, command: "\\sqrt") { "√\($0)" }
}
private static func replaceCommands(_ text: String) -> String {
var result = text
for (command, replacement) in symbolsByDescendingLength {
result = result.replacingOccurrences(of: command, with: replacement)
}
return result
}
private static let symbolsByDescendingLength = symbols.sorted { $0.0.count > $1.0.count }
private static func replaceScripts(_ text: String) -> String {
var out = ""
let scalars = Array(text)
var i = 0
while i < scalars.count {
if scalars[i] == "^", i + 1 < scalars.count, let mapped = superscripts[scalars[i + 1]] {
out.append(mapped)
i += 2
continue
}
if scalars[i] == "_", i + 1 < scalars.count, let mapped = subscripts[scalars[i + 1]] {
out.append(mapped)
i += 2
continue
}
out.append(scalars[i])
i += 1
}
return out
}
private static func rewriteBracedSingle(
_ text: String,
command: String,
transform: (String) -> String
) -> String {
var result = text
while let range = result.range(of: command + "{") {
guard let argEnd = matchingBrace(in: result, openBraceBefore: range.upperBound) else { break }
let arg = String(result[range.upperBound..<argEnd])
result.replaceSubrange(range.lowerBound..<result.index(after: argEnd), with: transform(arg))
}
return result
}
private static func rewriteBracedPairs(
_ text: String,
command: String,
transform: (String, String) -> String
) -> String {
var result = text
while let range = result.range(of: command + "{") {
guard let firstEnd = matchingBrace(in: result, openBraceBefore: range.upperBound),
result.index(after: firstEnd) < result.endIndex,
result[result.index(after: firstEnd)] == "{"
else { break }
let secondOpen = result.index(firstEnd, offsetBy: 2)
guard let secondEnd = matchingBrace(in: result, openBraceBefore: secondOpen) else { break }
let first = String(result[range.upperBound..<firstEnd])
let second = String(result[secondOpen..<secondEnd])
result.replaceSubrange(
range.lowerBound..<result.index(after: secondEnd),
with: transform(first, second)
)
}
return result
}
private static func matchingBrace(in text: String, openBraceBefore start: String.Index) -> String.Index? {
var depth = 1
var cursor = start
while cursor < text.endIndex {
switch text[cursor] {
case "{": depth += 1
case "}":
depth -= 1
if depth == 0 { return cursor }
default: break
}
cursor = text.index(after: cursor)
}
return nil
}
private static let superscripts: [Character: Character] = [
"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
"5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",
"+": "⁺", "-": "⁻", "n": "ⁿ", "i": "ⁱ",
]
private static let subscripts: [Character: Character] = [
"0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄",
"5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉",
"+": "₊", "-": "₋",
]
private static let symbols: [(String, String)] = [
("\\alpha", "α"), ("\\beta", "β"), ("\\gamma", "γ"), ("\\delta", "δ"),
("\\epsilon", "ε"), ("\\zeta", "ζ"), ("\\eta", "η"), ("\\theta", "θ"),
("\\iota", "ι"), ("\\kappa", "κ"), ("\\lambda", "λ"), ("\\mu", "μ"),
("\\nu", "ν"), ("\\xi", "ξ"), ("\\pi", "π"), ("\\rho", "ρ"),
("\\sigma", "σ"), ("\\tau", "τ"), ("\\phi", "φ"), ("\\chi", "χ"),
("\\psi", "ψ"), ("\\omega", "ω"),
("\\Gamma", "Γ"), ("\\Delta", "Δ"), ("\\Theta", "Θ"), ("\\Lambda", "Λ"),
("\\Xi", "Ξ"), ("\\Pi", "Π"), ("\\Sigma", "Σ"), ("\\Phi", "Φ"),
("\\Psi", "Ψ"), ("\\Omega", "Ω"),
("\\Rightarrow", "⇒"), ("\\Leftarrow", "⇐"), ("\\Leftrightarrow", "⇔"),
("\\rightarrow", "→"), ("\\leftarrow", "←"), ("\\leftrightarrow", "↔"),
("\\to", "→"), ("\\mapsto", "↦"),
("\\leq", "≤"), ("\\geq", "≥"), ("\\neq", "≠"), ("\\approx", "≈"),
("\\equiv", "≡"), ("\\sim", "∼"), ("\\propto", "∝"),
("\\times", "×"), ("\\div", "÷"), ("\\pm", "±"), ("\\mp", "∓"),
("\\cdot", "·"), ("\\ast", "∗"), ("\\star", "⋆"),
("\\in", "∈"), ("\\notin", "∉"), ("\\subset", "⊂"), ("\\supset", "⊃"),
("\\subseteq", "⊆"), ("\\supseteq", "⊇"), ("\\cup", "∪"), ("\\cap", "∩"),
("\\emptyset", "∅"), ("\\forall", "∀"), ("\\exists", "∃"),
("\\infty", "∞"), ("\\partial", "∂"), ("\\nabla", "∇"),
("\\sum", "∑"), ("\\prod", "∏"), ("\\int", "∫"),
("\\sqrt", "√"), ("\\angle", "∠"), ("\\perp", "⊥"),
("\\mathbb{R}", "ℝ"), ("\\mathbb{C}", "ℂ"), ("\\mathbb{N}", "ℕ"),
("\\mathbb{Z}", "ℤ"), ("\\mathbb{Q}", "ℚ"),
("\\ldots", "…"), ("\\cdots", "⋯"), ("\\langle", "⟨"), ("\\rangle", "⟩"),
]
}