算数+タブ
守矢諏訪子 <suwako@076.moe>、2026-11-10 19:01
コミット 902102d1ffff4b3ec98e8fb4056f67dff34a0103
・親 71875ee
・ファイルを見る
8個のファイル、+1213 -235
| @@ -1,3 +1,24 @@ | ||
| 1 | +math { | |
| 2 | + font-family: 'STIX Two Math', 'Cambria Math', 'Latin Modern Math', 'Noto Sans Math', math; | |
| 3 | + font-size: 1.1em; | |
| 4 | +} | |
| 5 | + | |
| 6 | +.math-display { | |
| 7 | + margin: 1em 0; | |
| 8 | + overflow-x: auto; | |
| 9 | + overflow-y: hidden; | |
| 10 | + padding: 0.25em 0; | |
| 11 | +} | |
| 12 | + | |
| 13 | +.math-display > math { | |
| 14 | + font-size: 1.2em; | |
| 15 | +} | |
| 16 | + | |
| 17 | +merror { | |
| 18 | + color: #f358ad; | |
| 19 | + border: 1px dashed var(--color-f35869, #f358ad); | |
| 20 | +} | |
| 21 | + | |
| 1 | 22 | .fraction { |
| 2 | 23 | display: inline-block; |
| 3 | 24 | vertical-align: middle; |
| @@ -0,0 +1,55 @@ | ||
| 1 | +.code-tabs { | |
| 2 | + margin: 1em 0; | |
| 3 | +} | |
| 4 | + | |
| 5 | +.code-tabs > input { | |
| 6 | + position: absolute; | |
| 7 | + opacity: 0; | |
| 8 | + pointer-events: none; | |
| 9 | +} | |
| 10 | + | |
| 11 | +.code-tabs > label { | |
| 12 | + display: inline-block; | |
| 13 | + padding: 4px 14px; | |
| 14 | + margin-right: 4px; | |
| 15 | + border: 1px solid #f545f5; | |
| 16 | + border-bottom: none; | |
| 17 | + border-radius: 6px 6px 0 0; | |
| 18 | + background: #120f12; | |
| 19 | + color: #bcb4bc; | |
| 20 | + cursor: pointer; | |
| 21 | + user-select: none; | |
| 22 | +} | |
| 23 | + | |
| 24 | +.code-tabs > label:hover { | |
| 25 | + color: #fcfcfc; | |
| 26 | +} | |
| 27 | + | |
| 28 | +.code-tabs > input:checked + label { | |
| 29 | + background: #f545f5; | |
| 30 | + color: #120f12; | |
| 31 | + font-weight: bold; | |
| 32 | +} | |
| 33 | + | |
| 34 | +.code-tabs > input:focus-visible + label { | |
| 35 | + outline: 2px solid #b421f8; | |
| 36 | + outline-offset: 2px; | |
| 37 | +} | |
| 38 | + | |
| 39 | +.code-tabs > .code-tab-panel { | |
| 40 | + display: none; | |
| 41 | +} | |
| 42 | + | |
| 43 | +.code-tabs > .code-tab-panel > pre { | |
| 44 | + margin-top: 0; | |
| 45 | + border-top-left-radius: 0; | |
| 46 | +} | |
| 47 | + | |
| 48 | +.code-tabs > input:nth-of-type(1):checked ~ .code-tab-panel:nth-of-type(1), | |
| 49 | +.code-tabs > input:nth-of-type(2):checked ~ .code-tab-panel:nth-of-type(2), | |
| 50 | +.code-tabs > input:nth-of-type(3):checked ~ .code-tab-panel:nth-of-type(3), | |
| 51 | +.code-tabs > input:nth-of-type(4):checked ~ .code-tab-panel:nth-of-type(4), | |
| 52 | +.code-tabs > input:nth-of-type(5):checked ~ .code-tab-panel:nth-of-type(5), | |
| 53 | +.code-tabs > input:nth-of-type(6):checked ~ .code-tab-panel:nth-of-type(6) { | |
| 54 | + display: block; | |
| 55 | +} |
| @@ -121,7 +121,7 @@ | ||
| 121 | 121 | $ip = $_SERVER['REMOTE_ADDR']; |
| 122 | 122 | $res = $this->purgeOldTokens($userData); |
| 123 | 123 | if (!$res->isSuccess) return Result::Error($res->message); |
| 124 | - $userData = $res->data; | |
| 124 | + $userData = $res->data['user']; | |
| 125 | 125 | $token = bin2hex(random_bytes(64)); |
| 126 | 126 | $expire = time() + $this->tokenDuration; |
| 127 | 127 | $newToken = [ |
| @@ -164,7 +164,7 @@ | ||
| 164 | 164 | if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = []; |
| 165 | 165 | $res = $this->purgeOldTokens($userData); |
| 166 | 166 | if (!$res->isSuccess) return null; |
| 167 | - $userData = $res->data; | |
| 167 | + $userData = $res->data['user']; | |
| 168 | 168 | } |
| 169 | 169 | |
| 170 | 170 | public function logout(?string $token = null): Result { |
| @@ -363,9 +363,9 @@ | ||
| 363 | 363 | if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); |
| 364 | 364 | // 有効期限切れたトークンの削除 |
| 365 | 365 | $out = $userData; |
| 366 | - $out->tokens = array_filter($userData->tokens, function($t): bool { | |
| 367 | - return Result::Success('', ($t->expDate ?? 0) > time()); | |
| 368 | - }); | |
| 366 | + $out->tokens = array_values(array_filter($userData->tokens, function($t): bool { | |
| 367 | + return ($t->expDate ?? 0) > time(); | |
| 368 | + })); | |
| 369 | 369 | |
| 370 | 370 | $path = $this->dataDir.$userData->id.'.'.$userData->username.'.json'; |
| 371 | 371 | $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); |
| @@ -375,7 +375,7 @@ | ||
| 375 | 375 | return Result::Error('エラー:ユーザーデータの保存に失敗。'); |
| 376 | 376 | } |
| 377 | 377 | |
| 378 | - return Result::Success('', $out); | |
| 378 | + return Result::Success('', ['user' => $out]); | |
| 379 | 379 | } |
| 380 | 380 | |
| 381 | 381 | private function getUserId(?string $username = null, ?string $token = null): int { |
| @@ -562,4 +562,4 @@ | ||
| 562 | 562 | |
| 563 | 563 | return Result::Success(); |
| 564 | 564 | } |
| 565 | -}; | |
| 565 | +}; |
| @@ -97,6 +97,10 @@ | ||
| 97 | 97 | $this->fullInfo->type = $this->type; |
| 98 | 98 | $this->fullInfo->size = $this->size; |
| 99 | 99 | $this->fullInfo->bytes = $this->bytes; |
| 100 | + if (!isset($this->fullInfo->width) && isset($this->fullInfo->header->width)) { | |
| 101 | + $this->fullInfo->width = $this->fullInfo->header->width; | |
| 102 | + $this->fullInfo->height = $this->fullInfo->header->height; | |
| 103 | + } | |
| 100 | 104 | } |
| 101 | 105 | |
| 102 | 106 | /////////////// |
| @@ -0,0 +1,822 @@ | ||
| 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 | +} |
| @@ -46,7 +46,20 @@ | ||
| 46 | 46 | private array $codeBlockContent = []; |
| 47 | 47 | private const METADATA_LINE = "----"; |
| 48 | 48 | private array $algebraicPlaceholder = []; |
| 49 | + private array $codePlaceholder = []; | |
| 50 | + private array $mathErrors = []; | |
| 51 | + private ?Latex $latex = null; | |
| 52 | + private static int $tabGroupCount = 0; | |
| 49 | 53 | |
| 54 | + // コードタブのラベル / labels for code tabs | |
| 55 | + private const LANG_LABELS = [ | |
| 56 | + 'c' => 'C', 'cpp' => 'C++', 'cc' => 'C++', 'hpp' => 'C++', 'cs' => 'C#', | |
| 57 | + 'slang' => 'Slang', 'glsl' => 'GLSL', 'hlsl' => 'HLSL', 'msl' => 'MSL', | |
| 58 | + 'wgsl' => 'WGSL', 'spirv' => 'SPIR-V', 'asm' => 'Assembly', 'php' => 'PHP', | |
| 59 | + 'js' => 'JavaScript', 'sh' => 'Shell', 'txt' => 'Text', 'json' => 'JSON', | |
| 60 | + 'go' => 'Go', 'rust' => 'Rust', 'zig' => 'Zig', 'odin' => 'Odin', 'sql' => 'SQL', | |
| 61 | + ]; | |
| 62 | + | |
| 50 | 63 | public function __construct(string $path, string $section, ?string $lang = null) { |
| 51 | 64 | $this->html = []; |
| 52 | 65 | if ($lang) $this->path = ROOT.$section.$lang.'/'.$path.'.md'; |
| @@ -115,16 +128,44 @@ | ||
| 115 | 128 | $tableHeaders = []; |
| 116 | 129 | $tableRows = []; |
| 117 | 130 | $inTable = false; |
| 118 | - $i = 0; | |
| 131 | + $inMathBlock = false; | |
| 132 | + $mathLines = []; | |
| 133 | + $inTabs = false; | |
| 134 | + $tabItems = []; | |
| 135 | + $codeBlockLabel = ''; | |
| 119 | 136 | $headerCount = 1; |
| 137 | + $this->mathErrors = []; | |
| 138 | + $i = 0; | |
| 120 | 139 | |
| 140 | + $flush = function() use (&$currentParagraph, &$inList, &$listItems, &$listLevel, | |
| 141 | + &$inTable, &$tableHeaders, &$tableRows, | |
| 142 | + &$inBlockquote, &$blockquoteContent): void { | |
| 143 | + if ($inList) { | |
| 144 | + $this->html[] = $this->createList($listItems); | |
| 145 | + $listItems = []; | |
| 146 | + $inList = false; | |
| 147 | + $listLevel = 0; | |
| 148 | + } | |
| 149 | + if ($inTable) { | |
| 150 | + $this->html[] = $this->createTable($tableHeaders, $tableRows); | |
| 151 | + $tableHeaders = []; | |
| 152 | + $tableRows = []; | |
| 153 | + $inTable = false; | |
| 154 | + } | |
| 155 | + if ($inBlockquote) { | |
| 156 | + $this->html[] = $this->createBlockquote($blockquoteContent); | |
| 157 | + $blockquoteContent = []; | |
| 158 | + $inBlockquote = false; | |
| 159 | + } | |
| 160 | + if (!empty($currentParagraph)) { | |
| 161 | + $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>"; | |
| 162 | + $currentParagraph = []; | |
| 163 | + } | |
| 164 | + }; | |
| 165 | + | |
| 121 | 166 | foreach ($lines as $line) { |
| 122 | 167 | $i++; |
| 123 | - $line = rtrim($line, "\r\n"); | |
| 124 | - // コードの行末の「\」(Cのマクロの継続等)は残す | |
| 125 | - $rawLine = $line; | |
| 126 | - $hasBR = str_ends_with($line, '\\'); | |
| 127 | - $line = rtrim($line, "\\"); | |
| 168 | + $rawLine = rtrim($line, "\r\n"); | |
| 128 | 169 | |
| 129 | 170 | // コメント |
| 130 | 171 | // if (str_starts_with($line, '//')) { |
| @@ -134,22 +175,28 @@ | ||
| 134 | 175 | // continue; |
| 135 | 176 | // } |
| 136 | 177 | |
| 137 | - // コードブロック | |
| 138 | - // 言語名に「+」「#」「-」「.」も使える(```c++、```c#、```asm-x8664) | |
| 139 | - if (preg_match('/^```([\w+#.-]*)\s*$/', $rawLine, $matches)) { | |
| 178 | + // コードブロック(```言語 ラベル) | |
| 179 | + if (preg_match('/^```([\w\+\-#\.]*)(?:\s+(.*\S))?\s*$/u', $rawLine, $matches)) { | |
| 140 | 180 | if (!$this->inCodeBlock) { |
| 141 | - if (!empty($currentParagraph)) { | |
| 142 | - $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>"; | |
| 143 | - $currentParagraph = []; | |
| 144 | - } | |
| 181 | + $flush(); | |
| 145 | 182 | $this->inCodeBlock = true; |
| 146 | 183 | $this->codeBlockLanguage = $matches[1]; |
| 184 | + $codeBlockLabel = $matches[2] ?? ''; | |
| 147 | 185 | continue; |
| 148 | 186 | } else { |
| 149 | - $this->html[] = $this->createCodeBlock(); | |
| 187 | + if ($inTabs) { | |
| 188 | + $tabItems[] = [ | |
| 189 | + 'lang' => $this->codeBlockLanguage, | |
| 190 | + 'label' => $codeBlockLabel, | |
| 191 | + 'code' => $this->codeBlockContent, | |
| 192 | + ]; | |
| 193 | + } else { | |
| 194 | + $this->html[] = $this->createCodeBlock(); | |
| 195 | + } | |
| 150 | 196 | $this->inCodeBlock = false; |
| 151 | 197 | $this->codeBlockLanguage = ''; |
| 152 | 198 | $this->codeBlockContent = []; |
| 199 | + $codeBlockLabel = ''; | |
| 153 | 200 | continue; |
| 154 | 201 | } |
| 155 | 202 | } |
| @@ -159,6 +206,51 @@ | ||
| 159 | 206 | continue; |
| 160 | 207 | } |
| 161 | 208 | |
| 209 | + if ($inMathBlock) { | |
| 210 | + if (trim($rawLine) === '$$') { | |
| 211 | + $this->html[] = $this->createDisplayMath(implode("\n", $mathLines)); | |
| 212 | + $mathLines = []; | |
| 213 | + $inMathBlock = false; | |
| 214 | + } else { | |
| 215 | + $mathLines[] = $rawLine; | |
| 216 | + } | |
| 217 | + continue; | |
| 218 | + } | |
| 219 | + | |
| 220 | + if (trim($rawLine) === '$$') { | |
| 221 | + $flush(); | |
| 222 | + $inMathBlock = true; | |
| 223 | + continue; | |
| 224 | + } | |
| 225 | + | |
| 226 | + // 1行の数式ブロック:$$…$$だけの行 / a line holding only $$…$$ is display math | |
| 227 | + if (preg_match('/^\s*\$\$(.+)\$\$\s*$/u', $rawLine, $matches) | |
| 228 | + && substr_count($rawLine, '$$') === 2) { | |
| 229 | + $flush(); | |
| 230 | + $this->html[] = $this->createDisplayMath($matches[1]); | |
| 231 | + continue; | |
| 232 | + } | |
| 233 | + | |
| 234 | + // コードタブ(:::tabs … :::) / code tabs | |
| 235 | + if (preg_match('/^:::\s*tabs\s*$/', trim($rawLine))) { | |
| 236 | + $flush(); | |
| 237 | + $inTabs = true; | |
| 238 | + $tabItems = []; | |
| 239 | + continue; | |
| 240 | + } | |
| 241 | + | |
| 242 | + if ($inTabs) { | |
| 243 | + if (trim($rawLine) === ':::') { | |
| 244 | + $this->html[] = $this->createTabs($tabItems); | |
| 245 | + $tabItems = []; | |
| 246 | + $inTabs = false; | |
| 247 | + } | |
| 248 | + continue; // タブの中ではコードブロック以外は無視 / only code blocks inside tabs | |
| 249 | + } | |
| 250 | + | |
| 251 | + $hasBR = str_ends_with($rawLine, '\\'); | |
| 252 | + $line = rtrim($rawLine, "\\"); | |
| 253 | + | |
| 162 | 254 | // テーブルの処理 |
| 163 | 255 | if (preg_match('/^\|(.+)\|$/', $line)) { |
| 164 | 256 | if (!empty($currentParagraph)) { |
| @@ -292,15 +384,27 @@ | ||
| 292 | 384 | } |
| 293 | 385 | } |
| 294 | 386 | |
| 295 | - if ($inList) $this->html[] = $this->createList($listItems); | |
| 296 | - if ($inBlockquote) $this->html[] = $this->createBlockquote($blockquoteContent); | |
| 297 | - if ($inTable) $this->html[] = $this->createTable($tableHeaders, $tableRows); | |
| 298 | - if (!empty($currentParagraph)) | |
| 299 | - $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>"; | |
| 387 | + if ($this->inCodeBlock) { | |
| 388 | + $this->html[] = $this->createCodeBlock(); | |
| 389 | + $this->inCodeBlock = false; | |
| 390 | + $this->codeBlockContent = []; | |
| 391 | + } | |
| 392 | + if ($inMathBlock) $this->html[] = $this->createDisplayMath(implode("\n", $mathLines)); | |
| 393 | + if ($inTabs) $this->html[] = $this->createTabs($tabItems); | |
| 394 | + $flush(); | |
| 300 | 395 | |
| 301 | 396 | return implode("\n", $this->html); |
| 302 | 397 | } |
| 303 | 398 | |
| 399 | + /** | |
| 400 | + * 直前のparse()で見つかった、変換出来なかったLaTeXコマンド | |
| 401 | + * | |
| 402 | + * @return array エラーの配列 | |
| 403 | + */ | |
| 404 | + public function getMathErrors(): array { | |
| 405 | + return $this->mathErrors; | |
| 406 | + } | |
| 407 | + | |
| 304 | 408 | // 機能性メソッド |
| 305 | 409 | |
| 306 | 410 | /** |
| @@ -311,8 +415,18 @@ | ||
| 311 | 415 | */ |
| 312 | 416 | private function parseInline(string $text): string { |
| 313 | 417 | $this->algebraicPlaceholder = []; |
| 418 | + $this->codePlaceholder = []; | |
| 314 | 419 | $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); |
| 315 | 420 | |
| 421 | + // インラインコードは他の記法より先に保護する(中の*や_や\をそのまま表示する為) | |
| 422 | + $text = preg_replace_callback('/(?<!\\\\)`(.+?)`/u', function($matches): string { | |
| 423 | + $placeholder = "{{CODE".count($this->codePlaceholder).'CODE}}'; | |
| 424 | + // 記号の前の\だけをエスケープとして消す(`json\_encode`や`C:\\`の為)。\nや\0はそのまま。 | |
| 425 | + $code = preg_replace('/\\\\([\\\\`*_~$%^!\[\]()#{}|.+\-])/u', '$1', $matches[1]); | |
| 426 | + $this->codePlaceholder[$placeholder] = '<code>'.$code.'</code>'; | |
| 427 | + return $placeholder; | |
| 428 | + }, $text); | |
| 429 | + | |
| 316 | 430 | $patterns = [ |
| 317 | 431 | // 数式 |
| 318 | 432 | '/(?<!\\\\)\$\$([^$]+)\$\$/u' => function($matches): string { |
| @@ -346,26 +460,32 @@ | ||
| 346 | 460 | '/(?<!\\\\)\[(.+?)\]\((.+?)\)/u' => '<a href="$2">$1</a>', |
| 347 | 461 | // 振り仮名 |
| 348 | 462 | '/(?<!\\\\)<(.+?)>\((.+?)\)/u' => '<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>', |
| 349 | - // インラインコード | |
| 350 | - '/(?<!\\\\)`(.+?)`/u' => '<code>$1</code>', | |
| 351 | 463 | ]; |
| 352 | 464 | |
| 353 | 465 | foreach ($patterns as $pattern => $replacement) { |
| 354 | 466 | if (is_callable($replacement)) { |
| 355 | 467 | $text = preg_replace_callback($pattern, $replacement, $text); |
| 356 | 468 | } else { |
| 469 | + // kys([$pattern, $replacement, $text]); | |
| 357 | 470 | $result = preg_replace($pattern, $replacement, $text); |
| 471 | + // kys($result); | |
| 358 | 472 | if ($result === null) continue; |
| 473 | + // kys($text); | |
| 359 | 474 | $text = $result; |
| 360 | 475 | } |
| 361 | 476 | } |
| 362 | 477 | |
| 363 | - // プレースホルダーの数式に交換 | |
| 478 | + // エスケープ文字の\を消す(数式とコードを戻す前に) | |
| 479 | + $text = preg_replace('/\\\\(.)/u', '$1', $text); | |
| 480 | + | |
| 481 | + // プレースホルダーを数式とコードに交換 | |
| 364 | 482 | foreach ($this->algebraicPlaceholder as $placeholder => $expr) { |
| 365 | 483 | $text = str_replace($placeholder, $this->parseAlgebraic($expr), $text); |
| 366 | 484 | } |
| 367 | 485 | |
| 368 | - $text = preg_replace('/\\\\(.)/u', '$1', $text); | |
| 486 | + foreach ($this->codePlaceholder as $placeholder => $code) { | |
| 487 | + $text = str_replace($placeholder, $code, $text); | |
| 488 | + } | |
| 369 | 489 | |
| 370 | 490 | return $text; |
| 371 | 491 | } |
| @@ -374,92 +494,56 @@ | ||
| 374 | 494 | * 数式記法をパースする |
| 375 | 495 | * |
| 376 | 496 | * @param string $expression パースする数式 |
| 377 | - * @return string MathJax用にフォーマットされた数式 | |
| 497 | + * @param bool $display ブロック表示ならtrue | |
| 498 | + * @return string MathMLとしてレンダリングされた数式 | |
| 378 | 499 | */ |
| 379 | - private function parseAlgebraic(string $expression): string { | |
| 380 | - // HTMLエスケープ | |
| 500 | + private function parseAlgebraic(string $expression, bool $display = false): string { | |
| 501 | + // HTMLエスケープを戻す(Latexクラスが自分でエスケープする) | |
| 381 | 502 | $expression = htmlspecialchars_decode($expression, ENT_QUOTES); |
| 382 | 503 | |
| 383 | - // ふぃりしゃもじのマッピング | |
| 384 | - $greekLetters = [ | |
| 385 | - 'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ', 'delta' => 'δ', | |
| 386 | - 'epsilon' => 'ε', 'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ', | |
| 387 | - 'iota' => 'ι', 'kappa' => 'κ', 'lambda' => 'λ', 'mu' => 'μ', | |
| 388 | - 'nu' => 'ν', 'xi' => 'ξ', 'omicron' => 'ο', 'pi' => 'π', | |
| 389 | - 'rho' => 'ρ', 'sigma' => 'σ', 'tau' => 'τ', 'upsilon' => 'υ', | |
| 390 | - 'phi' => 'φ', 'chi' => 'χ', 'psi' => 'ψ', 'omega' => 'ω', | |
| 391 | - 'Alpha' => 'Α', 'Beta' => 'Β', 'Gamma' => 'Γ', 'Delta' => 'Δ', | |
| 392 | - 'Epsilon' => 'Ε', 'Zeta' => 'Ζ', 'Eta' => 'Η', 'Theta' => 'Θ', | |
| 393 | - 'Iota' => 'Ι', 'Kappa' => 'Κ', 'Lambda' => 'Λ', 'Mu' => 'Μ', | |
| 394 | - 'Nu' => 'Ν', 'Xi' => 'Ξ', 'Omicron' => 'Ο', 'Pi' => 'Π', | |
| 395 | - 'Rho' => 'Ρ', 'Sigma' => 'Σ', 'Tau' => 'Τ', 'Upsilon' => 'Υ', | |
| 396 | - 'Phi' => 'Φ', 'Chi' => 'Χ', 'Psi' => 'Ψ', 'Omega' => 'Ω', | |
| 397 | - ]; | |
| 504 | + if ($this->latex === null) $this->latex = new Latex(); | |
| 505 | + $mathml = $this->latex->render($expression, $display); | |
| 506 | + foreach ($this->latex->getErrors() as $err) $this->mathErrors[] = $err; | |
| 398 | 507 | |
| 399 | - // ギリシャ文字を置換 | |
| 400 | - foreach ($greekLetters as $text => $symbol) { | |
| 401 | - $expression = preg_replace("/\b$text\b/", $symbol, $expression); | |
| 402 | - } | |
| 508 | + return $mathml; | |
| 509 | + } | |
| 403 | 510 | |
| 404 | - // 上付き文字(例: x^2) | |
| 405 | - $expression = preg_replace_callback('/(\w+)\^(\{?(\d+|\w+)\}?)/', function($matches) { | |
| 406 | - $base = $matches[1]; | |
| 407 | - $exponent = $matches[2]; | |
| 408 | - if ($exponent[0] === '{') $exponent = substr($exponent, 1, -1); | |
| 511 | + /** | |
| 512 | + * ブロック表示の数式を作成する | |
| 513 | + * | |
| 514 | + * @param string $expression LaTeXの数式 | |
| 515 | + * @return string HTMLの数式ブロック | |
| 516 | + */ | |
| 517 | + private function createDisplayMath(string $expression): string { | |
| 518 | + return " <div class=\"math-display\">".$this->parseAlgebraic($expression, true)."</div>"; | |
| 519 | + } | |
| 409 | 520 | |
| 410 | - $superscripts = str_split($exponent); | |
| 411 | - $supMap = [ | |
| 412 | - '0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴', | |
| 413 | - '5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹', | |
| 414 | - 'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ', | |
| 415 | - 'i' => 'ⁱ', 'n' => 'ⁿ', | |
| 416 | - ]; | |
| 417 | - $converted = ''; | |
| 521 | + /** | |
| 522 | + * コードタブを作成する(JavaScript無し、ラジオボタンとCSSだけ) | |
| 523 | + * | |
| 524 | + * @param array $items ['lang' => 言語, 'label' => ラベル, 'code' => 行の配列]の配列 | |
| 525 | + * @return string HTMLのタブ | |
| 526 | + */ | |
| 527 | + private function createTabs(array $items): string { | |
| 528 | + if (empty($items)) return ''; | |
| 418 | 529 | |
| 419 | - foreach ($superscripts as $char) $converted .= $supMap[$char] ?? $char; | |
| 530 | + $group = 'code-tabs-'.(++self::$tabGroupCount); | |
| 531 | + $inputs = ''; | |
| 532 | + $panels = ''; | |
| 420 | 533 | |
| 421 | - return $base.'<sup>'.$converted.'</sup>'; | |
| 422 | - }, $expression); | |
| 534 | + foreach ($items as $n => $item) { | |
| 535 | + $id = "{$group}-{$n}"; | |
| 536 | + $label = $item['label'] !== '' ? $item['label'] | |
| 537 | + : (self::LANG_LABELS[strtolower($item['lang'])] ?? ($item['lang'] !== '' ? $item['lang'] : 'Code')); | |
| 538 | + $checked = $n === 0 ? ' checked="checked"' : ''; | |
| 539 | + [$code, $class] = $this->renderCode(implode("\n", $item['code']), $item['lang']); | |
| 423 | 540 | |
| 424 | - // 下付き文字(例: x_1) | |
| 425 | - $expression = preg_replace_callback('/(\w+)_((\d+|\w+)\}?)/', function($matches) { | |
| 426 | - $base = $matches[1]; | |
| 427 | - $subscript = $matches[2]; | |
| 541 | + $inputs .= " <input type=\"radio\" name=\"{$group}\" id=\"{$id}\"{$checked} />\n"; | |
| 542 | + $inputs .= " <label for=\"{$id}\">".htmlspecialchars($label)."</label>\n"; | |
| 543 | + $panels .= " <div class=\"code-tab-panel\"><pre><code{$class}>{$code}</code></pre></div>\n"; | |
| 544 | + } | |
| 428 | 545 | |
| 429 | - if ($subscript[0] === '{') $subscript = substr($subscript, 1, -1); | |
| 430 | - | |
| 431 | - $subscripts = str_split($subscript); | |
| 432 | - $subMap = [ | |
| 433 | - '0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄', | |
| 434 | - '5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉', | |
| 435 | - ]; | |
| 436 | - $converted = ''; | |
| 437 | - | |
| 438 | - foreach ($subscripts as $char) { | |
| 439 | - $converted .= $subMap[$char] ?? $char; | |
| 440 | - } | |
| 441 | - | |
| 442 | - return $base.'<sub>'.$converted.'</sub>'; | |
| 443 | - }, $expression); | |
| 444 | - | |
| 445 | - // 分数(例: 4/5x, a/b) | |
| 446 | - $expression = preg_replace('/(\d+|\w+)\/(\d*\w+)/', | |
| 447 | - '<span class="fraction"><span class="numerator">$1</span><span class="denominator">$2</span></span>', | |
| 448 | - $expression); | |
| 449 | - | |
| 450 | - // 演算子 | |
| 451 | - $operators = [ | |
| 452 | - '\*' => '・', | |
| 453 | - '!=' => '≠', | |
| 454 | - '>=' => '≥', | |
| 455 | - '<=' => '≤', | |
| 456 | - 'sqrt' => '√', | |
| 457 | - ]; | |
| 458 | - | |
| 459 | - foreach ($operators as $op => $symbol) $expression = preg_replace("/\b$op\b/", $symbol, $expression); | |
| 460 | - | |
| 461 | - // 数式全体をspanで囲む | |
| 462 | - return '<span class="algebraic">'.$expression.'</span>'; | |
| 546 | + return " <div class=\"code-tabs\">\n{$inputs}{$panels} </div>"; | |
| 463 | 547 | } |
| 464 | 548 | |
| 465 | 549 | /** |
| @@ -480,8 +564,11 @@ | ||
| 480 | 564 | foreach ($items as $item) { |
| 481 | 565 | $level = isset($item['level']) ? $item['level'] : $currentLevel; |
| 482 | 566 | |
| 483 | - while ($currentLevel > $level) | |
| 567 | + // ネストから戻る時はレベルを下げる(下げないと無限ループになる) | |
| 568 | + while ($currentLevel > $level) { | |
| 484 | 569 | $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n"; |
| 570 | + $currentLevel--; | |
| 571 | + } | |
| 485 | 572 | |
| 486 | 573 | while ($currentLevel < $level) { |
| 487 | 574 | $currentLevel++; |
| @@ -520,19 +607,27 @@ | ||
| 520 | 607 | * @return string HTMLのコードブロック |
| 521 | 608 | */ |
| 522 | 609 | private function createCodeBlock(): string { |
| 523 | - $raw = implode("\n", $this->codeBlockContent); | |
| 524 | - $lang = strtolower($this->codeBlockLanguage); | |
| 610 | + [$code, $class] = $this->renderCode(implode("\n", $this->codeBlockContent), $this->codeBlockLanguage); | |
| 611 | + return " <p>\n <pre><code{$class}>{$code}</code></pre>"; | |
| 612 | + } | |
| 613 | + | |
| 614 | + /** | |
| 615 | + * コードをHTMLにする(対応している言語はシンタックスハイライトする) | |
| 616 | + * | |
| 617 | + * @param string $raw 生のコード | |
| 618 | + * @param string $lang 言語名(```の後) | |
| 619 | + * @return array [エスケープ済みのコード, class属性] | |
| 620 | + */ | |
| 621 | + private function renderCode(string $raw, string $lang): array { | |
| 622 | + $lang = strtolower($lang); | |
| 525 | 623 | $attr = htmlspecialchars($lang, ENT_QUOTES, 'UTF-8'); |
| 526 | 624 | |
| 527 | - // 対応している言語はハイライトする(Syntax::colorize()はエスケープ済みのHTMLを返す) | |
| 625 | + // Syntax::colorize()はエスケープ済みのHTMLを返す | |
| 528 | 626 | if ($lang !== '' && Syntax::isSupported($lang)) { |
| 529 | - $code = (new Syntax($lang))->colorize($raw); | |
| 530 | - return " <p>\n <pre><code class=\"language-{$attr} hl\">{$code}</code></pre>"; | |
| 627 | + return [(new Syntax($lang))->colorize($raw), " class=\"language-{$attr} hl\""]; | |
| 531 | 628 | } |
| 532 | 629 | |
| 533 | - $code = htmlspecialchars($raw); | |
| 534 | - $class = $lang !== '' ? " class=\"language-{$attr}\"" : ''; | |
| 535 | - return " <p>\n <pre><code{$class}>{$code}</code></pre>"; | |
| 630 | + return [htmlspecialchars($raw), $lang !== '' ? " class=\"language-{$attr}\"" : '']; | |
| 536 | 631 | } |
| 537 | 632 | |
| 538 | 633 | private function createBlockquote(array $content): string { |
| @@ -569,127 +664,9 @@ | ||
| 569 | 664 | $html .= " </tr>\n"; |
| 570 | 665 | } |
| 571 | 666 | $html .= " </tbody>\n"; |
| 572 | - } | |
| 573 | - | |
| 574 | - $html .= " </table>\n </p>"; | |
| 575 | - return $html; | |
| 576 | - } | |
| 577 | - | |
| 578 | - /** | |
| 579 | - * Pure-PHP syntax highlighter | |
| 580 | - * | |
| 581 | - * @param string $code Raw source code | |
| 582 | - * @param string $lang Language identifier (html, css, js, php, …) | |
| 583 | - * @return string HTML with <span class="hl-…"> tokens | |
| 584 | - */ | |
| 585 | - private function highlightCode(string $code, string $lang): string | |
| 586 | - { | |
| 587 | - $lang = strtolower(trim($lang)); | |
| 588 | - $rules = []; | |
| 589 | - | |
| 590 | - // ---- COMMON ------------------------------------------------------- | |
| 591 | - $rules['comment'] = [ | |
| 592 | - // /* … */ (multi-line) | |
| 593 | - ['/\/\*(?:.|\n)*?\*\//', 'hl-comment'], | |
| 594 | - // // …\n | |
| 595 | - ['/\/\/.*(?=\n|$)/', 'hl-comment'], | |
| 596 | - ]; | |
| 597 | - | |
| 598 | - $rules['string'] = [ | |
| 599 | - // "…" and '…' (allow escaped quotes) | |
| 600 | - ['/"(?:\\.|[^"\\\n])*"/', 'hl-string'], | |
| 601 | - ["/'(?:\\.|[^'\\\n])*'/", 'hl-string'], | |
| 602 | - ]; | |
| 603 | - | |
| 604 | - $rules['number'] = [ | |
| 605 | - ['/\b(?:0x[0-9a-fA-F]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/', 'hl-number'], | |
| 606 | - ]; | |
| 607 | - | |
| 608 | - // ---- HTML --------------------------------------------------------- | |
| 609 | - if ($lang === 'html') { | |
| 610 | - $rules['tag'] = [['/<\/?[a-zA-Z0-9\-]+/', 'hl-tag']]; | |
| 611 | - $rules['attr'] = [['/\s[a-zA-Z0-9\-]+(?==)/', 'hl-attr']]; | |
| 612 | - $rules['value'] = [['/(?<==)"(?:\\.|[^"\\\n])*"(?=[>\s])/', 'hl-value']]; | |
| 613 | 667 | } |
| 614 | 668 | |
| 615 | - // ---- CSS ---------------------------------------------------------- | |
| 616 | - if ($lang === 'css') { | |
| 617 | - $rules['selector'] = [['/^[\t ]*[a-zA-Z0-9#\.\-\:$$ $$\=\*\>\+\~\^]+(?=\s*\{)/m', 'hl-selector']]; | |
| 618 | - $rules['property'] = [['/\b[a-z\-]+(?=\s*:)/', 'hl-property']]; | |
| 619 | - $rules['value'] = [['/(?<=\:)\s*[^;]+(?=;)/', 'hl-value']]; | |
| 620 | - $rules['unit'] = [['/\b\d+(px|em|rem|%|vh|vw|pt|pc|cm|mm|in)\b/i', 'hl-unit']]; | |
| 621 | - } | |
| 622 | - | |
| 623 | - // ---- PHP ---------------------------------------------------------- | |
| 624 | - if ($lang === 'php') { | |
| 625 | - $keywords = 'abstract|and|array|as|break|callable|case|catch|class|clone|' | |
| 626 | - . 'const|continue|declare|default|die|do|echo|else|elseif|empty|' | |
| 627 | - . 'enddeclare|endfor|endforeach|endif|endswitch|endwhile|eval|exit|' | |
| 628 | - . 'extends|final|finally|for|foreach|function|global|goto|if|' | |
| 629 | - . 'implements|include|include_once|instanceof|insteadof|interface|' | |
| 630 | - . 'isset|list|namespace|new|or|print|private|protected|public|' | |
| 631 | - . 'require|require_once|return|static|switch|throw|trait|try|unset|' | |
| 632 | - . 'use|var|while|xor|yield'; | |
| 633 | - $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']]; | |
| 634 | - $rules['function']= [['/\b([a-zA-Z_\x7f-\xff][\w\x7f-\xff]*)(?=\s*\()/', 'hl-function']]; | |
| 635 | - $rules['variable']= [['/\$[a-zA-Z_\x7f-\xff][\w\x7f-\xff]*/', 'hl-variable']]; | |
| 636 | - } | |
| 637 | - | |
| 638 | - // ---- Shell -------------------------------------------------------- | |
| 639 | - if ($lang === 'sh') { | |
| 640 | - $keywords = 'if|then|else|elif|fi|case|esac|for|select|while|until|do|done|in'; | |
| 641 | - $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']]; | |
| 642 | - $rules['variable']= [['/\$\{?[a-zA-Z_][\w]*\}?/', 'hl-variable']]; | |
| 643 | - } | |
| 644 | - | |
| 645 | - // ---- JSON --------------------------------------------------------- | |
| 646 | - if ($lang === 'json') { | |
| 647 | - $rules['property'] = [['/"([^"]+)":/', '<span class="hl-property">$1</span>:']]; | |
| 648 | - } | |
| 649 | - | |
| 650 | - // ---- C / C++ ----------------------------------------------- | |
| 651 | - if (in_array($lang, ['c', 'cpp', 'cs'])) { | |
| 652 | - $keywords = 'abstract|assert|boolean|break|byte|case|catch|char|class|const|' | |
| 653 | - . 'continue|default|do|double|else|enum|extends|false|final|finally|' | |
| 654 | - . 'float|for|goto|if|implements|import|instanceof|int|interface|long|' | |
| 655 | - . 'native|new|null|package|private|protected|public|return|short|static|' | |
| 656 | - . 'strictfp|super|switch|synchronized|this|throw|throws|transient|true|' | |
| 657 | - . 'try|void|volatile|while'; | |
| 658 | - $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']]; | |
| 659 | - $rules['type'] = [['/\b(?:int|float|double|char|bool|void|string)\b/', 'hl-type']]; | |
| 660 | - $rules['define'] = [['/(#include)|(#define)/', 'hl-define']]; | |
| 661 | - } | |
| 662 | - | |
| 663 | - if ($lang === 'markdown') { | |
| 664 | - $rules['header'] = [['/^(#{1,6})\s+(.+)$/', '<span class="hl-header">$1 $2</span>']]; | |
| 665 | - $rules['link'] = [['/\[([^\]]+)\]\(([^)]+) $$/', '<span class="hl-link">[$1]($2)</span>']]; | |
| 666 | - } | |
| 667 | - | |
| 668 | - $html = htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8'); | |
| 669 | - | |
| 670 | - $order = ['comment', 'string', 'number']; | |
| 671 | - foreach (['tag','attr','value','selector','property','unit','keyword','function','variable','type','header','link'] as $k) { | |
| 672 | - if (isset($rules[$k])) $order[] = $k; | |
| 673 | - } | |
| 674 | - | |
| 675 | - foreach ($order as $type) { | |
| 676 | - if (!isset($rules[$type])) continue; | |
| 677 | - foreach ($rules[$type] as $rule) { | |
| 678 | - [$pattern, $class] = $rule; | |
| 679 | - $html = preg_replace_callback( | |
| 680 | - $pattern, | |
| 681 | - function ($m) use ($class) { | |
| 682 | - // For JSON property we already built the <span> | |
| 683 | - if (strpos($m[0], '<span') === 0) return $m[0]; | |
| 684 | - $inner = $m[0]; | |
| 685 | - // Preserve already escaped entities inside strings/comments | |
| 686 | - return '<span class="'.$class.'">'.$inner.'</span>'; | |
| 687 | - }, | |
| 688 | - $html | |
| 689 | - ); | |
| 690 | - } | |
| 691 | - } | |
| 692 | - | |
| 669 | + $html .= " </table>\n </p>"; | |
| 693 | 670 | return $html; |
| 694 | 671 | } |
| 695 | 672 | } |
| @@ -262,7 +262,105 @@ | ||
| 262 | 262 | |
| 263 | 263 | $test->assertEquals($expect, $actual); |
| 264 | 264 | }); |
| 265 | - | |
| 265 | + | |
| 266 | + $test->it('インライン数式はMathMLに交換出来るはず', function($test): void { | |
| 267 | + mkfile('長さは$$\lVert \mathbf{v} \rVert = \sqrt{x^2 + y^2}$$です。'); | |
| 268 | + | |
| 269 | + $md = new Markdown('dietrannynigger', '/blog/'); | |
| 270 | + $body = $md->parse(); | |
| 271 | + | |
| 272 | + $test->assertStringContains('<math alttext=', $body); | |
| 273 | + $test->assertStringContains('<msqrt>', $body); | |
| 274 | + $test->assertStringContains('<mi>𝐯</mi>', $body); | |
| 275 | + $test->assertStringContains('<msup><mi>x</mi><mn>2</mn></msup>', $body); | |
| 276 | + $test->assertStringContains('です。', $body); | |
| 277 | + $test->assertEquals([], $md->getMathErrors()); | |
| 278 | + }); | |
| 279 | + | |
| 280 | + $test->it('LaTeXのバックスラッシュは消されないはず', function($test): void { | |
| 281 | + mkfile('$$\frac{1}{2} \cdot \theta$$の後に\*エスケープ\*'); | |
| 282 | + | |
| 283 | + $md = new Markdown('dietrannynigger', '/blog/'); | |
| 284 | + $body = $md->parse(); | |
| 285 | + | |
| 286 | + $test->assertStringContains('<mfrac><mrow><mn>1</mn></mrow><mrow><mn>2</mn></mrow></mfrac>', $body); | |
| 287 | + $test->assertStringContains('<mo>⋅</mo><mi>θ</mi>', $body); | |
| 288 | + $test->assertStringContains('の後に*エスケープ*', $body); | |
| 289 | + }); | |
| 290 | + | |
| 291 | + $test->it('数式ブロックはブロック表示のMathMLに交換出来るはず', function($test): void { | |
| 292 | + mkfile("前の段落\n\n$$\n\\mathbf{M} =\n\\begin{bmatrix} a & b \\\\\nc & d \\end{bmatrix}\n$$\n\n\$\$x^2\$\$\n\n後の段落"); | |
| 293 | + | |
| 294 | + $md = new Markdown('dietrannynigger', '/blog/'); | |
| 295 | + $body = $md->parse(); | |
| 296 | + | |
| 297 | + $test->assertEquals(2, substr_count($body, '<div class="math-display"><math display="block"')); | |
| 298 | + $test->assertEquals(2, substr_count($body, '<mtr>')); | |
| 299 | + $test->assertStringContains('<mo fence="true" stretchy="true" form="prefix">[</mo>', $body); | |
| 300 | + $test->assertStringContains('前の段落', $body); | |
| 301 | + $test->assertStringContains('後の段落', $body); | |
| 302 | + $test->assertEquals([], $md->getMathErrors()); | |
| 303 | + }); | |
| 304 | + | |
| 305 | + $test->it('未対応のLaTeXコマンドはエラーとして報告されるはず', function($test): void { | |
| 306 | + mkfile('$$\kerokero{x}$$'); | |
| 307 | + | |
| 308 | + $md = new Markdown('dietrannynigger', '/blog/'); | |
| 309 | + $body = $md->parse(); | |
| 310 | + | |
| 311 | + $test->assertStringContains('<merror>', $body); | |
| 312 | + $test->assertEquals(['\kerokero'], $md->getMathErrors()); | |
| 313 | + }); | |
| 314 | + | |
| 315 | + $test->it('インラインコードの中身は変換されないはず', function($test): void { | |
| 316 | + mkfile('`a * b * c`と`my_var_name`と`\n`と`$$x$$`'); | |
| 317 | + | |
| 318 | + $md = new Markdown('dietrannynigger', '/blog/'); | |
| 319 | + $body = $md->parse(); | |
| 320 | + | |
| 321 | + $test->assertStringContains('<code>a * b * c</code>', $body); | |
| 322 | + $test->assertStringContains('<code>my_var_name</code>', $body); | |
| 323 | + $test->assertStringContains('<code>\n</code>', $body); | |
| 324 | + $test->assertStringContains('<code>$$x$$</code>', $body); | |
| 325 | + }); | |
| 326 | + | |
| 327 | + $test->it('コードブロックの行末のバックスラッシュは残るはず', function($test): void { | |
| 328 | + mkfile("```txt\n /\\\n / \\\n```"); | |
| 329 | + | |
| 330 | + $md = new Markdown('dietrannynigger', '/blog/'); | |
| 331 | + $body = $md->parse(); | |
| 332 | + | |
| 333 | + $test->assertStringContains(" /\\\n / \\</code></pre>", $body); | |
| 334 | + }); | |
| 335 | + | |
| 336 | + $test->it('コードタブはラジオボタンのタブに交換出来るはず', function($test): void { | |
| 337 | + mkfile(":::tabs\n```slang\nfloat3 v;\n```\n```glsl\nvec3 v;\n```\n```glsl GLSL 4.50\nvec3 w;\n```\n:::\n\n後の段落"); | |
| 338 | + | |
| 339 | + $md = new Markdown('dietrannynigger', '/blog/'); | |
| 340 | + $body = $md->parse(); | |
| 341 | + | |
| 342 | + $test->assertStringContains('<div class="code-tabs">', $body); | |
| 343 | + $test->assertEquals(3, substr_count($body, '<input type="radio"')); | |
| 344 | + $test->assertEquals(1, substr_count($body, 'checked="checked"')); | |
| 345 | + $test->assertStringContains('>Slang</label>', $body); | |
| 346 | + $test->assertStringContains('>GLSL</label>', $body); | |
| 347 | + $test->assertStringContains('>GLSL 4.50</label>', $body); | |
| 348 | + $test->assertStringContains('<code class="language-slang hl"><span class="hl-type">float3</span> v;</code>', $body); | |
| 349 | + $test->assertStringContains('<code class="language-glsl hl"><span class="hl-type">vec3</span> v;</code>', $body); | |
| 350 | + $test->assertStringContains('後の段落', $body); | |
| 351 | + }); | |
| 352 | + | |
| 353 | + $test->it('対応している言語のコードブロックはハイライトされるはず', function($test): void { | |
| 354 | + mkfile("```c\nint x = 42; // <b>\n```\n\n```sh\necho \"<b>\"\n```"); | |
| 355 | + | |
| 356 | + $md = new Markdown('dietrannynigger', '/blog/'); | |
| 357 | + $body = $md->parse(); | |
| 358 | + | |
| 359 | + $test->assertStringContains('<code class="language-c hl"><span class="hl-keyword">int</span> x = <span class="hl-number">42</span>; <span class="hl-comment">// <b></span></code>', $body); | |
| 360 | + // 未対応の言語はエスケープだけ | |
| 361 | + $test->assertStringContains('<code class="language-sh">echo "<b>"</code>', $body); | |
| 362 | + }); | |
| 363 | + | |
| 266 | 364 | rmfile(); |
| 267 | 365 | }); |
| 268 | 366 |
変更
util.php
| @@ -36,6 +36,7 @@ | ||
| 36 | 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | 37 | */ |
| 38 | 38 | if (!CURL_ENABLED) define('ACTIVITYPUB_ENABLED', false); |
| 39 | +foreach (['MAILER_ENABLED', 'OPENPROVIDER_ENABLED'] as $c) if (!defined($c)) define($c, false); | |
| 39 | 40 | |
| 40 | 41 | enum LogType { |
| 41 | 42 | case ActivityPub; |