として出力し、getErrors()で取得出来る。 */ class Latex { private string $src = ''; private int $pos = 0; private bool $display = false; private string $variant = ''; private array $errors = []; private const GREEK = [ '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' => 'ω', ]; private const GREEK_UPPER = [ 'Gamma' => 'Γ', 'Delta' => 'Δ', 'Theta' => 'Θ', 'Lambda' => 'Λ', 'Xi' => 'Ξ', 'Pi' => 'Π', 'Sigma' => 'Σ', 'Upsilon' => 'Υ', 'Phi' => 'Φ', 'Psi' => 'Ψ', 'Omega' => 'Ω', ]; // 識別子として扱う記号 / symbols rendered as identifiers private const IDENT = [ 'infty' => '∞', 'partial' => '∂', 'nabla' => '∇', 'ell' => 'ℓ', 'hbar' => 'ℏ', 'top' => '⊤', 'bot' => '⊥', 'degree' => '°', 'emptyset' => '∅', 'Re' => 'ℜ', 'Im' => 'ℑ', 'aleph' => 'ℵ', 'imath' => 'ı', 'jmath' => 'ȷ', ]; // 演算子・関係記号・矢印 / operators, relations, arrows private const OPS = [ 'cdot' => '⋅', 'times' => '×', 'div' => '÷', 'pm' => '±', 'mp' => '∓', 'ast' => '∗', 'star' => '⋆', 'circ' => '∘', 'bullet' => '∙', 'otimes' => '⊗', 'oplus' => '⊕', 'le' => '≤', 'leq' => '≤', 'ge' => '≥', 'geq' => '≥', 'ne' => '≠', 'neq' => '≠', 'approx' => '≈', 'equiv' => '≡', 'sim' => '∼', 'simeq' => '≃', 'cong' => '≅', 'propto' => '∝', 'll' => '≪', 'gg' => '≫', 'perp' => '⊥', 'parallel' => '∥', 'to' => '→', 'rightarrow' => '→', 'leftarrow' => '←', 'gets' => '←', 'leftrightarrow' => '↔', 'Rightarrow' => '⇒', 'Leftarrow' => '⇐', 'Leftrightarrow' => '⇔', 'iff' => '⟺', 'implies' => '⟹', 'mapsto' => '↦', 'uparrow' => '↑', 'downarrow' => '↓', 'longrightarrow' => '⟶', 'in' => '∈', 'notin' => '∉', 'ni' => '∋', 'subset' => '⊂', 'subseteq' => '⊆', 'supset' => '⊃', 'supseteq' => '⊇', 'cup' => '∪', 'cap' => '∩', 'setminus' => '∖', 'forall' => '∀', 'exists' => '∃', 'neg' => '¬', 'lnot' => '¬', 'land' => '∧', 'wedge' => '∧', 'lor' => '∨', 'vee' => '∨', 'angle' => '∠', 'ldots' => '…', 'dots' => '…', 'cdots' => '⋯', 'vdots' => '⋮', 'ddots' => '⋱', 'prime' => '′', 'colon' => ':', 'mid' => '∣', ]; // 区切り記号 / delimiters private const DELIMS = [ '(' => '(', ')' => ')', '[' => '[', ']' => ']', '|' => '|', '/' => '/', '{' => '{', '}' => '}', '\\{' => '{', '\\}' => '}', '\\|' => '‖', '\\lVert' => '‖', '\\rVert' => '‖', '\\Vert' => '‖', '\\lvert' => '|', '\\rvert' => '|', '\\vert' => '|', '\\langle' => '⟨', '\\rangle' => '⟩', '\\lfloor' => '⌊', '\\rfloor' => '⌋', '\\lceil' => '⌈', '\\rceil' => '⌉', '.' => '', ]; // 括弧として使う記号 / fence-like commands usable outside \left…\right private const FENCES = [ 'lVert' => '‖', 'rVert' => '‖', 'Vert' => '‖', '|' => '‖', 'lvert' => '|', 'rvert' => '|', 'vert' => '|', 'langle' => '⟨', 'rangle' => '⟩', 'lfloor' => '⌊', 'rfloor' => '⌋', 'lceil' => '⌈', 'rceil' => '⌉', '{' => '{', '}' => '}', ]; // 関数名 / function names (upright) private const FUNCS = [ 'sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh', 'log', 'ln', 'exp', 'det', 'dim', 'ker', 'deg', 'gcd', 'arg', 'Pr', 'hom', ]; // 下に添え字を付ける関数 / functions that take limits underneath in display mode private const LIMIT_FUNCS = ['lim', 'max', 'min', 'sup', 'inf', 'limsup', 'liminf']; // 大型演算子 / large operators private const BIG_OPS = [ 'sum' => ['∑', true], 'prod' => ['∏', true], 'coprod' => ['∐', true], 'bigcup' => ['⋃', true], 'bigcap' => ['⋂', true], 'int' => ['∫', false], 'iint' => ['∬', false], 'iiint' => ['∭', false], 'oint' => ['∮', false], ]; private const ACCENTS = [ 'vec' => '→', 'hat' => '^', 'widehat' => '^', 'bar' => '¯', 'dot' => '˙', 'ddot' => '¨', 'tilde' => '~', 'widetilde' => '~', 'check' => 'ˇ', 'breve' => '˘', ]; private const SPACES = [ ',' => '0.1667em', ':' => '0.2222em', '>' => '0.2222em', ';' => '0.2778em', ' ' => '0.25em', 'quad' => '1em', 'qquad' => '2em', 'enspace' => '0.5em', 'thinspace' => '0.1667em', ]; private const ENV_FENCES = [ 'matrix' => ['', ''], 'pmatrix' => ['(', ')'], 'bmatrix' => ['[', ']'], 'Bmatrix' => ['{', '}'], 'vmatrix' => ['|', '|'], 'Vmatrix' => ['‖', '‖'], 'smallmatrix' => ['', ''], ]; /** * LaTeXをMathMLに変換する * * @param string $tex LaTeXの数式($$は含まない) * @param bool $display ブロック表示ならtrue * @return string 要素 */ public function render(string $tex, bool $display = false): string { $this->src = $tex; $this->pos = 0; $this->display = $display; $this->variant = ''; $this->errors = []; $nodes = $this->parseList(); // 外側に余ったトークンを読み飛ばす / skip stray tokens at the top level while ($this->pos < strlen($this->src)) { if ($this->startsWith('\\\\')) $this->pos += 2; else if ($this->startsWith('\\end')) { $this->readCommand(); $this->readGroupRaw(); } else if ($this->startsWith('\\right')) { $this->readCommand(); $this->readDelimiter(); } else $this->pos++; $nodes = array_merge($nodes, $this->parseList()); } $alt = htmlspecialchars(trim(preg_replace('/\s+/', ' ', $tex)), ENT_QUOTES, 'UTF-8'); $attr = $display ? ' display="block"' : ''; return "".$this->row($nodes).''; } /** * 直前のrender()で見つかった未対応のコマンド * * @return array エラーメッセージの配列 */ public function getErrors(): array { return $this->errors; } //------------------------------------------ // パーサー //------------------------------------------ /** * 終端記号(}、&、\\、\end、\right)又は文末まで要素を読む * * @return array ノードの配列 */ private function parseList(): array { $nodes = []; while (true) { $this->skipSpace(); if ($this->atStop()) break; $node = $this->parseScripted(); if ($node === null) continue; $nodes[] = $node; } // 先頭、又は演算子・開き括弧の直後の+−は符号(空白無し) / a +/− at the start or after an operator is a sign $count = count($nodes); for ($i = 0; $i < $count; $i++) { if (empty($nodes[$i]['sign'])) continue; $prev = $i > 0 ? $nodes[$i - 1]['xml'] : ''; $isClose = preg_match('/^]*>[\)\]\}|‖⟩⌋⌉]<\/mo>$/u', $prev); if ($i === 0 || (str_starts_with($prev, '', '', $nodes[$i]['xml']); } } // 関数名の後に細い空白を入れる / thin space after function names $out = []; for ($i = 0; $i < $count; $i++) { // 識別子の直後の関数名の前にも細い空白(a\tan x) / thin space between an identifier and a function name if (!empty($nodes[$i]['func']) && $i > 0 && !str_starts_with($nodes[$i - 1]['xml'], '(') && !str_starts_with($next, ' string, 'func' => bool] */ private function parseScripted(): ?array { $this->skipSpace(); $c = $this->peek(); if ($c === '^' || $c === '_') { $base = ['xml' => '', 'limits' => false, 'func' => false]; } else { $base = $this->parseAtom(); if ($base === null) return null; } $sub = null; $sup = null; $primes = ''; while (true) { $this->skipSpace(); $c = $this->peek(); if ($c === '^' && $sup === null) { $this->pos++; $sup = $this->parseScriptArg(); } else if ($c === '_' && $sub === null) { $this->pos++; $sub = $this->parseScriptArg(); } else if ($c === "'") { $this->pos++; $primes .= '′'; } else break; } // ′は既に上付きの形の字なので、他の上付きが無ければ横に並べるだけ // The prime glyph is already raised, so with no other superscript just place it after the base if ($primes !== '') { if ($sup === null) { $p = ''.$primes.''; $xml = ''.$base['xml'].$p.''; if ($sub === null) return ['xml' => $xml, 'func' => !empty($base['func'])]; $base = ['xml' => $base['xml'], 'func' => !empty($base['func'])]; $b = "{$base['xml']}".$this->wrap($sub).""; return ['xml' => ''.$b.$p.'', 'func' => false]; } $sup = ''.$primes.''.$sup; } if ($sub === null && $sup === null) return $base; $b = $base['xml']; $under = !empty($base['under']) || (!empty($base['limits']) && $this->display); if ($sub !== null) $sub = $this->wrap($sub); if ($sup !== null) $sup = $this->wrap($sup); if ($under) { if ($sub !== null && $sup !== null) $xml = "{$b}{$sub}{$sup}"; else if ($sub !== null) $xml = "{$b}{$sub}"; else $xml = "{$b}{$sup}"; } else { if ($sub !== null && $sup !== null) $xml = "{$b}{$sub}{$sup}"; else if ($sub !== null) $xml = "{$b}{$sub}"; else $xml = "{$b}{$sup}"; } return ['xml' => $xml, 'func' => !empty($base['func'])]; } /** * 上付き・下付きの引数(1文字、1コマンド、又は{…}) * * @return string MathML */ private function parseScriptArg(): string { $this->skipSpace(); $c = $this->peek(); if ($c === '{') return $this->parseGroup(); if ($c === '' ) return ''; if (ctype_digit($c)) { $this->pos++; return ''.$this->styleChar($c).''; } $atom = $this->parseAtom(); return $atom === null ? '' : $atom['xml']; } /** * {…}を読む * * @return string MathML(mrow) */ private function parseGroup(): string { $this->skipSpace(); if ($this->peek() !== '{') { // 波括弧の無い引数:1要素だけ / argument without braces: a single element $atom = $this->parseAtom(); return $atom === null ? '' : $atom['xml']; } $this->pos++; $nodes = $this->parseList(); if ($this->peek() === '}') $this->pos++; return $this->row($nodes, true); } /** * 一つの要素を読む * * @return array|null ノード */ private function parseAtom(): ?array { $c = $this->peek(); if ($c === '') return null; if ($c === '{') { return $this->node($this->parseGroup()); } if ($c === '\\') return $this->parseCommand(); // 数字 / numbers if (preg_match('/\G[0-9]+(?:\.[0-9]+)?/', $this->src, $m, 0, $this->pos)) { $this->pos += strlen($m[0]); return $this->node(''.$this->styleString($m[0]).''); } // 英字 / latin letters if (ctype_alpha($c)) { $this->pos++; return $this->node($this->ident($c)); } if ($c === '~') { $this->pos++; return $this->node(''); } // 1文字(UTF-8対応) / one character (UTF-8 aware) preg_match('/\G./us', $this->src, $m, 0, $this->pos); $ch = $m[0] ?? $c; $this->pos += strlen($ch); switch ($ch) { case '-': return ['xml' => '−', 'func' => false, 'limits' => false, 'sign' => true]; case '+': return ['xml' => '+', 'func' => false, 'limits' => false, 'sign' => true]; case '*': return $this->node('∗'); case '(': case ')': case '[': case ']': case '|': return $this->node(''.$this->esc($ch).''); case '/': // TeXでは/は普通の文字(空白無し) / in TeX the slash is an ordinary symbol return $this->node('/'); case '=': case '<': case '>': case ',': case ';': case ':': case '!': case '?': case '.': case '@': return $this->node(''.$this->esc($ch).''); } // ASCII以外:ギリシャ文字は識別子、記号は演算子、其れ以外は文字列 if (preg_match('/^[\x{0370}-\x{03FF}]$/u', $ch)) return $this->node($this->ident($ch)); if (preg_match('/^[\x{2190}-\x{22FF}\x{2A00}-\x{2AFF}×÷±·]$/u', $ch)) return $this->node(''.$ch.''); if (preg_match('/^[\x{2100}-\x{214F}\x{1D400}-\x{1D7FF}]$/u', $ch)) return $this->node(''.$ch.''); return $this->node(''.$this->esc($ch).''); } /** * バックスラッシュで始まるコマンドを読む * * @return array|null ノード */ private function parseCommand(): ?array { $name = $this->readCommand(); // 空白 / spacing if (isset(self::SPACES[$name])) { return $this->node(''); } if ($name === '!') return null; if ($name === '\\') return null; // 行列の外の改行は無視 / line break outside a table // エスケープされた記号 / escaped characters switch ($name) { case '{': return $this->node('{'); case '}': return $this->node('}'); case '%': return $this->node('%'); case '#': return $this->node('#'); case '&': return $this->node('&'); case '_': return $this->node('_'); case '$': return $this->node('$'); } if (isset(self::GREEK[$name])) return $this->node($this->ident(self::GREEK[$name])); if (isset(self::GREEK_UPPER[$name])) { $ch = $this->styleChar(self::GREEK_UPPER[$name]); $attr = $ch === self::GREEK_UPPER[$name] ? ' mathvariant="normal"' : ''; return $this->node("{$ch}"); } if (isset(self::IDENT[$name])) { return $this->node(''.self::IDENT[$name].''); } if (isset(self::FENCES[$name])) { return $this->node(''.self::FENCES[$name].''); } if (isset(self::OPS[$name])) return $this->node(''.self::OPS[$name].''); if (in_array($name, self::FUNCS, true)) { return ['xml' => ''.$name.'', 'func' => true, 'limits' => false]; } if (in_array($name, self::LIMIT_FUNCS, true)) { return ['xml' => ''.$name.'', 'func' => true, 'limits' => true]; } if (isset(self::BIG_OPS[$name])) { [$sym, $limits] = self::BIG_OPS[$name]; return ['xml' => ''.$sym.'', 'func' => false, 'limits' => $limits]; } switch ($name) { case 'frac': case 'dfrac': case 'tfrac': case 'cfrac': $num = $this->parseGroup(); $den = $this->parseGroup(); $attr = ''; if ($name === 'dfrac' || $name === 'cfrac') $attr = ' displaystyle="true"'; if ($name === 'tfrac') $attr = ' displaystyle="false"'; return $this->node("{$num}{$den}"); case 'sqrt': $this->skipSpace(); if ($this->peek() === '[') { $index = $this->renderSub($this->readBracketRaw()); $base = $this->parseGroup(); return $this->node("{$base}{$index}"); } return $this->node(''.$this->parseGroup().''); case 'left': return $this->parseLeftRight(); case 'begin': return $this->parseEnvironment(); case 'text': case 'textrm': case 'textnormal': case 'mbox': case 'textup': return $this->node(''.$this->esc($this->readGroupRaw()).''); case 'textbf': return $this->node(''.$this->esc($this->readGroupRaw()).''); case 'textit': return $this->node(''.$this->esc($this->readGroupRaw()).''); case 'operatorname': $fn = $this->esc(trim($this->readGroupRaw())); return ['xml' => ''.$fn.'', 'func' => true, 'limits' => false]; case 'mathbf': case 'boldsymbol': case 'bm': case 'mathrm': case 'mathit': case 'mathbb': case 'mathcal': case 'mathsf': case 'mathtt': $map = [ 'mathbf' => 'bold', 'boldsymbol' => 'bold-italic', 'bm' => 'bold-italic', 'mathrm' => 'normal', 'mathit' => 'italic', 'mathbb' => 'double-struck', 'mathcal' => 'script', 'mathsf' => 'sans-serif', 'mathtt' => 'monospace', ]; $prev = $this->variant; $this->variant = $map[$name]; $xml = $this->parseGroup(); $this->variant = $prev; return $this->node($xml); case 'overline': $base = $this->parseGroup(); return $this->node("{$base}‾"); case 'underline': $base = $this->parseGroup(); return $this->node("{$base}_"); case 'underbrace': $base = $this->parseGroup(); return ['xml' => "{$base}⏟", 'func' => false, 'limits' => true, 'under' => true]; case 'overbrace': $base = $this->parseGroup(); return ['xml' => "{$base}⏞", 'func' => false, 'limits' => true, 'under' => true]; case 'overrightarrow': $base = $this->parseGroup(); return $this->node("{$base}→"); } if (isset(self::ACCENTS[$name])) { $base = $this->parseGroup(); $acc = self::ACCENTS[$name]; $stretch = str_starts_with($name, 'wide') ? 'true' : 'false'; return $this->node("{$base}{$acc}"); } $this->errors[] = '\\'.$name; return $this->node('\\'.$this->esc($name).''); } /** * \left … \right を読む * * @return array ノード */ private function parseLeftRight(): array { $open = $this->readDelimiter(); $inner = $this->parseList(); $close = ''; if ($this->startsWith('\\right')) { $this->readCommand(); $close = $this->readDelimiter(); } else { $this->errors[] = '\\left without \\right'; } $xml = ''; if ($open !== '') $xml .= ''.$this->esc($open).''; $xml .= $this->row($inner); if ($close !== '') $xml .= ''.$this->esc($close).''; $xml .= ''; return $this->node($xml); } /** * \begin{env} … \end{env} を読む * * @return array ノード */ private function parseEnvironment(): array { $env = trim($this->readGroupRaw()); $align = []; if ($env === 'array') { $spec = $this->readGroupRaw(); foreach (str_split(preg_replace('/[^lcr]/', '', $spec)) as $ch) $align[] = ['l' => 'left', 'c' => 'center', 'r' => 'right'][$ch]; } $known = isset(self::ENV_FENCES[$env]) || in_array($env, ['cases', 'aligned', 'align', 'align*', 'gathered', 'array', 'split'], true); if (!$known) $this->errors[] = '\\begin{'.$env.'}'; // 行と列を読む / read rows and cells $rows = []; $cells = []; while (true) { $cells[] = $this->row($this->parseList()); if ($this->peek() === '&') { $this->pos++; continue; } if ($this->startsWith('\\\\')) { $this->pos += 2; $rows[] = $cells; $cells = []; continue; } if ($this->startsWith('\\end')) { $this->readCommand(); $this->readGroupRaw(); break; } if ($this->peek() === '}') { // 迷子の}を読み飛ばす / skip a stray } $this->pos++; continue; } break; // 文末 / end of input } // 最後の空行を捨てる / drop an empty trailing row if (!(count($cells) === 1 && $cells[0] === '')) $rows[] = $cells; $isAligned = in_array($env, ['aligned', 'align', 'align*', 'split'], true); $table = ''; foreach ($rows as $r) { $table .= ''; foreach ($r as $i => $cell) { $ca = ''; if ($isAligned) $ca = $i % 2 === 0 ? 'right' : 'left'; else if ($env === 'cases') $ca = 'left'; else if (isset($align[$i])) $ca = $align[$i]; $style = $ca !== '' && $ca !== 'center' ? " style=\"text-align: {$ca};\"" : ''; // alignedの列間の隙間を詰める / close the gap between aligned columns if ($isAligned) $style = " style=\"text-align: {$ca}; padding-".($i % 2 === 0 ? 'right' : 'left').': 0;"'; $table .= "{$cell}"; } $table .= ''; } $table .= ''; [$open, $close] = self::ENV_FENCES[$env] ?? ['', '']; if ($env === 'cases') $open = '{'; if ($open === '' && $close === '') return $this->node($table); $xml = ''; if ($open !== '') $xml .= ''.$this->esc($open).''; $xml .= $table; if ($close !== '') $xml .= ''.$this->esc($close).''; $xml .= ''; return $this->node($xml); } //------------------------------------------ // 字句解析 //------------------------------------------ private function peek(): string { return $this->pos < strlen($this->src) ? $this->src[$this->pos] : ''; } private function startsWith(string $s): bool { if (substr($this->src, $this->pos, strlen($s)) !== $s) return false; // \endや\rightの後ろに英字が続く場合は別のコマンド / \endpoint is not \end if (ctype_alpha(substr($s, -1))) { $after = substr($this->src, $this->pos + strlen($s), 1); if ($after !== '' && ctype_alpha($after)) return false; } return true; } private function skipSpace(): void { while ($this->pos < strlen($this->src) && ctype_space($this->src[$this->pos])) $this->pos++; } /** * 現在位置が終端記号かどうか */ private function atStop(): bool { $c = $this->peek(); if ($c === '' || $c === '}' || $c === '&') return true; return $this->startsWith('\\\\') || $this->startsWith('\\end') || $this->startsWith('\\right'); } /** * \の後のコマンド名を読む(英字列、又は記号1文字) */ private function readCommand(): string { $this->pos++; // \ if (preg_match('/\G[a-zA-Z]+/', $this->src, $m, 0, $this->pos)) { $this->pos += strlen($m[0]); return $m[0]; } preg_match('/\G./us', $this->src, $m, 0, $this->pos); $ch = $m[0] ?? ''; $this->pos += strlen($ch); return $ch; } /** * {…}の中身を加工せずに読む(\text等) */ private function readGroupRaw(): string { $this->skipSpace(); if ($this->peek() !== '{') { preg_match('/\G./us', $this->src, $m, 0, $this->pos); $ch = $m[0] ?? ''; $this->pos += strlen($ch); return $ch; } $this->pos++; $depth = 1; $out = ''; $len = strlen($this->src); while ($this->pos < $len) { $c = $this->src[$this->pos]; if ($c === '\\' && $this->pos + 1 < $len) { $next = $this->src[$this->pos + 1]; // \{ \} \\ はそのまま文字にする / escaped braces become literal characters $out .= ($next === '{' || $next === '}' || $next === '\\' || $next === '_' || $next === '%' || $next === '&' || $next === '#' || $next === '$') ? $next : $c.$next; $this->pos += 2; continue; } if ($c === '{') $depth++; if ($c === '}') { $depth--; if ($depth === 0) { $this->pos++; break; } } $out .= $c; $this->pos++; } return $out; } /** * [...]の中身を読む(\sqrt[n]用) */ private function readBracketRaw(): string { $this->pos++; // [ $depth = 0; $out = ''; $len = strlen($this->src); while ($this->pos < $len) { $c = $this->src[$this->pos]; if ($c === '{') $depth++; if ($c === '}') $depth--; if ($c === ']' && $depth === 0) { $this->pos++; break; } $out .= $c; $this->pos++; } return $out; } /** * \left、\rightの後の区切り記号を読む */ private function readDelimiter(): string { $this->skipSpace(); $c = $this->peek(); if ($c === '\\') { $start = $this->pos; $name = $this->readCommand(); $key = '\\'.$name; if (isset(self::DELIMS[$key])) return self::DELIMS[$key]; $this->errors[] = 'delimiter '.$key; return ''; } if ($c === '') return ''; $this->pos++; if (isset(self::DELIMS[$c])) return self::DELIMS[$c]; $this->errors[] = 'delimiter '.$c; return $c; } //------------------------------------------ // 出力ヘルパー //------------------------------------------ /** * 部分文字列を同じ設定で変換する(\sqrtの指数等) */ private function renderSub(string $tex): string { [$src, $pos] = [$this->src, $this->pos]; $this->src = $tex; $this->pos = 0; $xml = $this->row($this->parseList()); [$this->src, $this->pos] = [$src, $pos]; return $xml; } private function node(string $xml): array { return ['xml' => $xml, 'func' => false, 'limits' => false]; } /** * 複数のノードをmrowで包む(1つだけならそのまま) */ private function row(array $nodes, bool $forceRow = false): string { if (count($nodes) === 1 && !$forceRow) return $nodes[0]; if (count($nodes) === 1 && str_starts_with($nodes[0], '')) return $nodes[0]; return ''.implode('', $nodes).''; } /** * 添え字はmrowで包んで1要素にする */ private function wrap(string $xml): string { if (preg_match('/^<(mi|mn|mo|mrow|mtext|mfrac|msqrt)[ >]/', $xml) && substr_count($xml, '') && str_ends_with($xml, '')) return $xml; return ''.$xml.''; } private function esc(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } /** * 識別子(1文字)を書体付きで出力する */ private function ident(string $ch): string { $styled = $this->styleChar($ch); if ($this->variant === 'normal' && $styled === $ch) { return ''.$this->esc($ch).''; } return ''.$this->esc($styled).''; } private function styleString(string $s): string { $out = ''; foreach (preg_split('//u', $s, -1, PREG_SPLIT_NO_EMPTY) as $ch) $out .= $this->styleChar($ch); return $out; } /** * 書体をUnicodeの数学用英数字記号に変換する * (MathML Coreではmathvariant="normal"以外は使えない為) */ private function styleChar(string $ch): string { $v = $this->variant; if ($v === '' || $v === 'normal' || $v === 'italic') return $ch; $cp = mb_ord($ch, 'UTF-8'); $isUpper = $cp >= 0x41 && $cp <= 0x5A; $isLower = $cp >= 0x61 && $cp <= 0x7A; $isDigit = $cp >= 0x30 && $cp <= 0x39; $isGreekUpper = $cp >= 0x391 && $cp <= 0x3A9; $isGreekLower = $cp >= 0x3B1 && $cp <= 0x3C9; $holes = [ 'double-struck' => ['C' => 0x2102, 'H' => 0x210D, 'N' => 0x2115, 'P' => 0x2119, 'Q' => 0x211A, 'R' => 0x211D, 'Z' => 0x2124], 'script' => ['B' => 0x212C, 'E' => 0x2130, 'F' => 0x2131, 'H' => 0x210B, 'I' => 0x2110, 'L' => 0x2112, 'M' => 0x2133, 'R' => 0x211B, 'e' => 0x212F, 'g' => 0x210A, 'o' => 0x2134], ]; if (isset($holes[$v][$ch])) return mb_chr($holes[$v][$ch], 'UTF-8'); // [大文字, 小文字, 数字, ギリシャ大文字, ギリシャ小文字] $base = [ 'bold' => [0x1D400, 0x1D41A, 0x1D7CE, 0x1D6A8, 0x1D6C2], 'bold-italic' => [0x1D468, 0x1D482, 0x1D7CE, 0x1D71C, 0x1D736], 'double-struck' => [0x1D538, 0x1D552, 0x1D7D8, null, null], 'script' => [0x1D49C, 0x1D4B6, null, null, null], 'sans-serif' => [0x1D5A0, 0x1D5BA, 0x1D7E2, null, null], 'monospace' => [0x1D670, 0x1D68A, 0x1D7F6, null, null], ][$v] ?? null; if ($base === null) return $ch; if ($isUpper && $base[0]) return mb_chr($base[0] + $cp - 0x41, 'UTF-8'); if ($isLower && $base[1]) return mb_chr($base[1] + $cp - 0x61, 'UTF-8'); if ($isDigit && $base[2]) return mb_chr($base[2] + $cp - 0x30, 'UTF-8'); if ($isGreekUpper && $base[3]) return mb_chr($base[3] + $cp - 0x391, 'UTF-8'); if ($isGreekLower && $base[4]) return mb_chr($base[4] + $cp - 0x3B1, 'UTF-8'); return $ch; } }