register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handle_ajax_call'); $controller->register_hook('TPL_CONTENT_DISPLAY', 'BEFORE', $this, 'handle_tpl_content_display'); } public function handle_tpl_content_display(Event $event, $_param) { if (!is_string($event->data)) return; $html = $event->data; if (strpos($html, 'mizarWrapper') !== false && strpos($html, 'id="hideAllButton"') === false) { $buttonHtml = '
' . '' . '' . '' . '
'; $event->data = $buttonHtml . $html; } } public function handle_ajax_call(Event $event, $param) { unset($param); switch ($event->data) { case 'clear_temp_files': $event->preventDefault(); $event->stopPropagation(); $this->clearTempFiles(); break; case 'source_compile': $event->preventDefault(); $event->stopPropagation(); $this->handleSourceCompileRequest(); break; case 'source_sse': $event->preventDefault(); $event->stopPropagation(); $this->handleSourceSSERequest(); break; case 'view_compile': $event->preventDefault(); $event->stopPropagation(); $this->handleViewCompileRequest(); break; case 'view_sse': $event->preventDefault(); $event->stopPropagation(); $this->handleViewSSERequest(); break; case 'create_combined_file': $event->preventDefault(); $event->stopPropagation(); $this->handle_create_combined_file(); break; case 'view_graph': $event->preventDefault(); $event->stopPropagation(); $this->handleViewGraphRequest(); break; } } /* ===================== Helpers ===================== */ private function isWindows(): bool { return strncasecmp(PHP_OS, 'WIN', 3) === 0; } /** 設定→未設定なら htdocs(= DOKU_INC の1つ上) 相対にフォールバックして正規化 */ private function resolvePaths(): array { // DokuWiki ルート(末尾スラッシュなしに正規化) $dokuroot = rtrim(realpath(DOKU_INC), '/\\'); // htdocs を「dokuwiki の 1つ上」から求める $htdocs = realpath($dokuroot . DIRECTORY_SEPARATOR . '..'); if ($htdocs === false) $htdocs = dirname($dokuroot); $defM = $htdocs . DIRECTORY_SEPARATOR . 'MIZAR'; $defW = $htdocs . DIRECTORY_SEPARATOR . 'work'; // 設定値取得(空ならフォールバック)。相対指定が来たら htdocs 基準に解決 $exe = trim((string)$this->getConf('mizar_exe_dir')); $share = trim((string)$this->getConf('mizar_share_dir')); $work = trim((string)$this->getConf('mizar_work_dir')); $isAbs = function(string $p): bool { // Windowsドライブ/UNC/Unix ざっくり対応 return $p !== '' && (preg_match('~^[A-Za-z]:[\\/]|^\\\\\\\\|^/~', $p) === 1); }; if ($exe !== '' && !$isAbs($exe)) $exe = $htdocs . DIRECTORY_SEPARATOR . $exe; if ($share !== '' && !$isAbs($share)) $share = $htdocs . DIRECTORY_SEPARATOR . $share; if ($work !== '' && !$isAbs($work)) $work = $htdocs . DIRECTORY_SEPARATOR . $work; $exe = rtrim($exe ?: $defM, '/\\'); $share = rtrim($share ?: $defM, '/\\'); $work = rtrim($work ?: $defW, '/\\'); return ['exe' => $exe, 'share' => $share, 'work' => $work]; } /** exeDir 直下 or exeDir\bin から実行ファイルを探す(.exe/.bat/.cmd 対応) */ private function findExe(string $exeDir, string $name): ?string { if ($this->isWindows()) { $candidates = [ "$exeDir\\$name.exe", "$exeDir\\bin\\$name.exe", "$exeDir\\$name.bat", "$exeDir\\bin\\$name.bat", "$exeDir\\$name.cmd", "$exeDir\\bin\\$name.cmd", "$exeDir\\windows\\bin\\$name.exe", "$exeDir\\win\\bin\\$name.exe", ]; } else { $candidates = [ "$exeDir/$name", "$exeDir/bin/$name", ]; } foreach ($candidates as $p) { if (is_file($p)) return $p; } return null; } /** 出力をUTF-8へ(WinはSJIS-WIN想定) */ private function outUTF8(string $s): string { return $this->isWindows() ? mb_convert_encoding($s, 'UTF-8', 'SJIS-WIN') : $s; } /** * .exe は配列+bypass_shell、.bat/.cmd は「cmd /C ""...""」の文字列+shell経由で起動 * @return array [$proc, $pipes] 失敗時は [null, []] */ private function openProcess(string $exeOrBat, array $args, string $cwd): array { $des = [1 => ['pipe','w'], 2 => ['pipe','w']]; if ($this->isWindows() && preg_match('/\.(bat|cmd)$/i', $exeOrBat)) { // cmd /C ""C:\path\tool.bat" "arg1" "arg2"" $cmd = 'cmd.exe /C "' . '"' . $exeOrBat . '"'; foreach ($args as $a) { $cmd .= ' ' . escapeshellarg($a); } $cmd .= '"'; $proc = proc_open($cmd, $des, $pipes, $cwd); // shell経由(bypass_shell=false) } else { $cmd = array_merge([$exeOrBat], $args); $proc = proc_open($cmd, $des, $pipes, $cwd, null, ['bypass_shell' => true]); } if (!is_resource($proc)) return [null, []]; return [$proc, $pipes]; } /* ===================== Source ===================== */ private function handleSourceCompileRequest() { global $INPUT; $pageContent = $INPUT->post->str('content'); $mizarData = $this->extractMizarContent($pageContent); if ($mizarData === null) { $this->sendAjaxResponse(false, 'Mizar content not found'); return; } if (isset($mizarData['error'])) { $this->sendAjaxResponse(false, $mizarData['error']); return; } $filePath = $this->saveMizarContent($mizarData); if (session_status() !== PHP_SESSION_ACTIVE) session_start(); $_SESSION['source_filepath'] = $filePath; $this->sendAjaxResponse(true, 'Mizar content processed successfully'); } private function handleSourceSSERequest() { header('Content-Type: text/event-stream'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); if (session_status() !== PHP_SESSION_ACTIVE) session_start(); if (empty($_SESSION['source_filepath'])) { echo "data: Mizar file path not found in session\n\n"; @ob_flush(); @flush(); return; } $this->streamSourceOutput($_SESSION['source_filepath']); echo "event: end\n"; echo "data: Compilation complete\n\n"; @ob_flush(); @flush(); } /* ===================== View ===================== */ private function handleViewCompileRequest() { global $INPUT; $content = $INPUT->post->str('content'); $filePath = $this->createTempFile($content); if (session_status() !== PHP_SESSION_ACTIVE) session_start(); $_SESSION['view_filepath'] = $filePath; $this->sendAjaxResponse(true, 'Mizar content processed successfully'); } private function handleViewSSERequest() { header('Content-Type: text/event-stream'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); if (session_status() !== PHP_SESSION_ACTIVE) session_start(); if (empty($_SESSION['view_filepath'])) { echo "data: Mizar file path not found in session\n\n"; @ob_flush(); @flush(); return; } $this->streamViewCompileOutput($_SESSION['view_filepath']); echo "event: end\n"; echo "data: Compilation complete\n\n"; @ob_flush(); @flush(); } /***** view_graph: SVG を返す *****/ private function handleViewGraphRequest() { global $INPUT; $content = $INPUT->post->str('content', ''); if ($content === '') { $this->sendAjaxResponse(false, 'Empty content'); return; } $tmp = tempnam(sys_get_temp_dir(), 'miz'); $miz = $tmp . '.miz'; rename($tmp, $miz); file_put_contents($miz, $content); $parser = __DIR__ . '/script/miz2svg.py'; $py = $this->getConf('py_cmd') ?: 'python'; $svg = shell_exec(escapeshellcmd($py) . ' ' . escapeshellarg($parser) . ' ' . escapeshellarg($miz)); @unlink($miz); if ($svg) $this->sendAjaxResponse(true, 'success', ['svg' => $svg]); else $this->sendAjaxResponse(false, 'conversion failed'); } /* ===================== Content utils ===================== */ private function extractMizarContent($pageContent) { $pattern = '/]+)>(.*?)<\/mizar>/s'; preg_match_all($pattern, $pageContent, $m, PREG_SET_ORDER); if (empty($m)) return null; $fn = trim($m[0][1]); $stem = preg_replace('/\.miz$/i', '', $fn); if (!$this->isValidFileName($stem)) { return ['error' => "Invalid characters in file name: '{$stem}'. Only letters, numbers, underscores (_), and apostrophes (') are allowed, up to 8 characters."]; } $combined = ''; foreach ($m as $mm) { $cur = preg_replace('/\.miz$/i', '', trim($mm[1])); if ($cur !== $stem) return ['error' => "File name mismatch in tags: '{$stem}' and '{$cur}'"]; if (!$this->isValidFileName($cur)) return ['error' => "Invalid characters in file name: '{$cur}'."]; $combined .= trim($mm[2]) . "\n"; } return ['fileName' => $stem . '.miz', 'content' => $combined]; } private function isValidFileName($fileName) { if (strlen($fileName) > 8) return false; return (bool)preg_match('/^[A-Za-z0-9_\']+$/', $fileName); } private function saveMizarContent($mizarData) { $paths = $this->resolvePaths(); $textDir = $paths['work'] . DIRECTORY_SEPARATOR . 'TEXT'; if (!is_dir($textDir)) @mkdir($textDir, 0777, true); $filePath = $textDir . DIRECTORY_SEPARATOR . $mizarData['fileName']; file_put_contents($filePath, $mizarData['content']); return $filePath; } private function createTempFile($content) { $paths = $this->resolvePaths(); $textDir = $paths['work'] . DIRECTORY_SEPARATOR . 'TEXT'; if (!is_dir($textDir)) @mkdir($textDir, 0777, true); $tempFilename = $textDir . DIRECTORY_SEPARATOR . str_replace('.', '_', uniqid('tmp', true)) . ".miz"; file_put_contents($tempFilename, $content); return $tempFilename; } private function clearTempFiles() { $paths = $this->resolvePaths(); $dir = $paths['work'] . DIRECTORY_SEPARATOR . 'TEXT' . DIRECTORY_SEPARATOR; $files = glob($dir . '*'); $errors = []; foreach ($files as $f) { if (is_file($f)) { if (!$this->is_file_locked($f)) { $ok = false; $retries = 5; while ($retries-- > 0) { if (@unlink($f)) { $ok = true; break; } sleep(2); } if (!$ok) $errors[] = "Failed to delete: $f"; } else { $errors[] = "File is locked: $f"; } } } if ($errors) $this->sendAjaxResponse(false, 'Some files could not be deleted', $errors); else $this->sendAjaxResponse(true, 'Temporary files cleared successfully'); } private function is_file_locked($file) { $fp = @fopen($file, "r+"); if ($fp === false) return true; $locked = !flock($fp, LOCK_EX | LOCK_NB); fclose($fp); return $locked; } /* ===================== Run (miz2prel/makeenv/verifier) ===================== */ private function streamSourceOutput($filePath) { $paths = $this->resolvePaths(); $workPath = $paths['work']; $sharePath = $paths['share']; putenv("MIZFILES={$sharePath}"); $exe = $this->findExe($paths['exe'], 'miz2prel'); if ($exe === null) { echo "data: ERROR: miz2prel not found under {$paths['exe']} (or bin)\n\n"; @ob_flush(); @flush(); return; } [$proc, $pipes] = $this->openProcess($exe, [$filePath], $workPath); if (!$proc) { echo "data: ERROR: Failed to execute miz2prel.\n\n"; @ob_flush(); @flush(); return; } while (($line = fgets($pipes[1])) !== false) { echo "data: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } fclose($pipes[1]); while (($line = fgets($pipes[2])) !== false) { echo "data: ERROR: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } fclose($pipes[2]); proc_close($proc); $errFilename = str_replace('.miz', '.err', $filePath); $this->handleCompilationErrors($errFilename, $sharePath . DIRECTORY_SEPARATOR . 'mizar.msg'); } private function streamViewCompileOutput($filePath) { $paths = $this->resolvePaths(); $workPath = $paths['work']; $sharePath = $paths['share']; putenv("MIZFILES={$sharePath}"); // makeenv $makeenv = $this->findExe($paths['exe'], 'makeenv'); if ($makeenv === null) { echo "data: ERROR: makeenv not found under {$paths['exe']} (or bin)\n\n"; @ob_flush(); @flush(); return; } [$proc, $pipes] = $this->openProcess($makeenv, [$filePath], $workPath); if (!$proc) { echo "data: ERROR: Failed to execute makeenv.\n\n"; @ob_flush(); @flush(); return; } while (($line = fgets($pipes[1])) !== false) { echo "data: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } fclose($pipes[1]); while (($line = fgets($pipes[2])) !== false) { echo "data: ERROR: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } fclose($pipes[2]); proc_close($proc); $errFilename = str_replace('.miz', '.err', $filePath); if ($this->handleCompilationErrors($errFilename, $sharePath . DIRECTORY_SEPARATOR . 'mizar.msg')) return; // verifier $verifier = $this->findExe($paths['exe'], 'verifier'); if ($verifier === null) { echo "data: ERROR: verifier not found under {$paths['exe']} (or bin)\n\n"; @ob_flush(); @flush(); return; } $rel = 'TEXT' . DIRECTORY_SEPARATOR . basename($filePath); [$proc, $pipes] = $this->openProcess($verifier, ['-q','-l',$rel], $workPath); if (!$proc) { echo "data: ERROR: Failed to execute verifier.\n\n"; @ob_flush(); @flush(); return; } while (($line = fgets($pipes[1])) !== false) { echo "data: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } fclose($pipes[1]); while (($line = fgets($pipes[2])) !== false) { echo "data: ERROR: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } fclose($pipes[2]); proc_close($proc); $this->handleCompilationErrors($errFilename, $sharePath . DIRECTORY_SEPARATOR . 'mizar.msg'); } /* ===================== Errors ===================== */ private function getMizarErrorMessages($mizarMsgFile) { if (!is_file($mizarMsgFile)) return []; $errorMessages = []; $lines = file($mizarMsgFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $isReading = false; $key = 0; foreach ($lines as $line) { if (preg_match('/# (\d+)/', $line, $m)) { $isReading = true; $key = (int)$m[1]; } elseif ($isReading) { $errorMessages[$key] = $line; $isReading = false; } } return $errorMessages; } private function handleCompilationErrors($errFilename, $mizarMsgFilePath) { if (!file_exists($errFilename)) return false; $errs = []; $lines = file($errFilename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); foreach ($lines as $ln) { if (preg_match('/(\d+)\s+(\d+)\s+(\d+)/', $ln, $m)) { $code = (int)$m[3]; $errs[] = [ 'code' => $code, 'line' => (int)$m[1], 'column' => (int)$m[2], 'message' => $this->getMizarErrorMessages($mizarMsgFilePath)[$code] ?? 'Unknown error' ]; } } if ($errs) { echo "event: compileErrors\n"; echo "data: " . json_encode($errs) . "\n\n"; @ob_flush(); @flush(); return true; } return false; } private function sendAjaxResponse($success, $message, $data = '') { header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); echo json_encode(['success' => $success, 'message' => $message, 'data' => $data]); exit; } private function handle_create_combined_file() { global $INPUT; $combinedContent = $INPUT->post->str('content'); $filename = $INPUT->post->str('filename', 'combined_file.miz'); if (!empty($combinedContent)) { $this->sendAjaxResponse(true, 'File created successfully', [ 'filename' => $filename, 'content' => $combinedContent ]); } else { $this->sendAjaxResponse(false, 'Content is empty, no file created'); } } }