master
ブランチ master
LittleBeast / src / Std / Lib / Latex.php
30.1 KB、822行 生 Blame 履歴
1<?php
2namespace Std\Lib;
3
4/**
5 * LaTeX数式をMathMLに変換する(JavaScript不要、ブラウザがネイティブで描画する)
6 *
7 * 対応範囲はチュートリアルで使うLaTeXのサブセット:
8 * 上付き・下付き、\frac、\sqrt、\left…\right、行列環境、\text、\mathbf等の書体、
9 * アクセント、ギリシャ文字、関数名、演算子・関係記号・矢印、空白。
10 * 未対応のコマンドは<merror>として出力し、getErrors()で取得出来る。
11 */
12class 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>&amp;</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}