| 1 | <?php |
| 2 | namespace Std\Lib; |
| 3 | |
| 4 | /** |
| 5 | * LaTeX数式をMathMLに変換する(JavaScript不要、ブラウザがネイティブで描画する) |
| 6 | * |
| 7 | * 対応範囲はチュートリアルで使うLaTeXのサブセット: |
| 8 | * 上付き・下付き、\frac、\sqrt、\left…\right、行列環境、\text、\mathbf等の書体、 |
| 9 | * アクセント、ギリシャ文字、関数名、演算子・関係記号・矢印、空白。 |
| 10 | * 未対応のコマンドは<merror>として出力し、getErrors()で取得出来る。 |
| 11 | */ |
| 12 | class Latex { |
| 13 | private string $src = ''; |
| 14 | private int $pos = 0; |
| 15 | private bool $display = false; |
| 16 | private string $variant = ''; |
| 17 | private array $errors = []; |
| 18 | |
| 19 | private const GREEK = [ |
| 20 | 'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ', 'delta' => 'δ', 'epsilon' => 'ϵ', |
| 21 | 'varepsilon' => 'ε', 'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ', 'vartheta' => 'ϑ', |
| 22 | 'iota' => 'ι', 'kappa' => 'κ', 'lambda' => 'λ', 'mu' => 'μ', 'nu' => 'ν', 'xi' => 'ξ', |
| 23 | 'omicron' => 'ο', 'pi' => 'π', 'varpi' => 'ϖ', 'rho' => 'ρ', 'varrho' => 'ϱ', |
| 24 | 'sigma' => 'σ', 'varsigma' => 'ς', 'tau' => 'τ', 'upsilon' => 'υ', 'phi' => 'ϕ', |
| 25 | 'varphi' => 'φ', 'chi' => 'χ', 'psi' => 'ψ', 'omega' => 'ω', |
| 26 | ]; |
| 27 | |
| 28 | private const GREEK_UPPER = [ |
| 29 | 'Gamma' => 'Γ', 'Delta' => 'Δ', 'Theta' => 'Θ', 'Lambda' => 'Λ', 'Xi' => 'Ξ', |
| 30 | 'Pi' => 'Π', 'Sigma' => 'Σ', 'Upsilon' => 'Υ', 'Phi' => 'Φ', 'Psi' => 'Ψ', 'Omega' => 'Ω', |
| 31 | ]; |
| 32 | |
| 33 | // 識別子として扱う記号 / symbols rendered as identifiers |
| 34 | private const IDENT = [ |
| 35 | 'infty' => '∞', 'partial' => '∂', 'nabla' => '∇', 'ell' => 'ℓ', 'hbar' => 'ℏ', |
| 36 | 'top' => '⊤', 'bot' => '⊥', 'degree' => '°', 'emptyset' => '∅', 'Re' => 'ℜ', 'Im' => 'ℑ', |
| 37 | 'aleph' => 'ℵ', 'imath' => 'ı', 'jmath' => 'ȷ', |
| 38 | ]; |
| 39 | |
| 40 | // 演算子・関係記号・矢印 / operators, relations, arrows |
| 41 | private const OPS = [ |
| 42 | 'cdot' => '⋅', 'times' => '×', 'div' => '÷', 'pm' => '±', 'mp' => '∓', 'ast' => '∗', |
| 43 | 'star' => '⋆', 'circ' => '∘', 'bullet' => '∙', 'otimes' => '⊗', 'oplus' => '⊕', |
| 44 | 'le' => '≤', 'leq' => '≤', 'ge' => '≥', 'geq' => '≥', 'ne' => '≠', 'neq' => '≠', |
| 45 | 'approx' => '≈', 'equiv' => '≡', 'sim' => '∼', 'simeq' => '≃', 'cong' => '≅', |
| 46 | 'propto' => '∝', 'll' => '≪', 'gg' => '≫', 'perp' => '⊥', 'parallel' => '∥', |
| 47 | 'to' => '→', 'rightarrow' => '→', 'leftarrow' => '←', 'gets' => '←', |
| 48 | 'leftrightarrow' => '↔', 'Rightarrow' => '⇒', 'Leftarrow' => '⇐', |
| 49 | 'Leftrightarrow' => '⇔', 'iff' => '⟺', 'implies' => '⟹', 'mapsto' => '↦', |
| 50 | 'uparrow' => '↑', 'downarrow' => '↓', 'longrightarrow' => '⟶', |
| 51 | 'in' => '∈', 'notin' => '∉', 'ni' => '∋', 'subset' => '⊂', 'subseteq' => '⊆', |
| 52 | 'supset' => '⊃', 'supseteq' => '⊇', 'cup' => '∪', 'cap' => '∩', 'setminus' => '∖', |
| 53 | 'forall' => '∀', 'exists' => '∃', 'neg' => '¬', 'lnot' => '¬', 'land' => '∧', |
| 54 | 'wedge' => '∧', 'lor' => '∨', 'vee' => '∨', 'angle' => '∠', |
| 55 | 'ldots' => '…', 'dots' => '…', 'cdots' => '⋯', 'vdots' => '⋮', 'ddots' => '⋱', |
| 56 | 'prime' => '′', 'colon' => ':', 'mid' => '∣', |
| 57 | ]; |
| 58 | |
| 59 | // 区切り記号 / delimiters |
| 60 | private const DELIMS = [ |
| 61 | '(' => '(', ')' => ')', '[' => '[', ']' => ']', '|' => '|', '/' => '/', |
| 62 | '{' => '{', '}' => '}', '\\{' => '{', '\\}' => '}', '\\|' => '‖', |
| 63 | '\\lVert' => '‖', '\\rVert' => '‖', '\\Vert' => '‖', '\\lvert' => '|', '\\rvert' => '|', |
| 64 | '\\vert' => '|', '\\langle' => '⟨', '\\rangle' => '⟩', '\\lfloor' => '⌊', |
| 65 | '\\rfloor' => '⌋', '\\lceil' => '⌈', '\\rceil' => '⌉', '.' => '', |
| 66 | ]; |
| 67 | |
| 68 | // 括弧として使う記号 / fence-like commands usable outside \left…\right |
| 69 | private const FENCES = [ |
| 70 | 'lVert' => '‖', 'rVert' => '‖', 'Vert' => '‖', '|' => '‖', 'lvert' => '|', |
| 71 | 'rvert' => '|', 'vert' => '|', 'langle' => '⟨', 'rangle' => '⟩', 'lfloor' => '⌊', |
| 72 | 'rfloor' => '⌋', 'lceil' => '⌈', 'rceil' => '⌉', '{' => '{', '}' => '}', |
| 73 | ]; |
| 74 | |
| 75 | // 関数名 / function names (upright) |
| 76 | private const FUNCS = [ |
| 77 | 'sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'arcsin', 'arccos', 'arctan', |
| 78 | 'sinh', 'cosh', 'tanh', 'log', 'ln', 'exp', 'det', 'dim', 'ker', 'deg', 'gcd', |
| 79 | 'arg', 'Pr', 'hom', |
| 80 | ]; |
| 81 | |
| 82 | // 下に添え字を付ける関数 / functions that take limits underneath in display mode |
| 83 | private const LIMIT_FUNCS = ['lim', 'max', 'min', 'sup', 'inf', 'limsup', 'liminf']; |
| 84 | |
| 85 | // 大型演算子 / large operators |
| 86 | private const BIG_OPS = [ |
| 87 | 'sum' => ['∑', true], 'prod' => ['∏', true], 'coprod' => ['∐', true], |
| 88 | 'bigcup' => ['⋃', true], 'bigcap' => ['⋂', true], |
| 89 | 'int' => ['∫', false], 'iint' => ['∬', false], 'iiint' => ['∭', false], 'oint' => ['∮', false], |
| 90 | ]; |
| 91 | |
| 92 | private const ACCENTS = [ |
| 93 | 'vec' => '→', 'hat' => '^', 'widehat' => '^', 'bar' => '¯', 'dot' => '˙', |
| 94 | 'ddot' => '¨', 'tilde' => '~', 'widetilde' => '~', 'check' => 'ˇ', 'breve' => '˘', |
| 95 | ]; |
| 96 | |
| 97 | private const SPACES = [ |
| 98 | ',' => '0.1667em', ':' => '0.2222em', '>' => '0.2222em', ';' => '0.2778em', |
| 99 | ' ' => '0.25em', 'quad' => '1em', 'qquad' => '2em', 'enspace' => '0.5em', |
| 100 | 'thinspace' => '0.1667em', |
| 101 | ]; |
| 102 | |
| 103 | private const ENV_FENCES = [ |
| 104 | 'matrix' => ['', ''], 'pmatrix' => ['(', ')'], 'bmatrix' => ['[', ']'], |
| 105 | 'Bmatrix' => ['{', '}'], 'vmatrix' => ['|', '|'], 'Vmatrix' => ['‖', '‖'], |
| 106 | 'smallmatrix' => ['', ''], |
| 107 | ]; |
| 108 | |
| 109 | /** |
| 110 | * LaTeXをMathMLに変換する |
| 111 | * |
| 112 | * @param string $tex LaTeXの数式($$は含まない) |
| 113 | * @param bool $display ブロック表示ならtrue |
| 114 | * @return string <math>要素 |
| 115 | */ |
| 116 | public function render(string $tex, bool $display = false): string { |
| 117 | $this->src = $tex; |
| 118 | $this->pos = 0; |
| 119 | $this->display = $display; |
| 120 | $this->variant = ''; |
| 121 | $this->errors = []; |
| 122 | |
| 123 | $nodes = $this->parseList(); |
| 124 | // 外側に余ったトークンを読み飛ばす / skip stray tokens at the top level |
| 125 | while ($this->pos < strlen($this->src)) { |
| 126 | if ($this->startsWith('\\\\')) $this->pos += 2; |
| 127 | else if ($this->startsWith('\\end')) { $this->readCommand(); $this->readGroupRaw(); } |
| 128 | else if ($this->startsWith('\\right')) { $this->readCommand(); $this->readDelimiter(); } |
| 129 | else $this->pos++; |
| 130 | $nodes = array_merge($nodes, $this->parseList()); |
| 131 | } |
| 132 | |
| 133 | $alt = htmlspecialchars(trim(preg_replace('/\s+/', ' ', $tex)), ENT_QUOTES, 'UTF-8'); |
| 134 | $attr = $display ? ' display="block"' : ''; |
| 135 | return "<math{$attr} alttext=\"{$alt}\">".$this->row($nodes).'</math>'; |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * 直前のrender()で見つかった未対応のコマンド |
| 140 | * |
| 141 | * @return array エラーメッセージの配列 |
| 142 | */ |
| 143 | public function getErrors(): array { |
| 144 | return $this->errors; |
| 145 | } |
| 146 | |
| 147 | //------------------------------------------ |
| 148 | // パーサー |
| 149 | //------------------------------------------ |
| 150 | |
| 151 | /** |
| 152 | * 終端記号(}、&、\\、\end、\right)又は文末まで要素を読む |
| 153 | * |
| 154 | * @return array ノードの配列 |
| 155 | */ |
| 156 | private function parseList(): array { |
| 157 | $nodes = []; |
| 158 | while (true) { |
| 159 | $this->skipSpace(); |
| 160 | if ($this->atStop()) break; |
| 161 | $node = $this->parseScripted(); |
| 162 | if ($node === null) continue; |
| 163 | $nodes[] = $node; |
| 164 | } |
| 165 | |
| 166 | // 先頭、又は演算子・開き括弧の直後の+−は符号(空白無し) / a +/− at the start or after an operator is a sign |
| 167 | $count = count($nodes); |
| 168 | for ($i = 0; $i < $count; $i++) { |
| 169 | if (empty($nodes[$i]['sign'])) continue; |
| 170 | $prev = $i > 0 ? $nodes[$i - 1]['xml'] : ''; |
| 171 | $isClose = preg_match('/^<mo[^>]*>[\)\]\}|‖⟩⌋⌉]<\/mo>$/u', $prev); |
| 172 | if ($i === 0 || (str_starts_with($prev, '<mo') && !$isClose)) { |
| 173 | $nodes[$i]['xml'] = str_replace('<mo>', '<mo form="prefix">', $nodes[$i]['xml']); |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // 関数名の後に細い空白を入れる / thin space after function names |
| 178 | $out = []; |
| 179 | for ($i = 0; $i < $count; $i++) { |
| 180 | // 識別子の直後の関数名の前にも細い空白(a\tan x) / thin space between an identifier and a function name |
| 181 | if (!empty($nodes[$i]['func']) && $i > 0 && !str_starts_with($nodes[$i - 1]['xml'], '<mo') |
| 182 | && !str_starts_with($nodes[$i - 1]['xml'], '<mspace')) |
| 183 | $out[] = '<mspace width="0.1667em"></mspace>'; |
| 184 | $out[] = $nodes[$i]['xml']; |
| 185 | if (!empty($nodes[$i]['func']) && $i + 1 < $count) { |
| 186 | $next = $nodes[$i + 1]['xml']; |
| 187 | if (!str_starts_with($next, '<mo stretchy="false">(</mo>') |
| 188 | && !str_starts_with($next, '<mrow><mo fence="true"')) |
| 189 | $out[] = '<mspace width="0.1667em"></mspace>'; |
| 190 | } |
| 191 | } |
| 192 | return $out; |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * 一つの要素と其の上付き・下付きを読む |
| 197 | * |
| 198 | * @return array|null ['xml' => string, 'func' => bool] |
| 199 | */ |
| 200 | private function parseScripted(): ?array { |
| 201 | $this->skipSpace(); |
| 202 | $c = $this->peek(); |
| 203 | |
| 204 | if ($c === '^' || $c === '_') { |
| 205 | $base = ['xml' => '<mrow></mrow>', 'limits' => false, 'func' => false]; |
| 206 | } else { |
| 207 | $base = $this->parseAtom(); |
| 208 | if ($base === null) return null; |
| 209 | } |
| 210 | |
| 211 | $sub = null; |
| 212 | $sup = null; |
| 213 | $primes = ''; |
| 214 | while (true) { |
| 215 | $this->skipSpace(); |
| 216 | $c = $this->peek(); |
| 217 | if ($c === '^' && $sup === null) { |
| 218 | $this->pos++; |
| 219 | $sup = $this->parseScriptArg(); |
| 220 | } else if ($c === '_' && $sub === null) { |
| 221 | $this->pos++; |
| 222 | $sub = $this->parseScriptArg(); |
| 223 | } else if ($c === "'") { |
| 224 | $this->pos++; |
| 225 | $primes .= '′'; |
| 226 | } else break; |
| 227 | } |
| 228 | |
| 229 | // ′は既に上付きの形の字なので、他の上付きが無ければ横に並べるだけ |
| 230 | // The prime glyph is already raised, so with no other superscript just place it after the base |
| 231 | if ($primes !== '') { |
| 232 | if ($sup === null) { |
| 233 | $p = '<mo lspace="0" rspace="0">'.$primes.'</mo>'; |
| 234 | $xml = '<mrow>'.$base['xml'].$p.'</mrow>'; |
| 235 | if ($sub === null) return ['xml' => $xml, 'func' => !empty($base['func'])]; |
| 236 | $base = ['xml' => $base['xml'], 'func' => !empty($base['func'])]; |
| 237 | $b = "<msub>{$base['xml']}".$this->wrap($sub)."</msub>"; |
| 238 | return ['xml' => '<mrow>'.$b.$p.'</mrow>', 'func' => false]; |
| 239 | } |
| 240 | $sup = '<mo>'.$primes.'</mo>'.$sup; |
| 241 | } |
| 242 | |
| 243 | if ($sub === null && $sup === null) return $base; |
| 244 | |
| 245 | $b = $base['xml']; |
| 246 | $under = !empty($base['under']) || (!empty($base['limits']) && $this->display); |
| 247 | if ($sub !== null) $sub = $this->wrap($sub); |
| 248 | if ($sup !== null) $sup = $this->wrap($sup); |
| 249 | |
| 250 | if ($under) { |
| 251 | if ($sub !== null && $sup !== null) $xml = "<munderover>{$b}{$sub}{$sup}</munderover>"; |
| 252 | else if ($sub !== null) $xml = "<munder>{$b}{$sub}</munder>"; |
| 253 | else $xml = "<mover>{$b}{$sup}</mover>"; |
| 254 | } else { |
| 255 | if ($sub !== null && $sup !== null) $xml = "<msubsup>{$b}{$sub}{$sup}</msubsup>"; |
| 256 | else if ($sub !== null) $xml = "<msub>{$b}{$sub}</msub>"; |
| 257 | else $xml = "<msup>{$b}{$sup}</msup>"; |
| 258 | } |
| 259 | |
| 260 | return ['xml' => $xml, 'func' => !empty($base['func'])]; |
| 261 | } |
| 262 | |
| 263 | /** |
| 264 | * 上付き・下付きの引数(1文字、1コマンド、又は{…}) |
| 265 | * |
| 266 | * @return string MathML |
| 267 | */ |
| 268 | private function parseScriptArg(): string { |
| 269 | $this->skipSpace(); |
| 270 | $c = $this->peek(); |
| 271 | if ($c === '{') return $this->parseGroup(); |
| 272 | if ($c === '' ) return '<mrow></mrow>'; |
| 273 | if (ctype_digit($c)) { |
| 274 | $this->pos++; |
| 275 | return '<mn>'.$this->styleChar($c).'</mn>'; |
| 276 | } |
| 277 | $atom = $this->parseAtom(); |
| 278 | return $atom === null ? '<mrow></mrow>' : $atom['xml']; |
| 279 | } |
| 280 | |
| 281 | /** |
| 282 | * {…}を読む |
| 283 | * |
| 284 | * @return string MathML(mrow) |
| 285 | */ |
| 286 | private function parseGroup(): string { |
| 287 | $this->skipSpace(); |
| 288 | if ($this->peek() !== '{') { |
| 289 | // 波括弧の無い引数:1要素だけ / argument without braces: a single element |
| 290 | $atom = $this->parseAtom(); |
| 291 | return $atom === null ? '<mrow></mrow>' : $atom['xml']; |
| 292 | } |
| 293 | $this->pos++; |
| 294 | $nodes = $this->parseList(); |
| 295 | if ($this->peek() === '}') $this->pos++; |
| 296 | return $this->row($nodes, true); |
| 297 | } |
| 298 | |
| 299 | /** |
| 300 | * 一つの要素を読む |
| 301 | * |
| 302 | * @return array|null ノード |
| 303 | */ |
| 304 | private function parseAtom(): ?array { |
| 305 | $c = $this->peek(); |
| 306 | if ($c === '') return null; |
| 307 | |
| 308 | if ($c === '{') { |
| 309 | return $this->node($this->parseGroup()); |
| 310 | } |
| 311 | |
| 312 | if ($c === '\\') return $this->parseCommand(); |
| 313 | |
| 314 | // 数字 / numbers |
| 315 | if (preg_match('/\G[0-9]+(?:\.[0-9]+)?/', $this->src, $m, 0, $this->pos)) { |
| 316 | $this->pos += strlen($m[0]); |
| 317 | return $this->node('<mn>'.$this->styleString($m[0]).'</mn>'); |
| 318 | } |
| 319 | |
| 320 | // 英字 / latin letters |
| 321 | if (ctype_alpha($c)) { |
| 322 | $this->pos++; |
| 323 | return $this->node($this->ident($c)); |
| 324 | } |
| 325 | |
| 326 | if ($c === '~') { |
| 327 | $this->pos++; |
| 328 | return $this->node('<mspace width="0.25em"></mspace>'); |
| 329 | } |
| 330 | |
| 331 | // 1文字(UTF-8対応) / one character (UTF-8 aware) |
| 332 | preg_match('/\G./us', $this->src, $m, 0, $this->pos); |
| 333 | $ch = $m[0] ?? $c; |
| 334 | $this->pos += strlen($ch); |
| 335 | |
| 336 | switch ($ch) { |
| 337 | case '-': return ['xml' => '<mo>−</mo>', 'func' => false, 'limits' => false, 'sign' => true]; |
| 338 | case '+': return ['xml' => '<mo>+</mo>', 'func' => false, 'limits' => false, 'sign' => true]; |
| 339 | case '*': return $this->node('<mo>∗</mo>'); |
| 340 | case '(': case ')': case '[': case ']': case '|': |
| 341 | return $this->node('<mo stretchy="false">'.$this->esc($ch).'</mo>'); |
| 342 | case '/': // TeXでは/は普通の文字(空白無し) / in TeX the slash is an ordinary symbol |
| 343 | return $this->node('<mo lspace="0" rspace="0">/</mo>'); |
| 344 | case '=': case '<': case '>': case ',': case ';': case ':': |
| 345 | case '!': case '?': case '.': case '@': |
| 346 | return $this->node('<mo>'.$this->esc($ch).'</mo>'); |
| 347 | } |
| 348 | |
| 349 | // ASCII以外:ギリシャ文字は識別子、記号は演算子、其れ以外は文字列 |
| 350 | if (preg_match('/^[\x{0370}-\x{03FF}]$/u', $ch)) return $this->node($this->ident($ch)); |
| 351 | if (preg_match('/^[\x{2190}-\x{22FF}\x{2A00}-\x{2AFF}×÷±·]$/u', $ch)) |
| 352 | return $this->node('<mo>'.$ch.'</mo>'); |
| 353 | if (preg_match('/^[\x{2100}-\x{214F}\x{1D400}-\x{1D7FF}]$/u', $ch)) |
| 354 | return $this->node('<mi>'.$ch.'</mi>'); |
| 355 | return $this->node('<mtext>'.$this->esc($ch).'</mtext>'); |
| 356 | } |
| 357 | |
| 358 | /** |
| 359 | * バックスラッシュで始まるコマンドを読む |
| 360 | * |
| 361 | * @return array|null ノード |
| 362 | */ |
| 363 | private function parseCommand(): ?array { |
| 364 | $name = $this->readCommand(); |
| 365 | |
| 366 | // 空白 / spacing |
| 367 | if (isset(self::SPACES[$name])) { |
| 368 | return $this->node('<mspace width="'.self::SPACES[$name].'"></mspace>'); |
| 369 | } |
| 370 | if ($name === '!') return null; |
| 371 | if ($name === '\\') return null; // 行列の外の改行は無視 / line break outside a table |
| 372 | |
| 373 | // エスケープされた記号 / escaped characters |
| 374 | switch ($name) { |
| 375 | case '{': return $this->node('<mo stretchy="false">{</mo>'); |
| 376 | case '}': return $this->node('<mo stretchy="false">}</mo>'); |
| 377 | case '%': return $this->node('<mo>%</mo>'); |
| 378 | case '#': return $this->node('<mo>#</mo>'); |
| 379 | case '&': return $this->node('<mo>&</mo>'); |
| 380 | case '_': return $this->node('<mo>_</mo>'); |
| 381 | case '$': return $this->node('<mo>$</mo>'); |
| 382 | } |
| 383 | |
| 384 | if (isset(self::GREEK[$name])) return $this->node($this->ident(self::GREEK[$name])); |
| 385 | if (isset(self::GREEK_UPPER[$name])) { |
| 386 | $ch = $this->styleChar(self::GREEK_UPPER[$name]); |
| 387 | $attr = $ch === self::GREEK_UPPER[$name] ? ' mathvariant="normal"' : ''; |
| 388 | return $this->node("<mi{$attr}>{$ch}</mi>"); |
| 389 | } |
| 390 | if (isset(self::IDENT[$name])) { |
| 391 | return $this->node('<mi mathvariant="normal">'.self::IDENT[$name].'</mi>'); |
| 392 | } |
| 393 | if (isset(self::FENCES[$name])) { |
| 394 | return $this->node('<mo stretchy="false">'.self::FENCES[$name].'</mo>'); |
| 395 | } |
| 396 | if (isset(self::OPS[$name])) return $this->node('<mo>'.self::OPS[$name].'</mo>'); |
| 397 | |
| 398 | if (in_array($name, self::FUNCS, true)) { |
| 399 | return ['xml' => '<mi>'.$name.'</mi>', 'func' => true, 'limits' => false]; |
| 400 | } |
| 401 | if (in_array($name, self::LIMIT_FUNCS, true)) { |
| 402 | return ['xml' => '<mo movablelimits="true" form="prefix">'.$name.'</mo>', |
| 403 | 'func' => true, 'limits' => true]; |
| 404 | } |
| 405 | if (isset(self::BIG_OPS[$name])) { |
| 406 | [$sym, $limits] = self::BIG_OPS[$name]; |
| 407 | return ['xml' => '<mo largeop="true" movablelimits="'.($limits ? 'true' : 'false').'">'.$sym.'</mo>', |
| 408 | 'func' => false, 'limits' => $limits]; |
| 409 | } |
| 410 | |
| 411 | switch ($name) { |
| 412 | case 'frac': case 'dfrac': case 'tfrac': case 'cfrac': |
| 413 | $num = $this->parseGroup(); |
| 414 | $den = $this->parseGroup(); |
| 415 | $attr = ''; |
| 416 | if ($name === 'dfrac' || $name === 'cfrac') $attr = ' displaystyle="true"'; |
| 417 | if ($name === 'tfrac') $attr = ' displaystyle="false"'; |
| 418 | return $this->node("<mfrac{$attr}>{$num}{$den}</mfrac>"); |
| 419 | |
| 420 | case 'sqrt': |
| 421 | $this->skipSpace(); |
| 422 | if ($this->peek() === '[') { |
| 423 | $index = $this->renderSub($this->readBracketRaw()); |
| 424 | $base = $this->parseGroup(); |
| 425 | return $this->node("<mroot>{$base}{$index}</mroot>"); |
| 426 | } |
| 427 | return $this->node('<msqrt>'.$this->parseGroup().'</msqrt>'); |
| 428 | |
| 429 | case 'left': |
| 430 | return $this->parseLeftRight(); |
| 431 | |
| 432 | case 'begin': |
| 433 | return $this->parseEnvironment(); |
| 434 | |
| 435 | case 'text': case 'textrm': case 'textnormal': case 'mbox': case 'textup': |
| 436 | return $this->node('<mtext>'.$this->esc($this->readGroupRaw()).'</mtext>'); |
| 437 | case 'textbf': |
| 438 | return $this->node('<mtext style="font-weight: bold;">'.$this->esc($this->readGroupRaw()).'</mtext>'); |
| 439 | case 'textit': |
| 440 | return $this->node('<mtext style="font-style: italic;">'.$this->esc($this->readGroupRaw()).'</mtext>'); |
| 441 | |
| 442 | case 'operatorname': |
| 443 | $fn = $this->esc(trim($this->readGroupRaw())); |
| 444 | return ['xml' => '<mi>'.$fn.'</mi>', 'func' => true, 'limits' => false]; |
| 445 | |
| 446 | case 'mathbf': case 'boldsymbol': case 'bm': case 'mathrm': case 'mathit': |
| 447 | case 'mathbb': case 'mathcal': case 'mathsf': case 'mathtt': |
| 448 | $map = [ |
| 449 | 'mathbf' => 'bold', 'boldsymbol' => 'bold-italic', 'bm' => 'bold-italic', |
| 450 | 'mathrm' => 'normal', 'mathit' => 'italic', 'mathbb' => 'double-struck', |
| 451 | 'mathcal' => 'script', 'mathsf' => 'sans-serif', 'mathtt' => 'monospace', |
| 452 | ]; |
| 453 | $prev = $this->variant; |
| 454 | $this->variant = $map[$name]; |
| 455 | $xml = $this->parseGroup(); |
| 456 | $this->variant = $prev; |
| 457 | return $this->node($xml); |
| 458 | |
| 459 | case 'overline': |
| 460 | $base = $this->parseGroup(); |
| 461 | return $this->node("<mover>{$base}<mo stretchy=\"true\">‾</mo></mover>"); |
| 462 | case 'underline': |
| 463 | $base = $this->parseGroup(); |
| 464 | return $this->node("<munder>{$base}<mo stretchy=\"true\">_</mo></munder>"); |
| 465 | case 'underbrace': |
| 466 | $base = $this->parseGroup(); |
| 467 | return ['xml' => "<munder>{$base}<mo stretchy=\"true\">⏟</mo></munder>", |
| 468 | 'func' => false, 'limits' => true, 'under' => true]; |
| 469 | case 'overbrace': |
| 470 | $base = $this->parseGroup(); |
| 471 | return ['xml' => "<mover>{$base}<mo stretchy=\"true\">⏞</mo></mover>", |
| 472 | 'func' => false, 'limits' => true, 'under' => true]; |
| 473 | case 'overrightarrow': |
| 474 | $base = $this->parseGroup(); |
| 475 | return $this->node("<mover>{$base}<mo stretchy=\"true\">→</mo></mover>"); |
| 476 | } |
| 477 | |
| 478 | if (isset(self::ACCENTS[$name])) { |
| 479 | $base = $this->parseGroup(); |
| 480 | $acc = self::ACCENTS[$name]; |
| 481 | $stretch = str_starts_with($name, 'wide') ? 'true' : 'false'; |
| 482 | return $this->node("<mover accent=\"true\">{$base}<mo stretchy=\"{$stretch}\">{$acc}</mo></mover>"); |
| 483 | } |
| 484 | |
| 485 | $this->errors[] = '\\'.$name; |
| 486 | return $this->node('<merror><mtext>\\'.$this->esc($name).'</mtext></merror>'); |
| 487 | } |
| 488 | |
| 489 | /** |
| 490 | * \left … \right を読む |
| 491 | * |
| 492 | * @return array ノード |
| 493 | */ |
| 494 | private function parseLeftRight(): array { |
| 495 | $open = $this->readDelimiter(); |
| 496 | $inner = $this->parseList(); |
| 497 | $close = ''; |
| 498 | if ($this->startsWith('\\right')) { |
| 499 | $this->readCommand(); |
| 500 | $close = $this->readDelimiter(); |
| 501 | } else { |
| 502 | $this->errors[] = '\\left without \\right'; |
| 503 | } |
| 504 | |
| 505 | $xml = '<mrow>'; |
| 506 | if ($open !== '') $xml .= '<mo fence="true" stretchy="true" form="prefix">'.$this->esc($open).'</mo>'; |
| 507 | $xml .= $this->row($inner); |
| 508 | if ($close !== '') $xml .= '<mo fence="true" stretchy="true" form="postfix">'.$this->esc($close).'</mo>'; |
| 509 | $xml .= '</mrow>'; |
| 510 | return $this->node($xml); |
| 511 | } |
| 512 | |
| 513 | /** |
| 514 | * \begin{env} … \end{env} を読む |
| 515 | * |
| 516 | * @return array ノード |
| 517 | */ |
| 518 | private function parseEnvironment(): array { |
| 519 | $env = trim($this->readGroupRaw()); |
| 520 | $align = []; |
| 521 | |
| 522 | if ($env === 'array') { |
| 523 | $spec = $this->readGroupRaw(); |
| 524 | foreach (str_split(preg_replace('/[^lcr]/', '', $spec)) as $ch) |
| 525 | $align[] = ['l' => 'left', 'c' => 'center', 'r' => 'right'][$ch]; |
| 526 | } |
| 527 | |
| 528 | $known = isset(self::ENV_FENCES[$env]) |
| 529 | || in_array($env, ['cases', 'aligned', 'align', 'align*', 'gathered', 'array', 'split'], true); |
| 530 | if (!$known) $this->errors[] = '\\begin{'.$env.'}'; |
| 531 | |
| 532 | // 行と列を読む / read rows and cells |
| 533 | $rows = []; |
| 534 | $cells = []; |
| 535 | while (true) { |
| 536 | $cells[] = $this->row($this->parseList()); |
| 537 | if ($this->peek() === '&') { |
| 538 | $this->pos++; |
| 539 | continue; |
| 540 | } |
| 541 | if ($this->startsWith('\\\\')) { |
| 542 | $this->pos += 2; |
| 543 | $rows[] = $cells; |
| 544 | $cells = []; |
| 545 | continue; |
| 546 | } |
| 547 | if ($this->startsWith('\\end')) { |
| 548 | $this->readCommand(); |
| 549 | $this->readGroupRaw(); |
| 550 | break; |
| 551 | } |
| 552 | if ($this->peek() === '}') { // 迷子の}を読み飛ばす / skip a stray } |
| 553 | $this->pos++; |
| 554 | continue; |
| 555 | } |
| 556 | break; // 文末 / end of input |
| 557 | } |
| 558 | // 最後の空行を捨てる / drop an empty trailing row |
| 559 | if (!(count($cells) === 1 && $cells[0] === '')) $rows[] = $cells; |
| 560 | |
| 561 | $isAligned = in_array($env, ['aligned', 'align', 'align*', 'split'], true); |
| 562 | $table = '<mtable'.($isAligned || $env === 'cases' ? ' displaystyle="true"' : '').'>'; |
| 563 | foreach ($rows as $r) { |
| 564 | $table .= '<mtr>'; |
| 565 | foreach ($r as $i => $cell) { |
| 566 | $ca = ''; |
| 567 | if ($isAligned) $ca = $i % 2 === 0 ? 'right' : 'left'; |
| 568 | else if ($env === 'cases') $ca = 'left'; |
| 569 | else if (isset($align[$i])) $ca = $align[$i]; |
| 570 | $style = $ca !== '' && $ca !== 'center' ? " style=\"text-align: {$ca};\"" : ''; |
| 571 | // alignedの列間の隙間を詰める / close the gap between aligned columns |
| 572 | if ($isAligned) $style = " style=\"text-align: {$ca}; padding-".($i % 2 === 0 ? 'right' : 'left').': 0;"'; |
| 573 | $table .= "<mtd{$style}>{$cell}</mtd>"; |
| 574 | } |
| 575 | $table .= '</mtr>'; |
| 576 | } |
| 577 | $table .= '</mtable>'; |
| 578 | |
| 579 | [$open, $close] = self::ENV_FENCES[$env] ?? ['', '']; |
| 580 | if ($env === 'cases') $open = '{'; |
| 581 | |
| 582 | if ($open === '' && $close === '') return $this->node($table); |
| 583 | |
| 584 | $xml = '<mrow>'; |
| 585 | if ($open !== '') $xml .= '<mo fence="true" stretchy="true" form="prefix">'.$this->esc($open).'</mo>'; |
| 586 | $xml .= $table; |
| 587 | if ($close !== '') $xml .= '<mo fence="true" stretchy="true" form="postfix">'.$this->esc($close).'</mo>'; |
| 588 | $xml .= '</mrow>'; |
| 589 | return $this->node($xml); |
| 590 | } |
| 591 | |
| 592 | //------------------------------------------ |
| 593 | // 字句解析 |
| 594 | //------------------------------------------ |
| 595 | |
| 596 | private function peek(): string { |
| 597 | return $this->pos < strlen($this->src) ? $this->src[$this->pos] : ''; |
| 598 | } |
| 599 | |
| 600 | private function startsWith(string $s): bool { |
| 601 | if (substr($this->src, $this->pos, strlen($s)) !== $s) return false; |
| 602 | // \endや\rightの後ろに英字が続く場合は別のコマンド / \endpoint is not \end |
| 603 | if (ctype_alpha(substr($s, -1))) { |
| 604 | $after = substr($this->src, $this->pos + strlen($s), 1); |
| 605 | if ($after !== '' && ctype_alpha($after)) return false; |
| 606 | } |
| 607 | return true; |
| 608 | } |
| 609 | |
| 610 | private function skipSpace(): void { |
| 611 | while ($this->pos < strlen($this->src) && ctype_space($this->src[$this->pos])) $this->pos++; |
| 612 | } |
| 613 | |
| 614 | /** |
| 615 | * 現在位置が終端記号かどうか |
| 616 | */ |
| 617 | private function atStop(): bool { |
| 618 | $c = $this->peek(); |
| 619 | if ($c === '' || $c === '}' || $c === '&') return true; |
| 620 | return $this->startsWith('\\\\') || $this->startsWith('\\end') || $this->startsWith('\\right'); |
| 621 | } |
| 622 | |
| 623 | /** |
| 624 | * \の後のコマンド名を読む(英字列、又は記号1文字) |
| 625 | */ |
| 626 | private function readCommand(): string { |
| 627 | $this->pos++; // \ |
| 628 | if (preg_match('/\G[a-zA-Z]+/', $this->src, $m, 0, $this->pos)) { |
| 629 | $this->pos += strlen($m[0]); |
| 630 | return $m[0]; |
| 631 | } |
| 632 | preg_match('/\G./us', $this->src, $m, 0, $this->pos); |
| 633 | $ch = $m[0] ?? ''; |
| 634 | $this->pos += strlen($ch); |
| 635 | return $ch; |
| 636 | } |
| 637 | |
| 638 | /** |
| 639 | * {…}の中身を加工せずに読む(\text等) |
| 640 | */ |
| 641 | private function readGroupRaw(): string { |
| 642 | $this->skipSpace(); |
| 643 | if ($this->peek() !== '{') { |
| 644 | preg_match('/\G./us', $this->src, $m, 0, $this->pos); |
| 645 | $ch = $m[0] ?? ''; |
| 646 | $this->pos += strlen($ch); |
| 647 | return $ch; |
| 648 | } |
| 649 | $this->pos++; |
| 650 | $depth = 1; |
| 651 | $out = ''; |
| 652 | $len = strlen($this->src); |
| 653 | while ($this->pos < $len) { |
| 654 | $c = $this->src[$this->pos]; |
| 655 | if ($c === '\\' && $this->pos + 1 < $len) { |
| 656 | $next = $this->src[$this->pos + 1]; |
| 657 | // \{ \} \\ はそのまま文字にする / escaped braces become literal characters |
| 658 | $out .= ($next === '{' || $next === '}' || $next === '\\' || $next === '_' |
| 659 | || $next === '%' || $next === '&' || $next === '#' || $next === '$') ? $next : $c.$next; |
| 660 | $this->pos += 2; |
| 661 | continue; |
| 662 | } |
| 663 | if ($c === '{') $depth++; |
| 664 | if ($c === '}') { |
| 665 | $depth--; |
| 666 | if ($depth === 0) { |
| 667 | $this->pos++; |
| 668 | break; |
| 669 | } |
| 670 | } |
| 671 | $out .= $c; |
| 672 | $this->pos++; |
| 673 | } |
| 674 | return $out; |
| 675 | } |
| 676 | |
| 677 | /** |
| 678 | * [...]の中身を読む(\sqrt[n]用) |
| 679 | */ |
| 680 | private function readBracketRaw(): string { |
| 681 | $this->pos++; // [ |
| 682 | $depth = 0; |
| 683 | $out = ''; |
| 684 | $len = strlen($this->src); |
| 685 | while ($this->pos < $len) { |
| 686 | $c = $this->src[$this->pos]; |
| 687 | if ($c === '{') $depth++; |
| 688 | if ($c === '}') $depth--; |
| 689 | if ($c === ']' && $depth === 0) { |
| 690 | $this->pos++; |
| 691 | break; |
| 692 | } |
| 693 | $out .= $c; |
| 694 | $this->pos++; |
| 695 | } |
| 696 | return $out; |
| 697 | } |
| 698 | |
| 699 | /** |
| 700 | * \left、\rightの後の区切り記号を読む |
| 701 | */ |
| 702 | private function readDelimiter(): string { |
| 703 | $this->skipSpace(); |
| 704 | $c = $this->peek(); |
| 705 | if ($c === '\\') { |
| 706 | $start = $this->pos; |
| 707 | $name = $this->readCommand(); |
| 708 | $key = '\\'.$name; |
| 709 | if (isset(self::DELIMS[$key])) return self::DELIMS[$key]; |
| 710 | $this->errors[] = 'delimiter '.$key; |
| 711 | return ''; |
| 712 | } |
| 713 | if ($c === '') return ''; |
| 714 | $this->pos++; |
| 715 | if (isset(self::DELIMS[$c])) return self::DELIMS[$c]; |
| 716 | $this->errors[] = 'delimiter '.$c; |
| 717 | return $c; |
| 718 | } |
| 719 | |
| 720 | //------------------------------------------ |
| 721 | // 出力ヘルパー |
| 722 | //------------------------------------------ |
| 723 | |
| 724 | /** |
| 725 | * 部分文字列を同じ設定で変換する(\sqrtの指数等) |
| 726 | */ |
| 727 | private function renderSub(string $tex): string { |
| 728 | [$src, $pos] = [$this->src, $this->pos]; |
| 729 | $this->src = $tex; |
| 730 | $this->pos = 0; |
| 731 | $xml = $this->row($this->parseList()); |
| 732 | [$this->src, $this->pos] = [$src, $pos]; |
| 733 | return $xml; |
| 734 | } |
| 735 | |
| 736 | private function node(string $xml): array { |
| 737 | return ['xml' => $xml, 'func' => false, 'limits' => false]; |
| 738 | } |
| 739 | |
| 740 | /** |
| 741 | * 複数のノードをmrowで包む(1つだけならそのまま) |
| 742 | */ |
| 743 | private function row(array $nodes, bool $forceRow = false): string { |
| 744 | if (count($nodes) === 1 && !$forceRow) return $nodes[0]; |
| 745 | if (count($nodes) === 1 && str_starts_with($nodes[0], '<mrow>')) return $nodes[0]; |
| 746 | return '<mrow>'.implode('', $nodes).'</mrow>'; |
| 747 | } |
| 748 | |
| 749 | /** |
| 750 | * 添え字はmrowで包んで1要素にする |
| 751 | */ |
| 752 | private function wrap(string $xml): string { |
| 753 | if (preg_match('/^<(mi|mn|mo|mrow|mtext|mfrac|msqrt)[ >]/', $xml) && substr_count($xml, '<m') === 1) |
| 754 | return $xml; |
| 755 | if (str_starts_with($xml, '<mrow>') && str_ends_with($xml, '</mrow>')) return $xml; |
| 756 | return '<mrow>'.$xml.'</mrow>'; |
| 757 | } |
| 758 | |
| 759 | private function esc(string $s): string { |
| 760 | return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); |
| 761 | } |
| 762 | |
| 763 | /** |
| 764 | * 識別子(1文字)を書体付きで出力する |
| 765 | */ |
| 766 | private function ident(string $ch): string { |
| 767 | $styled = $this->styleChar($ch); |
| 768 | if ($this->variant === 'normal' && $styled === $ch) { |
| 769 | return '<mi mathvariant="normal">'.$this->esc($ch).'</mi>'; |
| 770 | } |
| 771 | return '<mi>'.$this->esc($styled).'</mi>'; |
| 772 | } |
| 773 | |
| 774 | private function styleString(string $s): string { |
| 775 | $out = ''; |
| 776 | foreach (preg_split('//u', $s, -1, PREG_SPLIT_NO_EMPTY) as $ch) $out .= $this->styleChar($ch); |
| 777 | return $out; |
| 778 | } |
| 779 | |
| 780 | /** |
| 781 | * 書体をUnicodeの数学用英数字記号に変換する |
| 782 | * (MathML Coreではmathvariant="normal"以外は使えない為) |
| 783 | */ |
| 784 | private function styleChar(string $ch): string { |
| 785 | $v = $this->variant; |
| 786 | if ($v === '' || $v === 'normal' || $v === 'italic') return $ch; |
| 787 | |
| 788 | $cp = mb_ord($ch, 'UTF-8'); |
| 789 | $isUpper = $cp >= 0x41 && $cp <= 0x5A; |
| 790 | $isLower = $cp >= 0x61 && $cp <= 0x7A; |
| 791 | $isDigit = $cp >= 0x30 && $cp <= 0x39; |
| 792 | $isGreekUpper = $cp >= 0x391 && $cp <= 0x3A9; |
| 793 | $isGreekLower = $cp >= 0x3B1 && $cp <= 0x3C9; |
| 794 | |
| 795 | $holes = [ |
| 796 | 'double-struck' => ['C' => 0x2102, 'H' => 0x210D, 'N' => 0x2115, 'P' => 0x2119, |
| 797 | 'Q' => 0x211A, 'R' => 0x211D, 'Z' => 0x2124], |
| 798 | 'script' => ['B' => 0x212C, 'E' => 0x2130, 'F' => 0x2131, 'H' => 0x210B, 'I' => 0x2110, |
| 799 | 'L' => 0x2112, 'M' => 0x2133, 'R' => 0x211B, 'e' => 0x212F, 'g' => 0x210A, |
| 800 | 'o' => 0x2134], |
| 801 | ]; |
| 802 | if (isset($holes[$v][$ch])) return mb_chr($holes[$v][$ch], 'UTF-8'); |
| 803 | |
| 804 | // [大文字, 小文字, 数字, ギリシャ大文字, ギリシャ小文字] |
| 805 | $base = [ |
| 806 | 'bold' => [0x1D400, 0x1D41A, 0x1D7CE, 0x1D6A8, 0x1D6C2], |
| 807 | 'bold-italic' => [0x1D468, 0x1D482, 0x1D7CE, 0x1D71C, 0x1D736], |
| 808 | 'double-struck' => [0x1D538, 0x1D552, 0x1D7D8, null, null], |
| 809 | 'script' => [0x1D49C, 0x1D4B6, null, null, null], |
| 810 | 'sans-serif' => [0x1D5A0, 0x1D5BA, 0x1D7E2, null, null], |
| 811 | 'monospace' => [0x1D670, 0x1D68A, 0x1D7F6, null, null], |
| 812 | ][$v] ?? null; |
| 813 | if ($base === null) return $ch; |
| 814 | |
| 815 | if ($isUpper && $base[0]) return mb_chr($base[0] + $cp - 0x41, 'UTF-8'); |
| 816 | if ($isLower && $base[1]) return mb_chr($base[1] + $cp - 0x61, 'UTF-8'); |
| 817 | if ($isDigit && $base[2]) return mb_chr($base[2] + $cp - 0x30, 'UTF-8'); |
| 818 | if ($isGreekUpper && $base[3]) return mb_chr($base[3] + $cp - 0x391, 'UTF-8'); |
| 819 | if ($isGreekLower && $base[4]) return mb_chr($base[4] + $cp - 0x3B1, 'UTF-8'); |
| 820 | return $ch; |
| 821 | } |
| 822 | } |