| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, and/or sell copies of the Software, subject to the following conditions: |
| 10 | |
| 11 | 1. **Origin Attribution**: |
| 12 | - You must not misrepresent the origin of the Software; you must not claim |
| 13 | you created the original Software. |
| 14 | - If the Software is used in a product, you must either: |
| 15 | a. Provide clear attribution in the product's documentation, user interface, |
| 16 | or other visible areas, **OR** |
| 17 | b. Pay the original developers a fee they specify in writing. |
| 18 | 2. **Usage Restriction**: |
| 19 | - The Software, or any derivative works, dependencies, or libraries |
| 20 | incorporating it, must not be used for censorship or to suppress freedom of |
| 21 | speech, expression, or creativity. Prohibited uses include, but are not |
| 22 | limited to: |
| 23 | - Censorship of so-called "hate speech", visuals, non-mainstream opinions, |
| 24 | ideas, or objective reality. |
| 25 | - Tools or systems designed to restrict access to information or |
| 26 | artistic works. |
| 27 | 3. **Notice Preservation**: |
| 28 | - This license and the above copyright notice must remain intact in all copies |
| 29 | of the source code. |
| 30 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib; |
| 39 | |
| 40 | class Markdown { |
| 41 | private string $content; |
| 42 | private array $html; |
| 43 | private string $path; |
| 44 | private bool $inCodeBlock = false; |
| 45 | private string $codeBlockLanguage = ''; |
| 46 | private array $codeBlockContent = []; |
| 47 | private const METADATA_LINE = "----"; |
| 48 | private array $algebraicPlaceholder = []; |
| 49 | private array $codePlaceholder = []; |
| 50 | private array $mathErrors = []; |
| 51 | private ?Latex $latex = null; |
| 52 | private static int $tabGroupCount = 0; |
| 53 | /** fromString()の文字列(メタデータ無し) */ |
| 54 | private ?string $text = null; |
| 55 | |
| 56 | // コードタブのラベル / labels for code tabs |
| 57 | private const LANG_LABELS = [ |
| 58 | 'c' => 'C', 'cpp' => 'C++', 'cc' => 'C++', 'hpp' => 'C++', 'cs' => 'C#', |
| 59 | 'slang' => 'Slang', 'glsl' => 'GLSL', 'hlsl' => 'HLSL', 'msl' => 'MSL', |
| 60 | 'wgsl' => 'WGSL', 'spirv' => 'SPIR-V', 'asm' => 'Assembly', 'php' => 'PHP', |
| 61 | 'js' => 'JavaScript', 'sh' => 'Shell', 'txt' => 'Text', 'json' => 'JSON', |
| 62 | 'go' => 'Go', 'rust' => 'Rust', 'zig' => 'Zig', 'odin' => 'Odin', 'sql' => 'SQL', |
| 63 | ]; |
| 64 | |
| 65 | public function __construct(string $path, string $section, ?string $lang = null) { |
| 66 | $this->html = []; |
| 67 | if ($lang) $this->path = ROOT.$section.$lang.'/'.$path.'.md'; |
| 68 | else $this->path = ROOT.$section.$path.'.md'; |
| 69 | |
| 70 | if (!file_exists($this->path)) { |
| 71 | header('Location: /404'); |
| 72 | exit(); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * 文字列から(READMEファイル等、メタデータ無し) |
| 78 | * |
| 79 | * @param string $text Markdown |
| 80 | * @return self |
| 81 | */ |
| 82 | public static function fromString(string $text): self { |
| 83 | $m = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); |
| 84 | $m->html = []; |
| 85 | $m->path = ''; |
| 86 | $m->text = $text; |
| 87 | return $m; |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * メタデータを取得する |
| 92 | * |
| 93 | * @return \stdClass メタデータオブジェクト |
| 94 | */ |
| 95 | public function getMetadata(): \stdClass { |
| 96 | $content = file_get_contents($this->path); |
| 97 | $metadata = new \stdClass(); |
| 98 | |
| 99 | $parts = explode(self::METADATA_LINE, $content, 2); |
| 100 | if (count($parts) < 2) return $metadata; |
| 101 | |
| 102 | $lines = explode("\n", trim($parts[0])); |
| 103 | foreach ($lines as $line) { |
| 104 | $line = trim($line); |
| 105 | if (empty($line)) continue; |
| 106 | |
| 107 | $colonPos = strpos($line, ':'); |
| 108 | if ($colonPos === false) continue; |
| 109 | |
| 110 | $key = trim(substr($line, 0, $colonPos)); |
| 111 | $value = trim(substr($line, $colonPos + 1)); |
| 112 | $value = trim($value, '"\''); |
| 113 | |
| 114 | if ($key == 'category' || $key == 'css') { |
| 115 | $cat = explode(',', $value); |
| 116 | $value = $cat; |
| 117 | } |
| 118 | |
| 119 | $metadata->$key = $value; |
| 120 | } |
| 121 | |
| 122 | return $metadata; |
| 123 | } |
| 124 | |
| 125 | /** |
| 126 | * Markdownをパースする |
| 127 | * |
| 128 | * @return string HTMLとしてレンダリングされたコンテンツ |
| 129 | */ |
| 130 | public function parse(): string { |
| 131 | if ($this->text !== null) { |
| 132 | $this->content = trim(str_replace(["\r\n", "\r"], "\n", $this->text)); |
| 133 | } else { |
| 134 | $content = file_get_contents($this->path); |
| 135 | $parts = explode(self::METADATA_LINE, $content, 2); |
| 136 | $this->content = count($parts) > 1 ? trim($parts[1]): trim($content); |
| 137 | } |
| 138 | $this->html = []; |
| 139 | |
| 140 | $lines = self::removeComments(explode("\n", $this->content)); |
| 141 | $currentParagraph = []; |
| 142 | |
| 143 | $inList = false; |
| 144 | $listItems = []; |
| 145 | $listLevel = 0; |
| 146 | $inBlockquote = false; |
| 147 | $blockquoteContent = []; |
| 148 | $tableHeaders = []; |
| 149 | $tableRows = []; |
| 150 | $inTable = false; |
| 151 | $inMathBlock = false; |
| 152 | $mathLines = []; |
| 153 | $inTabs = false; |
| 154 | $tabItems = []; |
| 155 | $codeBlockLabel = ''; |
| 156 | $headerCount = 1; |
| 157 | $this->mathErrors = []; |
| 158 | $i = 0; |
| 159 | |
| 160 | $flush = function() use (&$currentParagraph, &$inList, &$listItems, &$listLevel, |
| 161 | &$inTable, &$tableHeaders, &$tableRows, |
| 162 | &$inBlockquote, &$blockquoteContent): void { |
| 163 | if ($inList) { |
| 164 | $this->html[] = $this->createList($listItems); |
| 165 | $listItems = []; |
| 166 | $inList = false; |
| 167 | $listLevel = 0; |
| 168 | } |
| 169 | if ($inTable) { |
| 170 | $this->html[] = $this->createTable($tableHeaders, $tableRows); |
| 171 | $tableHeaders = []; |
| 172 | $tableRows = []; |
| 173 | $inTable = false; |
| 174 | } |
| 175 | if ($inBlockquote) { |
| 176 | $this->html[] = $this->createBlockquote($blockquoteContent); |
| 177 | $blockquoteContent = []; |
| 178 | $inBlockquote = false; |
| 179 | } |
| 180 | if (!empty($currentParagraph)) { |
| 181 | $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>"; |
| 182 | $currentParagraph = []; |
| 183 | } |
| 184 | }; |
| 185 | |
| 186 | foreach ($lines as $line) { |
| 187 | $i++; |
| 188 | $rawLine = rtrim($line, "\r\n"); |
| 189 | |
| 190 | // コードブロック(```言語 ラベル) |
| 191 | if (preg_match('/^```([\w\+\-#\.]*)(?:\s+(.*\S))?\s*$/u', $rawLine, $matches)) { |
| 192 | if (!$this->inCodeBlock) { |
| 193 | $flush(); |
| 194 | $this->inCodeBlock = true; |
| 195 | $this->codeBlockLanguage = $matches[1]; |
| 196 | $codeBlockLabel = $matches[2] ?? ''; |
| 197 | continue; |
| 198 | } else { |
| 199 | if ($inTabs) { |
| 200 | $tabItems[] = [ |
| 201 | 'lang' => $this->codeBlockLanguage, |
| 202 | 'label' => $codeBlockLabel, |
| 203 | 'code' => $this->codeBlockContent, |
| 204 | ]; |
| 205 | } else { |
| 206 | $this->html[] = $this->createCodeBlock(); |
| 207 | } |
| 208 | $this->inCodeBlock = false; |
| 209 | $this->codeBlockLanguage = ''; |
| 210 | $this->codeBlockContent = []; |
| 211 | $codeBlockLabel = ''; |
| 212 | continue; |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | if ($this->inCodeBlock) { |
| 217 | $this->codeBlockContent[] = $rawLine; |
| 218 | continue; |
| 219 | } |
| 220 | |
| 221 | if ($inMathBlock) { |
| 222 | if (trim($rawLine) === '$$') { |
| 223 | $this->html[] = $this->createDisplayMath(implode("\n", $mathLines)); |
| 224 | $mathLines = []; |
| 225 | $inMathBlock = false; |
| 226 | } else { |
| 227 | $mathLines[] = $rawLine; |
| 228 | } |
| 229 | continue; |
| 230 | } |
| 231 | |
| 232 | if (trim($rawLine) === '$$') { |
| 233 | $flush(); |
| 234 | $inMathBlock = true; |
| 235 | continue; |
| 236 | } |
| 237 | |
| 238 | // 1行の数式ブロック:$$…$$だけの行 / a line holding only $$…$$ is display math |
| 239 | if (preg_match('/^\s*\$\$(.+)\$\$\s*$/u', $rawLine, $matches) |
| 240 | && substr_count($rawLine, '$$') === 2) { |
| 241 | $flush(); |
| 242 | $this->html[] = $this->createDisplayMath($matches[1]); |
| 243 | continue; |
| 244 | } |
| 245 | |
| 246 | // コードタブ(:::tabs … :::) / code tabs |
| 247 | if (preg_match('/^:::\s*tabs\s*$/', trim($rawLine))) { |
| 248 | $flush(); |
| 249 | $inTabs = true; |
| 250 | $tabItems = []; |
| 251 | continue; |
| 252 | } |
| 253 | |
| 254 | if ($inTabs) { |
| 255 | if (trim($rawLine) === ':::') { |
| 256 | $this->html[] = $this->createTabs($tabItems); |
| 257 | $tabItems = []; |
| 258 | $inTabs = false; |
| 259 | } |
| 260 | continue; // タブの中ではコードブロック以外は無視 / only code blocks inside tabs |
| 261 | } |
| 262 | |
| 263 | $hasBR = str_ends_with($rawLine, '\\'); |
| 264 | $line = rtrim($rawLine, "\\"); |
| 265 | |
| 266 | // テーブルの処理 |
| 267 | if (preg_match('/^\|(.+)\|$/', $line)) { |
| 268 | if (!empty($currentParagraph)) { |
| 269 | $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>"; |
| 270 | $currentParagraph = []; |
| 271 | } |
| 272 | $cells = array_map('trim', explode('|', trim($line, '|'))); |
| 273 | |
| 274 | if (!$inTable) { |
| 275 | $tableHeaders = $cells; |
| 276 | $inTable = true; |
| 277 | } elseif (preg_match('/^\|(\s*:?-+:?\s*\|)+$/', $line)) { |
| 278 | continue; |
| 279 | } else { |
| 280 | $tableRows[] = $cells; |
| 281 | } |
| 282 | continue; |
| 283 | } elseif ($inTable) { |
| 284 | $this->html[] = $this->createTable($tableHeaders, $tableRows); |
| 285 | $tableHeaders = []; |
| 286 | $tableRows = []; |
| 287 | $inTable = false; |
| 288 | } |
| 289 | |
| 290 | // 水平線の処理 |
| 291 | if (preg_match('/^([\-\*\_])\1{2,}$/', $line)) { |
| 292 | if (!empty($currentParagraph)) { |
| 293 | $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>"; |
| 294 | $currentParagraph = []; |
| 295 | } |
| 296 | $this->html[] = " <hr />\n"; |
| 297 | continue; |
| 298 | } |
| 299 | |
| 300 | // 引用ブロックの処理 |
| 301 | if (preg_match('/^>\s(.+)/', $line, $matches)) { |
| 302 | if (!empty($currentParagraph)) { |
| 303 | $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>"; |
| 304 | $currentParagraph = []; |
| 305 | } |
| 306 | $inBlockquote = true; |
| 307 | $blockquoteContent[] = $this->parseInline($matches[1]); |
| 308 | continue; |
| 309 | } elseif ($inBlockquote && empty($line)) { |
| 310 | $this->html[] = $this->createBlockquote($blockquoteContent); |
| 311 | $blockquoteContent = []; |
| 312 | $inBlockquote = false; |
| 313 | continue; |
| 314 | } |
| 315 | |
| 316 | // 空行をスキップ |
| 317 | if (empty($line)) { |
| 318 | if ($inList) { |
| 319 | $this->html[] = $this->createList($listItems); |
| 320 | $listItems = []; |
| 321 | $inList = false; |
| 322 | } |
| 323 | if (!empty($currentParagraph)) { |
| 324 | $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>"; |
| 325 | $currentParagraph = []; |
| 326 | } |
| 327 | |
| 328 | continue; |
| 329 | } |
| 330 | |
| 331 | // ヘッダー |
| 332 | if (preg_match('/^(#{1,6})\s(.+)/', $line, $m)) { |
| 333 | if ($inList) { |
| 334 | $this->html[] = $this->createList($listItems); |
| 335 | $listItems = []; |
| 336 | $inList = false; |
| 337 | } |
| 338 | if (!empty($currentParagraph)) { |
| 339 | $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>"; |
| 340 | $currentParagraph = []; |
| 341 | } |
| 342 | |
| 343 | $level = strlen($m[1]); |
| 344 | $this->html[] = " <h{$level} id=\"section-{$headerCount}\">".$this->parseInline($m[2])."</h{$level}>"; |
| 345 | $headerCount++; |
| 346 | continue; |
| 347 | } |
| 348 | |
| 349 | // 箇条書きリスト |
| 350 | if (preg_match('/^(\s*)([\*\-])\s(.+)/', $line, $m)) { |
| 351 | if (!empty($currentParagraph)) { |
| 352 | $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>"; |
| 353 | $currentParagraph = []; |
| 354 | } |
| 355 | $inList = true; |
| 356 | $currentLevel = strlen($m[1]) / 2; |
| 357 | $listLevel = max($listLevel, $currentLevel); |
| 358 | $listItems[] = [ |
| 359 | 'content' => $this->parseInline($m[3]), |
| 360 | 'level' => $currentLevel, |
| 361 | 'type' => 'ul', |
| 362 | ]; |
| 363 | |
| 364 | continue; |
| 365 | } |
| 366 | |
| 367 | // 番号付きリスト |
| 368 | if (preg_match('/^(\s*)\d+\.\s(.+)/', $line, $m)) { |
| 369 | if (!empty($currentParagraph)) { |
| 370 | $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>"; |
| 371 | $currentParagraph = []; |
| 372 | } |
| 373 | $inList = true; |
| 374 | $currentLevel = strlen($m[1]) / 2; |
| 375 | $listLevel = max($listLevel, $currentLevel); |
| 376 | $listItems[] = [ |
| 377 | 'content' => $this->parseInline($m[2]), |
| 378 | 'level' => $currentLevel, |
| 379 | 'type' => 'ol', |
| 380 | ]; |
| 381 | |
| 382 | continue; |
| 383 | } |
| 384 | |
| 385 | if ($inList) { |
| 386 | $this->html[] = $this->createList($listItems); |
| 387 | $listItems = []; |
| 388 | $inList = false; |
| 389 | $listLevel = 0; |
| 390 | } |
| 391 | |
| 392 | $parsedLine = $this->parseInline($line); |
| 393 | $currentParagraph[] = $parsedLine; |
| 394 | if ($hasBR) { |
| 395 | $currentParagraph[] = "<br />\n "; |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | if ($this->inCodeBlock) { |
| 400 | $this->html[] = $this->createCodeBlock(); |
| 401 | $this->inCodeBlock = false; |
| 402 | $this->codeBlockContent = []; |
| 403 | } |
| 404 | if ($inMathBlock) $this->html[] = $this->createDisplayMath(implode("\n", $mathLines)); |
| 405 | if ($inTabs) $this->html[] = $this->createTabs($tabItems); |
| 406 | $flush(); |
| 407 | |
| 408 | return implode("\n", $this->html); |
| 409 | } |
| 410 | |
| 411 | /** |
| 412 | * コメントを取り除く(parse()の前処理。プレビューや検索にも使う) |
| 413 | * |
| 414 | * 「//」で始まる行と、「/*」で始まる行から「*/」までは無い物として扱う。 |
| 415 | * 「*/」の後に文字があれば、その部分は残す。閉じられていない「/*」は最後までコメント。 |
| 416 | * コードブロックの中はそのまま。 |
| 417 | * |
| 418 | * @param array $lines 行の配列 |
| 419 | * @return array コメントを取り除いた行の配列 |
| 420 | */ |
| 421 | public static function removeComments(array $lines): array { |
| 422 | $out = []; |
| 423 | $inCode = false; |
| 424 | $inComment = false; |
| 425 | |
| 426 | foreach ($lines as $line) { |
| 427 | $raw = rtrim($line, "\r\n"); |
| 428 | |
| 429 | if (!$inComment && preg_match('/^```([\w\+\-#\.]*)(?:\s+(.*\S))?\s*$/u', $raw)) { |
| 430 | $inCode = !$inCode; |
| 431 | $out[] = $line; |
| 432 | continue; |
| 433 | } |
| 434 | if ($inCode) { |
| 435 | $out[] = $line; |
| 436 | continue; |
| 437 | } |
| 438 | |
| 439 | // 1行に「/* … */ /* … */ 本文」の様に続く場合もあるので、繰り返す |
| 440 | $rest = $raw; |
| 441 | while (true) { |
| 442 | if ($inComment) { |
| 443 | $end = strpos($rest, '*/'); |
| 444 | if ($end === false) { |
| 445 | $rest = null; |
| 446 | break; |
| 447 | } |
| 448 | $inComment = false; |
| 449 | $rest = ltrim(substr($rest, $end + 2)); |
| 450 | if ($rest === '') { |
| 451 | $rest = null; |
| 452 | break; |
| 453 | } |
| 454 | continue; |
| 455 | } |
| 456 | |
| 457 | $trimmed = ltrim($rest); |
| 458 | if (str_starts_with($trimmed, '//')) { |
| 459 | $rest = null; |
| 460 | } else if (str_starts_with($trimmed, '/*')) { |
| 461 | $inComment = true; |
| 462 | $rest = substr($trimmed, 2); |
| 463 | continue; |
| 464 | } |
| 465 | break; |
| 466 | } |
| 467 | |
| 468 | if ($rest !== null) $out[] = $rest === $raw ? $line : $rest; |
| 469 | } |
| 470 | |
| 471 | return $out; |
| 472 | } |
| 473 | |
| 474 | /** |
| 475 | * テキストからコメントを取り除く(removeComments()の文字列版) |
| 476 | * |
| 477 | * @param string $text Markdownのテキスト |
| 478 | * @return string |
| 479 | */ |
| 480 | public static function stripComments(string $text): string { |
| 481 | return implode("\n", self::removeComments(explode("\n", str_replace("\r\n", "\n", $text)))); |
| 482 | } |
| 483 | |
| 484 | /** |
| 485 | * 直前のparse()で見つかった、変換出来なかったLaTeXコマンド |
| 486 | * |
| 487 | * @return array エラーの配列 |
| 488 | */ |
| 489 | public function getMathErrors(): array { |
| 490 | return $this->mathErrors; |
| 491 | } |
| 492 | |
| 493 | // 機能性メソッド |
| 494 | |
| 495 | /** |
| 496 | * インラインのMarkdown記法をパースする |
| 497 | * |
| 498 | * @param string $text パースするテキスト |
| 499 | * @return string HTMLとしてレンダリングされたテキスト |
| 500 | */ |
| 501 | private function parseInline(string $text): string { |
| 502 | $this->algebraicPlaceholder = []; |
| 503 | $this->codePlaceholder = []; |
| 504 | $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); |
| 505 | |
| 506 | // インラインコードは他の記法より先に保護する(中の*や_や\をそのまま表示する為) |
| 507 | $text = preg_replace_callback('/(?<!\\\\)`(.+?)`/u', function($matches): string { |
| 508 | $placeholder = "{{CODE".count($this->codePlaceholder).'CODE}}'; |
| 509 | // 記号の前の\だけをエスケープとして消す(`json\_encode`や`C:\\`の為)。\nや\0はそのまま。 |
| 510 | $code = preg_replace('/\\\\([\\\\`*_~$%^!\[\]()#{}|.+\-])/u', '$1', $matches[1]); |
| 511 | $this->codePlaceholder[$placeholder] = '<code>'.$code.'</code>'; |
| 512 | return $placeholder; |
| 513 | }, $text); |
| 514 | |
| 515 | $patterns = [ |
| 516 | // 数式 |
| 517 | '/(?<!\\\\)\$\$([^$]+)\$\$/u' => function($matches): string { |
| 518 | $placeholder = "{{ALG".count($this->algebraicPlaceholder).'ALG}}'; |
| 519 | $this->algebraicPlaceholder[$placeholder] = $matches[1]; |
| 520 | return $placeholder; |
| 521 | }, |
| 522 | // 太字 |
| 523 | '/(?<!\\\\)\*\*(.+?)\*\*/u' => '<strong>$1</strong>', |
| 524 | // 斜体 |
| 525 | '/(?<!\\\\)\*(.+?)\*/u' => '<em>$1</em>', |
| 526 | // 下線 |
| 527 | '/(?<!\\\\)_(.+?)_/u' => '<u>$1</u>', |
| 528 | // 取り消し線 |
| 529 | '/(?<!\\\\)~(.+?)~/u' => '<s>$1</s>', |
| 530 | // Blink (with speed) |
| 531 | '/(?<!\\\\)!:\((.+?)\)(.+?):!/u' => '<span class="blink" style="animation-duration: $1s;">$2</span>', |
| 532 | // Blink |
| 533 | '/(?<!\\\\)!:(.+?):!/u' => '<span class="blink">$1</span>', |
| 534 | // フォントの大きさ |
| 535 | '/(?<!\\\\)\^\((.+?)\)(.+?)\^/u' => '<span style="font-size: $1px;">$2</span>', |
| 536 | // フォントカラー |
| 537 | '/(?<!\\\\)\%\((.+?)\)(.+?)\%/u' => '<span style="color: #$1;">$2</span>', |
| 538 | // 画像 |
| 539 | '/(?<!\\\\)\!\[(.*?)(?:#([^\]]*))?\]\((.+?)\)/u' => '<img style="width: 100%; $2" src="$3" alt="$1" />', |
| 540 | // 音楽 |
| 541 | '/(?<!\\\\)\$\[([^\]]+)\]\(([^\)]+)\)/u' => '<audio controls><source src="$2" type="$1" /></audio>', |
| 542 | // 動画 |
| 543 | '/(?<!\\\\)\#\[([^\]]+)\]\(([^\)]+)\)/u' => '<video style="max-width: 100%;" controls><source src="$2" type="$1" /></video>', |
| 544 | // リンク |
| 545 | '/(?<!\\\\)\[(.+?)\]\((.+?)\)/u' => '<a href="$2">$1</a>', |
| 546 | // 振り仮名 |
| 547 | '/(?<!\\\\)<(.+?)>\((.+?)\)/u' => '<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>', |
| 548 | ]; |
| 549 | |
| 550 | foreach ($patterns as $pattern => $replacement) { |
| 551 | if (is_callable($replacement)) { |
| 552 | $text = preg_replace_callback($pattern, $replacement, $text); |
| 553 | } else { |
| 554 | // kys([$pattern, $replacement, $text]); |
| 555 | $result = preg_replace($pattern, $replacement, $text); |
| 556 | // kys($result); |
| 557 | if ($result === null) continue; |
| 558 | // kys($text); |
| 559 | $text = $result; |
| 560 | } |
| 561 | } |
| 562 | |
| 563 | // エスケープ文字の\を消す(数式とコードを戻す前に) |
| 564 | $text = preg_replace('/\\\\(.)/u', '$1', $text); |
| 565 | |
| 566 | // プレースホルダーを数式とコードに交換 |
| 567 | foreach ($this->algebraicPlaceholder as $placeholder => $expr) { |
| 568 | $text = str_replace($placeholder, $this->parseAlgebraic($expr), $text); |
| 569 | } |
| 570 | |
| 571 | foreach ($this->codePlaceholder as $placeholder => $code) { |
| 572 | $text = str_replace($placeholder, $code, $text); |
| 573 | } |
| 574 | |
| 575 | return $text; |
| 576 | } |
| 577 | |
| 578 | /** |
| 579 | * 数式記法をパースする |
| 580 | * |
| 581 | * @param string $expression パースする数式 |
| 582 | * @param bool $display ブロック表示ならtrue |
| 583 | * @return string MathMLとしてレンダリングされた数式 |
| 584 | */ |
| 585 | private function parseAlgebraic(string $expression, bool $display = false): string { |
| 586 | // HTMLエスケープを戻す(Latexクラスが自分でエスケープする) |
| 587 | $expression = htmlspecialchars_decode($expression, ENT_QUOTES); |
| 588 | |
| 589 | if ($this->latex === null) $this->latex = new Latex(); |
| 590 | $mathml = $this->latex->render($expression, $display); |
| 591 | foreach ($this->latex->getErrors() as $err) $this->mathErrors[] = $err; |
| 592 | |
| 593 | return $mathml; |
| 594 | } |
| 595 | |
| 596 | /** |
| 597 | * ブロック表示の数式を作成する |
| 598 | * |
| 599 | * @param string $expression LaTeXの数式 |
| 600 | * @return string HTMLの数式ブロック |
| 601 | */ |
| 602 | private function createDisplayMath(string $expression): string { |
| 603 | return " <div class=\"math-display\">".$this->parseAlgebraic($expression, true)."</div>"; |
| 604 | } |
| 605 | |
| 606 | /** |
| 607 | * コードタブを作成する(JavaScript無し、ラジオボタンとCSSだけ) |
| 608 | * |
| 609 | * @param array $items ['lang' => 言語, 'label' => ラベル, 'code' => 行の配列]の配列 |
| 610 | * @return string HTMLのタブ |
| 611 | */ |
| 612 | private function createTabs(array $items): string { |
| 613 | if (empty($items)) return ''; |
| 614 | |
| 615 | $group = 'code-tabs-'.(++self::$tabGroupCount); |
| 616 | $inputs = ''; |
| 617 | $panels = ''; |
| 618 | |
| 619 | foreach ($items as $n => $item) { |
| 620 | $id = "{$group}-{$n}"; |
| 621 | $label = $item['label'] !== '' ? $item['label'] |
| 622 | : (self::LANG_LABELS[strtolower($item['lang'])] ?? ($item['lang'] !== '' ? $item['lang'] : 'Code')); |
| 623 | $checked = $n === 0 ? ' checked="checked"' : ''; |
| 624 | [$code, $class] = $this->renderCode(implode("\n", $item['code']), $item['lang']); |
| 625 | |
| 626 | $inputs .= " <input type=\"radio\" name=\"{$group}\" id=\"{$id}\"{$checked} />\n"; |
| 627 | $inputs .= " <label for=\"{$id}\">".htmlspecialchars($label)."</label>\n"; |
| 628 | $panels .= " <div class=\"code-tab-panel\"><pre><code{$class}>{$code}</code></pre></div>\n"; |
| 629 | } |
| 630 | |
| 631 | return " <div class=\"code-tabs\">\n{$inputs}{$panels} </div>"; |
| 632 | } |
| 633 | |
| 634 | /** |
| 635 | * リストを作成する |
| 636 | * |
| 637 | * @param array $items リストアイテムの配列 |
| 638 | * @param int $maxLevel 最大ネストレベル |
| 639 | * @return string HTMLのリスト |
| 640 | */ |
| 641 | private function createList(array $items, int $maxLevel = 1): string { |
| 642 | if ($items === []) return ''; |
| 643 | |
| 644 | $html = ''; |
| 645 | $currentLevel = 0; |
| 646 | $listStack = []; |
| 647 | $currentType = ''; |
| 648 | |
| 649 | foreach ($items as $item) { |
| 650 | $level = isset($item['level']) ? $item['level'] : $currentLevel; |
| 651 | |
| 652 | // ネストから戻る時はレベルを下げる(下げないと無限ループになる) |
| 653 | while ($currentLevel > $level) { |
| 654 | $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n"; |
| 655 | $currentLevel--; |
| 656 | } |
| 657 | |
| 658 | while ($currentLevel < $level) { |
| 659 | $currentLevel++; |
| 660 | $listStack[] = $item['type']; |
| 661 | $html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n"; |
| 662 | } |
| 663 | |
| 664 | if ($currentType != $item['type'] && $currentLevel == $item['level']) { |
| 665 | if (!empty($listStack)) { |
| 666 | $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n"; |
| 667 | $listStack[] = $item['type']; |
| 668 | $html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n"; |
| 669 | } |
| 670 | } |
| 671 | |
| 672 | $currentType = $item['type']; |
| 673 | } |
| 674 | |
| 675 | $html .= " <p>\n <{$currentType}>\n"; |
| 676 | foreach ($items as $item) { |
| 677 | $html .= str_repeat(' ', $currentLevel)." <li>".$item['content']."</li>\n"; |
| 678 | } |
| 679 | $html .= " </{$currentType}>\n </p>"; |
| 680 | |
| 681 | while (!empty($listStack)) { |
| 682 | $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n"; |
| 683 | $currentLevel--; |
| 684 | } |
| 685 | |
| 686 | return rtrim($html); |
| 687 | } |
| 688 | |
| 689 | /** |
| 690 | * コードブロックを作成する |
| 691 | * |
| 692 | * @return string HTMLのコードブロック |
| 693 | */ |
| 694 | private function createCodeBlock(): string { |
| 695 | [$code, $class] = $this->renderCode(implode("\n", $this->codeBlockContent), $this->codeBlockLanguage); |
| 696 | return " <p>\n <pre><code{$class}>{$code}</code></pre>"; |
| 697 | } |
| 698 | |
| 699 | /** |
| 700 | * コードをHTMLにする(対応している言語はシンタックスハイライトする) |
| 701 | * |
| 702 | * @param string $raw 生のコード |
| 703 | * @param string $lang 言語名(```の後) |
| 704 | * @return array [エスケープ済みのコード, class属性] |
| 705 | */ |
| 706 | private function renderCode(string $raw, string $lang): array { |
| 707 | $lang = strtolower($lang); |
| 708 | $attr = htmlspecialchars($lang, ENT_QUOTES, 'UTF-8'); |
| 709 | |
| 710 | // Syntax::colorize()はエスケープ済みのHTMLを返す |
| 711 | if ($lang !== '' && Syntax::isSupported($lang)) { |
| 712 | return [(new Syntax($lang))->colorize($raw), " class=\"language-{$attr} hl\""]; |
| 713 | } |
| 714 | |
| 715 | return [htmlspecialchars($raw), $lang !== '' ? " class=\"language-{$attr}\"" : '']; |
| 716 | } |
| 717 | |
| 718 | private function createBlockquote(array $content): string { |
| 719 | return " <p>\n <blockquote>\n ".implode("<br />\n ", $content)."\n </blockquote>\n </p>"; |
| 720 | } |
| 721 | |
| 722 | /** |
| 723 | * テーブルを作成する |
| 724 | * |
| 725 | * @param array $headers ヘッダー配列 |
| 726 | * @param array $rows 行データの配列 |
| 727 | * @return string HTMLのテーブル |
| 728 | */ |
| 729 | private function createTable(array $headers, array $rows): string { |
| 730 | $html = " <p>\n <table>\n"; |
| 731 | |
| 732 | // ヘッダーを追加 |
| 733 | if (!empty($headers)) { |
| 734 | $html .= " <thead>\n <tr>\n"; |
| 735 | foreach ($headers as $header) { |
| 736 | $html .= " <th>".$this->parseInline($header)."</th>\n"; |
| 737 | } |
| 738 | $html .= " </tr>\n </thead>\n"; |
| 739 | } |
| 740 | |
| 741 | // 行を追加 |
| 742 | if (!empty($rows)) { |
| 743 | $html .= " <tbody>\n"; |
| 744 | foreach ($rows as $row) { |
| 745 | $html .= " <tr>\n"; |
| 746 | foreach ($row as $cell) { |
| 747 | $html .= " <td>".$this->parseInline($cell)."</td>\n"; |
| 748 | } |
| 749 | $html .= " </tr>\n"; |
| 750 | } |
| 751 | $html .= " </tbody>\n"; |
| 752 | } |
| 753 | |
| 754 | $html .= " </table>\n </p>"; |
| 755 | return $html; |
| 756 | } |
| 757 | } |