算数+タブ

守矢諏訪子 <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+
122 .fraction {
223 display: inline-block;
324 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 @@
121121 $ip = $_SERVER['REMOTE_ADDR'];
122122 $res = $this->purgeOldTokens($userData);
123123 if (!$res->isSuccess) return Result::Error($res->message);
124- $userData = $res->data;
124+ $userData = $res->data['user'];
125125 $token = bin2hex(random_bytes(64));
126126 $expire = time() + $this->tokenDuration;
127127 $newToken = [
@@ -164,7 +164,7 @@
164164 if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
165165 $res = $this->purgeOldTokens($userData);
166166 if (!$res->isSuccess) return null;
167- $userData = $res->data;
167+ $userData = $res->data['user'];
168168 }
169169
170170 public function logout(?string $token = null): Result {
@@ -363,9 +363,9 @@
363363 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
364364 // 有効期限切れたトークンの削除
365365 $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+ }));
369369
370370 $path = $this->dataDir.$userData->id.'.'.$userData->username.'.json';
371371 $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
@@ -375,7 +375,7 @@
375375 return Result::Error('エラー:ユーザーデータの保存に失敗。');
376376 }
377377
378- return Result::Success('', $out);
378+ return Result::Success('', ['user' => $out]);
379379 }
380380
381381 private function getUserId(?string $username = null, ?string $token = null): int {
@@ -562,4 +562,4 @@
562562
563563 return Result::Success();
564564 }
565-};
565+};
@@ -97,6 +97,10 @@
9797 $this->fullInfo->type = $this->type;
9898 $this->fullInfo->size = $this->size;
9999 $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+ }
100104 }
101105
102106 ///////////////
@@ -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>&amp;</mo>');
380+ case '_': return $this->node('<mo>_</mo>');
381+ case '$': return $this->node('<mo>$</mo>');
382+ }
383+
384+ if (isset(self::GREEK[$name])) return $this->node($this->ident(self::GREEK[$name]));
385+ if (isset(self::GREEK_UPPER[$name])) {
386+ $ch = $this->styleChar(self::GREEK_UPPER[$name]);
387+ $attr = $ch === self::GREEK_UPPER[$name] ? ' mathvariant="normal"' : '';
388+ return $this->node("<mi{$attr}>{$ch}</mi>");
389+ }
390+ if (isset(self::IDENT[$name])) {
391+ return $this->node('<mi mathvariant="normal">'.self::IDENT[$name].'</mi>');
392+ }
393+ if (isset(self::FENCES[$name])) {
394+ return $this->node('<mo stretchy="false">'.self::FENCES[$name].'</mo>');
395+ }
396+ if (isset(self::OPS[$name])) return $this->node('<mo>'.self::OPS[$name].'</mo>');
397+
398+ if (in_array($name, self::FUNCS, true)) {
399+ return ['xml' => '<mi>'.$name.'</mi>', 'func' => true, 'limits' => false];
400+ }
401+ if (in_array($name, self::LIMIT_FUNCS, true)) {
402+ return ['xml' => '<mo movablelimits="true" form="prefix">'.$name.'</mo>',
403+ 'func' => true, 'limits' => true];
404+ }
405+ if (isset(self::BIG_OPS[$name])) {
406+ [$sym, $limits] = self::BIG_OPS[$name];
407+ return ['xml' => '<mo largeop="true" movablelimits="'.($limits ? 'true' : 'false').'">'.$sym.'</mo>',
408+ 'func' => false, 'limits' => $limits];
409+ }
410+
411+ switch ($name) {
412+ case 'frac': case 'dfrac': case 'tfrac': case 'cfrac':
413+ $num = $this->parseGroup();
414+ $den = $this->parseGroup();
415+ $attr = '';
416+ if ($name === 'dfrac' || $name === 'cfrac') $attr = ' displaystyle="true"';
417+ if ($name === 'tfrac') $attr = ' displaystyle="false"';
418+ return $this->node("<mfrac{$attr}>{$num}{$den}</mfrac>");
419+
420+ case 'sqrt':
421+ $this->skipSpace();
422+ if ($this->peek() === '[') {
423+ $index = $this->renderSub($this->readBracketRaw());
424+ $base = $this->parseGroup();
425+ return $this->node("<mroot>{$base}{$index}</mroot>");
426+ }
427+ return $this->node('<msqrt>'.$this->parseGroup().'</msqrt>');
428+
429+ case 'left':
430+ return $this->parseLeftRight();
431+
432+ case 'begin':
433+ return $this->parseEnvironment();
434+
435+ case 'text': case 'textrm': case 'textnormal': case 'mbox': case 'textup':
436+ return $this->node('<mtext>'.$this->esc($this->readGroupRaw()).'</mtext>');
437+ case 'textbf':
438+ return $this->node('<mtext style="font-weight: bold;">'.$this->esc($this->readGroupRaw()).'</mtext>');
439+ case 'textit':
440+ return $this->node('<mtext style="font-style: italic;">'.$this->esc($this->readGroupRaw()).'</mtext>');
441+
442+ case 'operatorname':
443+ $fn = $this->esc(trim($this->readGroupRaw()));
444+ return ['xml' => '<mi>'.$fn.'</mi>', 'func' => true, 'limits' => false];
445+
446+ case 'mathbf': case 'boldsymbol': case 'bm': case 'mathrm': case 'mathit':
447+ case 'mathbb': case 'mathcal': case 'mathsf': case 'mathtt':
448+ $map = [
449+ 'mathbf' => 'bold', 'boldsymbol' => 'bold-italic', 'bm' => 'bold-italic',
450+ 'mathrm' => 'normal', 'mathit' => 'italic', 'mathbb' => 'double-struck',
451+ 'mathcal' => 'script', 'mathsf' => 'sans-serif', 'mathtt' => 'monospace',
452+ ];
453+ $prev = $this->variant;
454+ $this->variant = $map[$name];
455+ $xml = $this->parseGroup();
456+ $this->variant = $prev;
457+ return $this->node($xml);
458+
459+ case 'overline':
460+ $base = $this->parseGroup();
461+ return $this->node("<mover>{$base}<mo stretchy=\"true\">‾</mo></mover>");
462+ case 'underline':
463+ $base = $this->parseGroup();
464+ return $this->node("<munder>{$base}<mo stretchy=\"true\">_</mo></munder>");
465+ case 'underbrace':
466+ $base = $this->parseGroup();
467+ return ['xml' => "<munder>{$base}<mo stretchy=\"true\">⏟</mo></munder>",
468+ 'func' => false, 'limits' => true, 'under' => true];
469+ case 'overbrace':
470+ $base = $this->parseGroup();
471+ return ['xml' => "<mover>{$base}<mo stretchy=\"true\">⏞</mo></mover>",
472+ 'func' => false, 'limits' => true, 'under' => true];
473+ case 'overrightarrow':
474+ $base = $this->parseGroup();
475+ return $this->node("<mover>{$base}<mo stretchy=\"true\">→</mo></mover>");
476+ }
477+
478+ if (isset(self::ACCENTS[$name])) {
479+ $base = $this->parseGroup();
480+ $acc = self::ACCENTS[$name];
481+ $stretch = str_starts_with($name, 'wide') ? 'true' : 'false';
482+ return $this->node("<mover accent=\"true\">{$base}<mo stretchy=\"{$stretch}\">{$acc}</mo></mover>");
483+ }
484+
485+ $this->errors[] = '\\'.$name;
486+ return $this->node('<merror><mtext>\\'.$this->esc($name).'</mtext></merror>');
487+ }
488+
489+ /**
490+ * \left … \right を読む
491+ *
492+ * @return array ノード
493+ */
494+ private function parseLeftRight(): array {
495+ $open = $this->readDelimiter();
496+ $inner = $this->parseList();
497+ $close = '';
498+ if ($this->startsWith('\\right')) {
499+ $this->readCommand();
500+ $close = $this->readDelimiter();
501+ } else {
502+ $this->errors[] = '\\left without \\right';
503+ }
504+
505+ $xml = '<mrow>';
506+ if ($open !== '') $xml .= '<mo fence="true" stretchy="true" form="prefix">'.$this->esc($open).'</mo>';
507+ $xml .= $this->row($inner);
508+ if ($close !== '') $xml .= '<mo fence="true" stretchy="true" form="postfix">'.$this->esc($close).'</mo>';
509+ $xml .= '</mrow>';
510+ return $this->node($xml);
511+ }
512+
513+ /**
514+ * \begin{env} … \end{env} を読む
515+ *
516+ * @return array ノード
517+ */
518+ private function parseEnvironment(): array {
519+ $env = trim($this->readGroupRaw());
520+ $align = [];
521+
522+ if ($env === 'array') {
523+ $spec = $this->readGroupRaw();
524+ foreach (str_split(preg_replace('/[^lcr]/', '', $spec)) as $ch)
525+ $align[] = ['l' => 'left', 'c' => 'center', 'r' => 'right'][$ch];
526+ }
527+
528+ $known = isset(self::ENV_FENCES[$env])
529+ || in_array($env, ['cases', 'aligned', 'align', 'align*', 'gathered', 'array', 'split'], true);
530+ if (!$known) $this->errors[] = '\\begin{'.$env.'}';
531+
532+ // 行と列を読む / read rows and cells
533+ $rows = [];
534+ $cells = [];
535+ while (true) {
536+ $cells[] = $this->row($this->parseList());
537+ if ($this->peek() === '&') {
538+ $this->pos++;
539+ continue;
540+ }
541+ if ($this->startsWith('\\\\')) {
542+ $this->pos += 2;
543+ $rows[] = $cells;
544+ $cells = [];
545+ continue;
546+ }
547+ if ($this->startsWith('\\end')) {
548+ $this->readCommand();
549+ $this->readGroupRaw();
550+ break;
551+ }
552+ if ($this->peek() === '}') { // 迷子の}を読み飛ばす / skip a stray }
553+ $this->pos++;
554+ continue;
555+ }
556+ break; // 文末 / end of input
557+ }
558+ // 最後の空行を捨てる / drop an empty trailing row
559+ if (!(count($cells) === 1 && $cells[0] === '')) $rows[] = $cells;
560+
561+ $isAligned = in_array($env, ['aligned', 'align', 'align*', 'split'], true);
562+ $table = '<mtable'.($isAligned || $env === 'cases' ? ' displaystyle="true"' : '').'>';
563+ foreach ($rows as $r) {
564+ $table .= '<mtr>';
565+ foreach ($r as $i => $cell) {
566+ $ca = '';
567+ if ($isAligned) $ca = $i % 2 === 0 ? 'right' : 'left';
568+ else if ($env === 'cases') $ca = 'left';
569+ else if (isset($align[$i])) $ca = $align[$i];
570+ $style = $ca !== '' && $ca !== 'center' ? " style=\"text-align: {$ca};\"" : '';
571+ // alignedの列間の隙間を詰める / close the gap between aligned columns
572+ if ($isAligned) $style = " style=\"text-align: {$ca}; padding-".($i % 2 === 0 ? 'right' : 'left').': 0;"';
573+ $table .= "<mtd{$style}>{$cell}</mtd>";
574+ }
575+ $table .= '</mtr>';
576+ }
577+ $table .= '</mtable>';
578+
579+ [$open, $close] = self::ENV_FENCES[$env] ?? ['', ''];
580+ if ($env === 'cases') $open = '{';
581+
582+ if ($open === '' && $close === '') return $this->node($table);
583+
584+ $xml = '<mrow>';
585+ if ($open !== '') $xml .= '<mo fence="true" stretchy="true" form="prefix">'.$this->esc($open).'</mo>';
586+ $xml .= $table;
587+ if ($close !== '') $xml .= '<mo fence="true" stretchy="true" form="postfix">'.$this->esc($close).'</mo>';
588+ $xml .= '</mrow>';
589+ return $this->node($xml);
590+ }
591+
592+ //------------------------------------------
593+ // 字句解析
594+ //------------------------------------------
595+
596+ private function peek(): string {
597+ return $this->pos < strlen($this->src) ? $this->src[$this->pos] : '';
598+ }
599+
600+ private function startsWith(string $s): bool {
601+ if (substr($this->src, $this->pos, strlen($s)) !== $s) return false;
602+ // \endや\rightの後ろに英字が続く場合は別のコマンド / \endpoint is not \end
603+ if (ctype_alpha(substr($s, -1))) {
604+ $after = substr($this->src, $this->pos + strlen($s), 1);
605+ if ($after !== '' && ctype_alpha($after)) return false;
606+ }
607+ return true;
608+ }
609+
610+ private function skipSpace(): void {
611+ while ($this->pos < strlen($this->src) && ctype_space($this->src[$this->pos])) $this->pos++;
612+ }
613+
614+ /**
615+ * 現在位置が終端記号かどうか
616+ */
617+ private function atStop(): bool {
618+ $c = $this->peek();
619+ if ($c === '' || $c === '}' || $c === '&') return true;
620+ return $this->startsWith('\\\\') || $this->startsWith('\\end') || $this->startsWith('\\right');
621+ }
622+
623+ /**
624+ * \の後のコマンド名を読む(英字列、又は記号1文字)
625+ */
626+ private function readCommand(): string {
627+ $this->pos++; // \
628+ if (preg_match('/\G[a-zA-Z]+/', $this->src, $m, 0, $this->pos)) {
629+ $this->pos += strlen($m[0]);
630+ return $m[0];
631+ }
632+ preg_match('/\G./us', $this->src, $m, 0, $this->pos);
633+ $ch = $m[0] ?? '';
634+ $this->pos += strlen($ch);
635+ return $ch;
636+ }
637+
638+ /**
639+ * {…}の中身を加工せずに読む(\text等)
640+ */
641+ private function readGroupRaw(): string {
642+ $this->skipSpace();
643+ if ($this->peek() !== '{') {
644+ preg_match('/\G./us', $this->src, $m, 0, $this->pos);
645+ $ch = $m[0] ?? '';
646+ $this->pos += strlen($ch);
647+ return $ch;
648+ }
649+ $this->pos++;
650+ $depth = 1;
651+ $out = '';
652+ $len = strlen($this->src);
653+ while ($this->pos < $len) {
654+ $c = $this->src[$this->pos];
655+ if ($c === '\\' && $this->pos + 1 < $len) {
656+ $next = $this->src[$this->pos + 1];
657+ // \{ \} \\ はそのまま文字にする / escaped braces become literal characters
658+ $out .= ($next === '{' || $next === '}' || $next === '\\' || $next === '_'
659+ || $next === '%' || $next === '&' || $next === '#' || $next === '$') ? $next : $c.$next;
660+ $this->pos += 2;
661+ continue;
662+ }
663+ if ($c === '{') $depth++;
664+ if ($c === '}') {
665+ $depth--;
666+ if ($depth === 0) {
667+ $this->pos++;
668+ break;
669+ }
670+ }
671+ $out .= $c;
672+ $this->pos++;
673+ }
674+ return $out;
675+ }
676+
677+ /**
678+ * [...]の中身を読む(\sqrt[n]用)
679+ */
680+ private function readBracketRaw(): string {
681+ $this->pos++; // [
682+ $depth = 0;
683+ $out = '';
684+ $len = strlen($this->src);
685+ while ($this->pos < $len) {
686+ $c = $this->src[$this->pos];
687+ if ($c === '{') $depth++;
688+ if ($c === '}') $depth--;
689+ if ($c === ']' && $depth === 0) {
690+ $this->pos++;
691+ break;
692+ }
693+ $out .= $c;
694+ $this->pos++;
695+ }
696+ return $out;
697+ }
698+
699+ /**
700+ * \left、\rightの後の区切り記号を読む
701+ */
702+ private function readDelimiter(): string {
703+ $this->skipSpace();
704+ $c = $this->peek();
705+ if ($c === '\\') {
706+ $start = $this->pos;
707+ $name = $this->readCommand();
708+ $key = '\\'.$name;
709+ if (isset(self::DELIMS[$key])) return self::DELIMS[$key];
710+ $this->errors[] = 'delimiter '.$key;
711+ return '';
712+ }
713+ if ($c === '') return '';
714+ $this->pos++;
715+ if (isset(self::DELIMS[$c])) return self::DELIMS[$c];
716+ $this->errors[] = 'delimiter '.$c;
717+ return $c;
718+ }
719+
720+ //------------------------------------------
721+ // 出力ヘルパー
722+ //------------------------------------------
723+
724+ /**
725+ * 部分文字列を同じ設定で変換する(\sqrtの指数等)
726+ */
727+ private function renderSub(string $tex): string {
728+ [$src, $pos] = [$this->src, $this->pos];
729+ $this->src = $tex;
730+ $this->pos = 0;
731+ $xml = $this->row($this->parseList());
732+ [$this->src, $this->pos] = [$src, $pos];
733+ return $xml;
734+ }
735+
736+ private function node(string $xml): array {
737+ return ['xml' => $xml, 'func' => false, 'limits' => false];
738+ }
739+
740+ /**
741+ * 複数のノードをmrowで包む(1つだけならそのまま)
742+ */
743+ private function row(array $nodes, bool $forceRow = false): string {
744+ if (count($nodes) === 1 && !$forceRow) return $nodes[0];
745+ if (count($nodes) === 1 && str_starts_with($nodes[0], '<mrow>')) return $nodes[0];
746+ return '<mrow>'.implode('', $nodes).'</mrow>';
747+ }
748+
749+ /**
750+ * 添え字はmrowで包んで1要素にする
751+ */
752+ private function wrap(string $xml): string {
753+ if (preg_match('/^<(mi|mn|mo|mrow|mtext|mfrac|msqrt)[ >]/', $xml) && substr_count($xml, '<m') === 1)
754+ return $xml;
755+ if (str_starts_with($xml, '<mrow>') && str_ends_with($xml, '</mrow>')) return $xml;
756+ return '<mrow>'.$xml.'</mrow>';
757+ }
758+
759+ private function esc(string $s): string {
760+ return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
761+ }
762+
763+ /**
764+ * 識別子(1文字)を書体付きで出力する
765+ */
766+ private function ident(string $ch): string {
767+ $styled = $this->styleChar($ch);
768+ if ($this->variant === 'normal' && $styled === $ch) {
769+ return '<mi mathvariant="normal">'.$this->esc($ch).'</mi>';
770+ }
771+ return '<mi>'.$this->esc($styled).'</mi>';
772+ }
773+
774+ private function styleString(string $s): string {
775+ $out = '';
776+ foreach (preg_split('//u', $s, -1, PREG_SPLIT_NO_EMPTY) as $ch) $out .= $this->styleChar($ch);
777+ return $out;
778+ }
779+
780+ /**
781+ * 書体をUnicodeの数学用英数字記号に変換する
782+ * (MathML Coreではmathvariant="normal"以外は使えない為)
783+ */
784+ private function styleChar(string $ch): string {
785+ $v = $this->variant;
786+ if ($v === '' || $v === 'normal' || $v === 'italic') return $ch;
787+
788+ $cp = mb_ord($ch, 'UTF-8');
789+ $isUpper = $cp >= 0x41 && $cp <= 0x5A;
790+ $isLower = $cp >= 0x61 && $cp <= 0x7A;
791+ $isDigit = $cp >= 0x30 && $cp <= 0x39;
792+ $isGreekUpper = $cp >= 0x391 && $cp <= 0x3A9;
793+ $isGreekLower = $cp >= 0x3B1 && $cp <= 0x3C9;
794+
795+ $holes = [
796+ 'double-struck' => ['C' => 0x2102, 'H' => 0x210D, 'N' => 0x2115, 'P' => 0x2119,
797+ 'Q' => 0x211A, 'R' => 0x211D, 'Z' => 0x2124],
798+ 'script' => ['B' => 0x212C, 'E' => 0x2130, 'F' => 0x2131, 'H' => 0x210B, 'I' => 0x2110,
799+ 'L' => 0x2112, 'M' => 0x2133, 'R' => 0x211B, 'e' => 0x212F, 'g' => 0x210A,
800+ 'o' => 0x2134],
801+ ];
802+ if (isset($holes[$v][$ch])) return mb_chr($holes[$v][$ch], 'UTF-8');
803+
804+ // [大文字, 小文字, 数字, ギリシャ大文字, ギリシャ小文字]
805+ $base = [
806+ 'bold' => [0x1D400, 0x1D41A, 0x1D7CE, 0x1D6A8, 0x1D6C2],
807+ 'bold-italic' => [0x1D468, 0x1D482, 0x1D7CE, 0x1D71C, 0x1D736],
808+ 'double-struck' => [0x1D538, 0x1D552, 0x1D7D8, null, null],
809+ 'script' => [0x1D49C, 0x1D4B6, null, null, null],
810+ 'sans-serif' => [0x1D5A0, 0x1D5BA, 0x1D7E2, null, null],
811+ 'monospace' => [0x1D670, 0x1D68A, 0x1D7F6, null, null],
812+ ][$v] ?? null;
813+ if ($base === null) return $ch;
814+
815+ if ($isUpper && $base[0]) return mb_chr($base[0] + $cp - 0x41, 'UTF-8');
816+ if ($isLower && $base[1]) return mb_chr($base[1] + $cp - 0x61, 'UTF-8');
817+ if ($isDigit && $base[2]) return mb_chr($base[2] + $cp - 0x30, 'UTF-8');
818+ if ($isGreekUpper && $base[3]) return mb_chr($base[3] + $cp - 0x391, 'UTF-8');
819+ if ($isGreekLower && $base[4]) return mb_chr($base[4] + $cp - 0x3B1, 'UTF-8');
820+ return $ch;
821+ }
822+}
@@ -46,7 +46,20 @@
4646 private array $codeBlockContent = [];
4747 private const METADATA_LINE = "----";
4848 private array $algebraicPlaceholder = [];
49+ private array $codePlaceholder = [];
50+ private array $mathErrors = [];
51+ private ?Latex $latex = null;
52+ private static int $tabGroupCount = 0;
4953
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+
5063 public function __construct(string $path, string $section, ?string $lang = null) {
5164 $this->html = [];
5265 if ($lang) $this->path = ROOT.$section.$lang.'/'.$path.'.md';
@@ -115,16 +128,44 @@
115128 $tableHeaders = [];
116129 $tableRows = [];
117130 $inTable = false;
118- $i = 0;
131+ $inMathBlock = false;
132+ $mathLines = [];
133+ $inTabs = false;
134+ $tabItems = [];
135+ $codeBlockLabel = '';
119136 $headerCount = 1;
137+ $this->mathErrors = [];
138+ $i = 0;
120139
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+
121166 foreach ($lines as $line) {
122167 $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");
128169
129170 // コメント
130171 // if (str_starts_with($line, '//')) {
@@ -134,22 +175,28 @@
134175 // continue;
135176 // }
136177
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)) {
140180 if (!$this->inCodeBlock) {
141- if (!empty($currentParagraph)) {
142- $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
143- $currentParagraph = [];
144- }
181+ $flush();
145182 $this->inCodeBlock = true;
146183 $this->codeBlockLanguage = $matches[1];
184+ $codeBlockLabel = $matches[2] ?? '';
147185 continue;
148186 } 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+ }
150196 $this->inCodeBlock = false;
151197 $this->codeBlockLanguage = '';
152198 $this->codeBlockContent = [];
199+ $codeBlockLabel = '';
153200 continue;
154201 }
155202 }
@@ -159,6 +206,51 @@
159206 continue;
160207 }
161208
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+
162254 // テーブルの処理
163255 if (preg_match('/^\|(.+)\|$/', $line)) {
164256 if (!empty($currentParagraph)) {
@@ -292,15 +384,27 @@
292384 }
293385 }
294386
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();
300395
301396 return implode("\n", $this->html);
302397 }
303398
399+ /**
400+ * 直前のparse()で見つかった、変換出来なかったLaTeXコマンド
401+ *
402+ * @return array エラーの配列
403+ */
404+ public function getMathErrors(): array {
405+ return $this->mathErrors;
406+ }
407+
304408 // 機能性メソッド
305409
306410 /**
@@ -311,8 +415,18 @@
311415 */
312416 private function parseInline(string $text): string {
313417 $this->algebraicPlaceholder = [];
418+ $this->codePlaceholder = [];
314419 $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
315420
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+
316430 $patterns = [
317431 // 数式
318432 '/(?<!\\\\)\$\$([^$]+)\$\$/u' => function($matches): string {
@@ -346,26 +460,32 @@
346460 '/(?<!\\\\)\[(.+?)\]\((.+?)\)/u' => '<a href="$2">$1</a>',
347461 // 振り仮名
348462 '/(?<!\\\\)&lt;(.+?)&gt;\((.+?)\)/u' => '<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>',
349- // インラインコード
350- '/(?<!\\\\)`(.+?)`/u' => '<code>$1</code>',
351463 ];
352464
353465 foreach ($patterns as $pattern => $replacement) {
354466 if (is_callable($replacement)) {
355467 $text = preg_replace_callback($pattern, $replacement, $text);
356468 } else {
469+ // kys([$pattern, $replacement, $text]);
357470 $result = preg_replace($pattern, $replacement, $text);
471+ // kys($result);
358472 if ($result === null) continue;
473+ // kys($text);
359474 $text = $result;
360475 }
361476 }
362477
363- // プレースホルダーの数式に交換
478+ // エスケープ文字の\を消す(数式とコードを戻す前に)
479+ $text = preg_replace('/\\\\(.)/u', '$1', $text);
480+
481+ // プレースホルダーを数式とコードに交換
364482 foreach ($this->algebraicPlaceholder as $placeholder => $expr) {
365483 $text = str_replace($placeholder, $this->parseAlgebraic($expr), $text);
366484 }
367485
368- $text = preg_replace('/\\\\(.)/u', '$1', $text);
486+ foreach ($this->codePlaceholder as $placeholder => $code) {
487+ $text = str_replace($placeholder, $code, $text);
488+ }
369489
370490 return $text;
371491 }
@@ -374,92 +494,56 @@
374494 * 数式記法をパースする
375495 *
376496 * @param string $expression パースする数式
377- * @return string MathJax用にフォーマットされた数式
497+ * @param bool $display ブロック表示ならtrue
498+ * @return string MathMLとしてレンダリングされた数式
378499 */
379- private function parseAlgebraic(string $expression): string {
380- // HTMLエスケープ
500+ private function parseAlgebraic(string $expression, bool $display = false): string {
501+ // HTMLエスケープを戻す(Latexクラスが自分でエスケープする)
381502 $expression = htmlspecialchars_decode($expression, ENT_QUOTES);
382503
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;
398507
399- // ギリシャ文字を置換
400- foreach ($greekLetters as $text => $symbol) {
401- $expression = preg_replace("/\b$text\b/", $symbol, $expression);
402- }
508+ return $mathml;
509+ }
403510
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+ }
409520
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 '';
418529
419- foreach ($superscripts as $char) $converted .= $supMap[$char] ?? $char;
530+ $group = 'code-tabs-'.(++self::$tabGroupCount);
531+ $inputs = '';
532+ $panels = '';
420533
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']);
423540
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+ }
428545
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>";
463547 }
464548
465549 /**
@@ -480,8 +564,11 @@
480564 foreach ($items as $item) {
481565 $level = isset($item['level']) ? $item['level'] : $currentLevel;
482566
483- while ($currentLevel > $level)
567+ // ネストから戻る時はレベルを下げる(下げないと無限ループになる)
568+ while ($currentLevel > $level) {
484569 $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
570+ $currentLevel--;
571+ }
485572
486573 while ($currentLevel < $level) {
487574 $currentLevel++;
@@ -520,19 +607,27 @@
520607 * @return string HTMLのコードブロック
521608 */
522609 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);
525623 $attr = htmlspecialchars($lang, ENT_QUOTES, 'UTF-8');
526624
527- // 対応している言語はハイライトする(Syntax::colorize()はエスケープ済みのHTMLを返す)
625+ // Syntax::colorize()はエスケープ済みのHTMLを返す
528626 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\""];
531628 }
532629
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}\"" : ''];
536631 }
537632
538633 private function createBlockquote(array $content): string {
@@ -569,127 +664,9 @@
569664 $html .= " </tr>\n";
570665 }
571666 $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'] = [['/&lt;\/?[a-zA-Z0-9\-]+/', 'hl-tag']];
611- $rules['attr'] = [['/\s[a-zA-Z0-9\-]+(?==)/', 'hl-attr']];
612- $rules['value'] = [['/(?<==)"(?:\\.|[^"\\\n])*"(?=[>\s])/', 'hl-value']];
613667 }
614668
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>";
693670 return $html;
694671 }
695672 }
@@ -262,7 +262,105 @@
262262
263263 $test->assertEquals($expect, $actual);
264264 });
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">// &lt;b&gt;</span></code>', $body);
360+ // 未対応の言語はエスケープだけ
361+ $test->assertStringContains('<code class="language-sh">echo &quot;&lt;b&gt;&quot;</code>', $body);
362+ });
363+
266364 rmfile();
267365 });
268366
変更 util.php
@@ -36,6 +36,7 @@
3636 OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
3737 */
3838 if (!CURL_ENABLED) define('ACTIVITYPUB_ENABLED', false);
39+foreach (['MAILER_ENABLED', 'OPENPROVIDER_ENABLED'] as $c) if (!defined($c)) define($c, false);
3940
4041 enum LogType {
4142 case ActivityPub;