Browse Source

fix: pdf的公式使用nodejs预先渲染

大侠咬超人 2 days ago
parent
commit
bd2b3c0121
5 changed files with 344 additions and 36 deletions
  1. 7 0
      Dockerfile
  2. 32 35
      app/Services/ExamPdfExportService.php
  3. 169 0
      app/Services/KatexRenderer.php
  4. 14 1
      docs/ops-commands.md
  5. 122 0
      scripts/katex-render.js

+ 7 - 0
Dockerfile

@@ -59,6 +59,9 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
         fontconfig \
         fontconfig \
         # PDF 合并工具
         # PDF 合并工具
         poppler-utils \
         poppler-utils \
+        # Node.js(KaTeX 服务端渲染需要)
+        nodejs \
+        npm \
         && rm -rf /var/cache/apk/* \
         && rm -rf /var/cache/apk/* \
         && fc-cache -fv \
         && fc-cache -fv \
         && mkdir -p /run/dbus
         && mkdir -p /run/dbus
@@ -96,6 +99,10 @@ COPY . .
 # 从第一阶段复制构建好的前端资源
 # 从第一阶段复制构建好的前端资源
 COPY --from=frontend-builder /app/public/build ./public/build
 COPY --from=frontend-builder /app/public/build ./public/build
 
 
+# 安装 KaTeX(服务端公式渲染)
+RUN npm config set registry https://registry.npmmirror.com && \
+    npm install -g katex@0.16.9
+
 # 缓存路由和视图(不缓存配置,配置在运行时从 .env 读取)
 # 缓存路由和视图(不缓存配置,配置在运行时从 .env 读取)
 RUN php artisan route:cache && \
 RUN php artisan route:cache && \
     php artisan view:cache && \
     php artisan view:cache && \

+ 32 - 35
app/Services/ExamPdfExportService.php

@@ -24,6 +24,8 @@ use Symfony\Component\Process\Process;
  */
  */
 class ExamPdfExportService
 class ExamPdfExportService
 {
 {
+    private ?KatexRenderer $katexRenderer = null;
+
     public function __construct(
     public function __construct(
         private readonly LearningAnalyticsService $learningAnalyticsService,
         private readonly LearningAnalyticsService $learningAnalyticsService,
         private readonly QuestionBankService $questionBankService,
         private readonly QuestionBankService $questionBankService,
@@ -31,7 +33,10 @@ class ExamPdfExportService
         private readonly PdfStorageService $pdfStorageService,
         private readonly PdfStorageService $pdfStorageService,
         private readonly MasteryCalculator $masteryCalculator,
         private readonly MasteryCalculator $masteryCalculator,
         private readonly PdfMerger $pdfMerger
         private readonly PdfMerger $pdfMerger
-    ) {}
+    ) {
+        // 延迟初始化 KatexRenderer(避免循环依赖)
+        $this->katexRenderer = new KatexRenderer();
+    }
 
 
     /**
     /**
      * 生成试卷 PDF(不含答案)
      * 生成试卷 PDF(不含答案)
@@ -1316,47 +1321,39 @@ class ExamPdfExportService
                 );
                 );
             }
             }
 
 
-            // 【关键修复】移除原有的DOMContentLoaded异步渲染,改为同步立即渲染
-            // Chrome headless的--print-to-pdf不会等待DOMContentLoaded后的JS执行
-            // 所以我们需要在</body>前添加同步渲染代码
+            // 【关键修复】使用服务端预渲染,而不是依赖客户端 JavaScript
+            // Chrome headless 的 --print-to-pdf 不会等待 JS 执行完成
+            // 所以我们使用 Node.js KaTeX 在服务端预渲染所有公式
 
 
-            // 1. 移除原有的DOMContentLoaded监听器(包括setTimeout延迟)
+            // 1. 移除所有 KaTeX JavaScript(不再需要,因为使用服务端渲染)
+            // 移除内联的 katex.min.js
             $html = preg_replace(
             $html = preg_replace(
-                '/<script[^>]*>\s*document\.addEventListener\s*\(\s*[\'"]DOMContentLoaded[\'"]\s*,\s*function\s*\(\)\s*\{[\s\S]*?renderMathInElement[\s\S]*?\}\s*\)\s*;?\s*<\/script>/i',
-                '<!-- KaTeX DOMContentLoaded removed, using sync render -->',
+                '/<script[^>]*type=["\']text\/javascript["\'][^>]*>[\s\S]*?katex[\s\S]*?<\/script>/i',
+                '<!-- KaTeX JS removed, using server-side rendering -->',
                 $html
                 $html
             );
             );
 
 
-            // 2. 在</body>前添加同步渲染脚本
-            $syncRenderScript = '
-<script type="text/javascript">
-// 同步执行KaTeX渲染(PDF生成专用)
-(function() {
-    if (typeof renderMathInElement === "function") {
-        try {
-            renderMathInElement(document.body, {
-                delimiters: [
-                    {left: "$$", right: "$$", display: true},
-                    {left: "$", right: "$", display: false},
-                    {left: "\\\\(", right: "\\\\)", display: false},
-                    {left: "\\\\[", right: "\\\\]", display: true}
-                ],
-                throwOnError: false,
-                strict: false,
-                trust: true
-            });
-            console.log("KaTeX sync render completed");
-        } catch(e) {
-            console.error("KaTeX render error:", e);
-        }
-    }
-})();
-</script>
-</body>';
+            // 移除 DOMContentLoaded 监听器
+            $html = preg_replace(
+                '/<script[^>]*>[\s\S]*?document\.addEventListener[\s\S]*?DOMContentLoaded[\s\S]*?<\/script>/i',
+                '<!-- DOMContentLoaded removed -->',
+                $html
+            );
 
 
-            $html = str_ireplace('</body>', $syncRenderScript, $html);
+            // 2. 使用 KatexRenderer 进行服务端预渲染
+            if ($this->katexRenderer) {
+                $beforeLength = strlen($html);
+                $html = $this->katexRenderer->renderHtml($html);
+                $afterLength = strlen($html);
 
 
-            Log::info('ExamPdfExportService: KaTeX已改为同步渲染模式');
+                Log::info('ExamPdfExportService: LaTeX 公式服务端预渲染完成', [
+                    'before_length' => $beforeLength,
+                    'after_length' => $afterLength,
+                    'size_change' => $afterLength - $beforeLength,
+                ]);
+            } else {
+                Log::warning('ExamPdfExportService: KatexRenderer 未初始化,跳过预渲染');
+            }
 
 
         } catch (\Exception $e) {
         } catch (\Exception $e) {
             Log::warning('ExamPdfExportService: 内联资源处理失败,保留原始HTML', [
             Log::warning('ExamPdfExportService: 内联资源处理失败,保留原始HTML', [

+ 169 - 0
app/Services/KatexRenderer.php

@@ -0,0 +1,169 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\Log;
+use Symfony\Component\Process\Process;
+use Symfony\Component\Process\Exception\ProcessFailedException;
+
+/**
+ * KaTeX 服务端渲染服务
+ *
+ * 使用 Node.js 的 KaTeX 库在服务端预渲染 LaTeX 公式
+ * 避免依赖 Chrome headless 执行 JavaScript
+ */
+class KatexRenderer
+{
+    /**
+     * Node.js 脚本路径
+     */
+    private string $scriptPath;
+
+    /**
+     * 是否启用缓存
+     */
+    private bool $cacheEnabled = true;
+
+    /**
+     * 缓存前缀
+     */
+    private const CACHE_PREFIX = 'katex_rendered_';
+
+    /**
+     * 缓存时间(秒)
+     */
+    private const CACHE_TTL = 86400; // 24小时
+
+    public function __construct()
+    {
+        $this->scriptPath = base_path('scripts/katex-render.js');
+    }
+
+    /**
+     * 渲染 HTML 中的所有 LaTeX 公式
+     *
+     * @param string $html 包含 LaTeX 公式的 HTML
+     * @return string 渲染后的 HTML
+     */
+    public function renderHtml(string $html): string
+    {
+        // 检查是否包含需要渲染的公式
+        if (!$this->containsLatex($html)) {
+            Log::debug('KatexRenderer: HTML 不包含 LaTeX 公式,跳过渲染');
+            return $html;
+        }
+
+        // 尝试从缓存获取
+        $cacheKey = $this->getCacheKey($html);
+        if ($this->cacheEnabled && $cached = cache()->get($cacheKey)) {
+            Log::debug('KatexRenderer: 从缓存获取渲染结果');
+            return $cached;
+        }
+
+        // 调用 Node.js 脚本渲染
+        $rendered = $this->callNodeScript($html);
+
+        // 缓存结果
+        if ($this->cacheEnabled && $rendered !== $html) {
+            cache()->put($cacheKey, $rendered, self::CACHE_TTL);
+        }
+
+        return $rendered;
+    }
+
+    /**
+     * 检查 HTML 是否包含 LaTeX 公式
+     */
+    private function containsLatex(string $html): bool
+    {
+        // 检查常见的 LaTeX 定界符
+        return preg_match('/\$[^$]+\$|\$\$[\s\S]+?\$\$|\\\\\([\s\S]+?\\\\\)|\\\\\[[\s\S]+?\\\\\]/', $html) === 1;
+    }
+
+    /**
+     * 调用 Node.js KaTeX 渲染脚本
+     */
+    private function callNodeScript(string $html): string
+    {
+        // 检查脚本是否存在
+        if (!file_exists($this->scriptPath)) {
+            Log::warning('KatexRenderer: 渲染脚本不存在', ['path' => $this->scriptPath]);
+            return $html;
+        }
+
+        try {
+            // 创建进程
+            $process = new Process(['node', $this->scriptPath]);
+            $process->setInput($html);
+            $process->setTimeout(30); // 30秒超时
+
+            // 执行
+            $process->run();
+
+            // 检查是否成功
+            if (!$process->isSuccessful()) {
+                Log::warning('KatexRenderer: Node.js 脚本执行失败', [
+                    'exit_code' => $process->getExitCode(),
+                    'error' => $process->getErrorOutput(),
+                ]);
+                return $html;
+            }
+
+            $output = $process->getOutput();
+
+            // 验证输出
+            if (empty($output)) {
+                Log::warning('KatexRenderer: Node.js 脚本输出为空');
+                return $html;
+            }
+
+            Log::info('KatexRenderer: LaTeX 公式渲染成功', [
+                'input_length' => strlen($html),
+                'output_length' => strlen($output),
+            ]);
+
+            return $output;
+
+        } catch (\Exception $e) {
+            Log::error('KatexRenderer: 渲染异常', [
+                'error' => $e->getMessage(),
+            ]);
+            return $html;
+        }
+    }
+
+    /**
+     * 生成缓存键
+     */
+    private function getCacheKey(string $html): string
+    {
+        return self::CACHE_PREFIX . md5($html);
+    }
+
+    /**
+     * 禁用缓存(用于调试)
+     */
+    public function disableCache(): self
+    {
+        $this->cacheEnabled = false;
+        return $this;
+    }
+
+    /**
+     * 启用缓存
+     */
+    public function enableCache(): self
+    {
+        $this->cacheEnabled = true;
+        return $this;
+    }
+
+    /**
+     * 清除所有 KaTeX 渲染缓存
+     */
+    public function clearCache(): void
+    {
+        // 注意:这个方法需要 Redis 或支持通配符删除的缓存驱动
+        Log::info('KatexRenderer: 缓存清除请求(需要手动清理或使用 Redis)');
+    }
+}

+ 14 - 1
docs/ops-commands.md

@@ -202,7 +202,11 @@ docker update --cpus=1 --memory=1g math_cms_pdf
 
 
 **问题1: PDF生成超时(50秒后失败)**
 **问题1: PDF生成超时(50秒后失败)**
 - 原因:HTML模板中引用了CDN资源(KaTeX CSS/JS),容器内网络请求慢
 - 原因:HTML模板中引用了CDN资源(KaTeX CSS/JS),容器内网络请求慢
-- 解决:代码已优化,自动移除CDN依赖,使用系统字体渲染
+- 解决:代码已优化,使用服务端 Node.js KaTeX 预渲染公式
+
+**问题4: 公式显示为原始 LaTeX 文本**
+- 原因:Chrome headless 的 --print-to-pdf 不等待 JavaScript 执行
+- 解决:使用 Node.js KaTeX 在服务端预渲染公式,生成静态 HTML
 
 
 **问题2: dbus 连接错误**
 **问题2: dbus 连接错误**
 - 表现:日志中出现 `Failed to connect to the bus` 错误
 - 表现:日志中出现 `Failed to connect to the bus` 错误
@@ -228,12 +232,21 @@ docker update --cpus=1 --memory=1g math_cms_pdf
 # 查看PDF生成日志
 # 查看PDF生成日志
 docker exec math_cms_app grep -i "ExamPdfExportService" storage/logs/laravel.log | tail -50
 docker exec math_cms_app grep -i "ExamPdfExportService" storage/logs/laravel.log | tail -50
 
 
+# 查看KaTeX渲染日志
+docker exec math_cms_app grep -i "KatexRenderer\|LaTeX.*预渲染" storage/logs/laravel.log | tail -20
+
+# 测试Node.js KaTeX是否正常工作
+docker exec math_cms_app echo '$x^2 + y^2 = z^2$' | node /app/scripts/katex-render.js
+
 # 测试Chrome是否正常工作
 # 测试Chrome是否正常工作
 docker exec math_cms_pdf chromium-browser --headless --disable-gpu --no-sandbox --print-to-pdf=/tmp/test.pdf https://www.baidu.com
 docker exec math_cms_pdf chromium-browser --headless --disable-gpu --no-sandbox --print-to-pdf=/tmp/test.pdf https://www.baidu.com
 
 
 # 检查Chrome版本
 # 检查Chrome版本
 docker exec math_cms_pdf chromium-browser --version
 docker exec math_cms_pdf chromium-browser --version
 
 
+# 检查Node.js版本
+docker exec math_cms_app node --version
+
 # 检查dbus状态
 # 检查dbus状态
 docker exec math_cms_pdf pgrep -a dbus
 docker exec math_cms_pdf pgrep -a dbus
 ```
 ```

+ 122 - 0
scripts/katex-render.js

@@ -0,0 +1,122 @@
+#!/usr/bin/env node
+/**
+ * KaTeX 服务端渲染脚本
+ * 用法: echo "HTML内容" | node katex-render.js
+ * 或:   node katex-render.js < input.html > output.html
+ *
+ * 将 HTML 中的 LaTeX 公式渲染为 KaTeX HTML
+ */
+
+// 尝试多个路径加载 KaTeX
+let katex;
+const possiblePaths = [
+    'katex',                                    // 本地 node_modules
+    '/usr/lib/node_modules/katex',              // Alpine 全局安装
+    '/usr/local/lib/node_modules/katex',        // 其他全局安装路径
+];
+
+for (const path of possiblePaths) {
+    try {
+        katex = require(path);
+        break;
+    } catch (e) {
+        // 继续尝试下一个路径
+    }
+}
+
+if (!katex) {
+    console.error('Error: KaTeX module not found. Please run: npm install -g katex');
+    process.exit(1);
+}
+
+// 读取标准输入
+let input = '';
+
+process.stdin.setEncoding('utf8');
+
+process.stdin.on('readable', () => {
+    let chunk;
+    while ((chunk = process.stdin.read()) !== null) {
+        input += chunk;
+    }
+});
+
+process.stdin.on('end', () => {
+    try {
+        const output = renderMathInHtml(input);
+        process.stdout.write(output);
+    } catch (error) {
+        console.error('KaTeX render error:', error.message);
+        process.exit(1);
+    }
+});
+
+/**
+ * 渲染 HTML 中的所有数学公式
+ */
+function renderMathInHtml(html) {
+    // 定界符配置(按优先级排序)
+    const delimiters = [
+        { left: '$$', right: '$$', display: true },
+        { left: '\\[', right: '\\]', display: true },
+        { left: '\\(', right: '\\)', display: false },
+        { left: '$', right: '$', display: false },
+    ];
+
+    let result = html;
+
+    // 按顺序处理每种定界符
+    for (const delimiter of delimiters) {
+        result = processDelimiter(result, delimiter.left, delimiter.right, delimiter.display);
+    }
+
+    return result;
+}
+
+/**
+ * 处理特定定界符的公式
+ */
+function processDelimiter(html, left, right, displayMode) {
+    // 转义正则特殊字符
+    const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+    const leftEscaped = escapeRegex(left);
+    const rightEscaped = escapeRegex(right);
+
+    // 构建正则表达式
+    // 对于 $ ... $,需要确保不匹配 $$ ... $$
+    let pattern;
+    if (left === '$' && right === '$') {
+        // 单个 $ 不能紧跟另一个 $
+        pattern = new RegExp(`(?<!\\$)\\$(?!\\$)([^$]+?)(?<!\\$)\\$(?!\\$)`, 'g');
+    } else {
+        pattern = new RegExp(`${leftEscaped}([\\s\\S]*?)${rightEscaped}`, 'g');
+    }
+
+    return html.replace(pattern, (match, latex) => {
+        try {
+            // 清理 LaTeX 内容
+            let cleanLatex = latex.trim();
+
+            // 跳过空内容
+            if (!cleanLatex) {
+                return match;
+            }
+
+            // 渲染 KaTeX
+            const rendered = katex.renderToString(cleanLatex, {
+                displayMode: displayMode,
+                throwOnError: false,
+                strict: false,
+                trust: true,
+                output: 'html', // 使用 HTML 输出(比 mathml 兼容性更好)
+            });
+
+            return rendered;
+        } catch (error) {
+            // 渲染失败时保留原始内容
+            console.error(`KaTeX error for "${latex.substring(0, 50)}...":`, error.message);
+            return match;
+        }
+    });
+}