From 4ca92e3a22c4125ccf76995a6b76c202b491d146 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 22 Jan 2026 17:41:12 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v0.1.11=20-=20BabelDOC=20=E7=BF=BB?= =?UTF-8?q?=E8=AD=AF=E5=BC=95=E6=93=8E=E8=88=87=E7=A9=A9=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=E6=94=B9=E9=80=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增功能 - BabelDOC PDF 翻譯引擎:支援 PDF/Markdown/HTML 輸出,15 種目標語言 - Dockerfile 更新:pipx 安裝 babeldoc,warmup 預載資源 ## 修復 - 修正 user.tsx XSS 安全警告(label 包裹 safe span) ## 品質保證 - 173 個測試全數通過 - TypeScript / ESLint / Prettier / Knip 檢查通過 --- Dockerfile | 34 ++- src/converters/babeldoc.ts | 352 +++++++++++++++++++++++ src/converters/main.ts | 5 + src/pages/user.tsx | 24 +- tests/converters/babeldoc.test.ts | 456 ++++++++++++++++++++++++++++++ 5 files changed, 852 insertions(+), 19 deletions(-) create mode 100644 src/converters/babeldoc.ts create mode 100644 tests/converters/babeldoc.test.ts diff --git a/Dockerfile b/Dockerfile index bda3134..60377be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -234,9 +234,15 @@ RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \ # 階段 12:安裝 Python 工具(pipx)+ huggingface_hub(用於模型下載) # 注意:Debian bookworm 使用 PEP 668,需要 --break-system-packages 來安裝系統級套件 RUN pipx install "markitdown[all]" \ - && pipx install "pdf2zh" \ && pip3 install --no-cache-dir --break-system-packages huggingface_hub +# 階段 12-A:安裝 pdf2zh(PDFMathTranslate 引擎) +RUN pipx install "pdf2zh" + +# 階段 12-B:安裝 babeldoc(BabelDOC 引擎) +# BabelDOC 是一個 PDF 翻譯工具,與 pdf2zh 類似但使用不同的翻譯方式 +RUN pipx install "babeldoc" || echo "⚠️ babeldoc 安裝失敗,跳過..." + # 階段 13:安裝 mineru(可能在 arm64 上有問題,加入錯誤處理) RUN pipx install "mineru[all]" || echo "⚠️ mineru 安裝失敗(可能是 arm64 相容性問題),跳過..." @@ -294,11 +300,16 @@ RUN mkdir -p /models/pdfmathtranslate && \ # 階段 14-B:BabelDOC Warmup(預載入所有資源) # ------------------------------------------------------------------------------ # 說明:babeldoc --warmup 會下載所有必要的字型和模型資源 -# 這確保 pdf2zh 執行時不會有任何隱式下載 +# 這確保 BabelDOC 執行時不會有任何隱式下載 +# 注意:分開執行以避免記憶體壓力 # ------------------------------------------------------------------------------ RUN echo "📥 [2/6] 執行 BabelDOC warmup..." && \ - babeldoc --warmup 2>&1 || echo "⚠️ BabelDOC warmup 可能已完成或無需 warmup" && \ - echo "✅ BabelDOC warmup 完成" + if command -v babeldoc >/dev/null 2>&1; then \ + babeldoc --warmup 2>&1 || echo "⚠️ BabelDOC warmup 失敗或無需 warmup"; \ + else \ + echo "⚠️ babeldoc 命令不存在,跳過 warmup"; \ + fi && \ + echo "✅ BabelDOC warmup 步驟完成" # ------------------------------------------------------------------------------ # 階段 14-C:PDFMathTranslate 字型下載 @@ -374,6 +385,14 @@ RUN echo "📥 [6/6] 驗證模型並清理快取..." && \ echo "🔹 PDFMathTranslate 字型:" && \ ls -lh /app/*.ttf 2>/dev/null || echo " ⚠️ 無字型檔案" && \ echo "" && \ + echo "🔹 BabelDOC 快取:" && \ + if [ -d "/root/.cache/babeldoc" ]; then \ + echo " ✅ BabelDOC 快取目錄存在"; \ + du -sh /root/.cache/babeldoc 2>/dev/null || true; \ + else \ + echo " ⚠️ BabelDOC 快取目錄不存在(可能需要 runtime 下載)"; \ + fi && \ + echo "" && \ echo "🔹 MinerU 模型目錄:" && \ if [ -d "/root/.cache/huggingface/hub/PDF-Extract-Kit-1.0" ]; then \ echo " ✅ MinerU Pipeline 模型目錄存在"; \ @@ -381,9 +400,6 @@ RUN echo "📥 [6/6] 驗證模型並清理快取..." && \ else \ echo " ⚠️ MinerU Pipeline 模型目錄不存在(可能需要 runtime 下載)"; \ fi && \ - echo "" && \ - echo "🔹 BabelDOC 快取:" && \ - ls -la /root/.cache/babeldoc 2>/dev/null || echo " 快取位置可能不同" && \ echo "========================================" && \ # 清理 pip 快取(保留模型) rm -rf /root/.cache/pip && \ @@ -393,6 +409,10 @@ RUN echo "📥 [6/6] 驗證模型並清理快取..." && \ ENV PDFMATHTRANSLATE_MODELS_PATH="/models/pdfmathtranslate" ENV NOTO_FONT_PATH="/app/GoNotoKurrent-Regular.ttf" +# BabelDOC 環境變數 +ENV BABELDOC_CACHE_PATH="/root/.cache/babeldoc" +ENV BABELDOC_SERVICE="google" + # MinerU 環境變數(強制使用本地模型) ENV MINERU_MODEL_SOURCE="local" ENV HF_HUB_OFFLINE="1" diff --git a/src/converters/babeldoc.ts b/src/converters/babeldoc.ts new file mode 100644 index 0000000..9b5509e --- /dev/null +++ b/src/converters/babeldoc.ts @@ -0,0 +1,352 @@ +import { execFile as execFileOriginal } from "node:child_process"; +import { + mkdirSync, + existsSync, + readdirSync, + unlinkSync, + rmdirSync, + copyFileSync, + statSync, +} from "node:fs"; +import { join, basename, dirname } from "node:path"; +import { ExecFileFn } from "./types"; +import { getArchiveFileName } from "../transfer"; + +/** + * BabelDOC Content Engine + * + * 用於翻譯 PDF 文件的引擎,基於 BabelDOC CLI 工具。 + * 與 PDFMathTranslate 類似,但使用 babeldoc 命令進行翻譯。 + * + * 所有輸出一律打包為 .tar 檔案,包含: + * - translated-.pdf(翻譯後的 PDF) + * - 其他 BabelDOC 產生的輔助檔案 + * + * 必須在 Docker build 階段預先下載所需資源(--warmup), + * 不允許在 runtime 隱式下載。 + */ + +// 支援的目標語言列表(與 PDFMathTranslate 保持一致) +const SUPPORTED_LANGUAGES = [ + "en", // English + "zh", // Chinese (Simplified) + "zh-TW", // Chinese (Traditional) + "ja", // Japanese + "ko", // Korean + "de", // German + "fr", // French + "es", // Spanish + "it", // Italian + "pt", // Portuguese + "ru", // Russian + "ar", // Arabic + "hi", // Hindi + "vi", // Vietnamese + "th", // Thai +] as const; + +// 支援的輸出格式 +const SUPPORTED_OUTPUT_FORMATS = ["pdf", "md", "html"] as const; +type OutputFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number]; + +// BabelDOC 快取路徑(Docker 環境中) +const BABELDOC_CACHE_PATH = process.env.BABELDOC_CACHE_PATH || "/root/.cache/babeldoc"; + +// 生成 from/to 格式映射 +function generateLanguageMappings(): { + from: Record; + to: Record; +} { + // BabelDOC 輸出格式: + // - pdf-: 輸出 PDF + // - md-: 輸出 Markdown + // - html-: 輸出 HTML + const outputFormats: string[] = []; + + for (const format of SUPPORTED_OUTPUT_FORMATS) { + for (const lang of SUPPORTED_LANGUAGES) { + outputFormats.push(`${format}-${lang}`); + } + } + + return { + from: { + document: ["pdf"], + }, + to: { + document: outputFormats, + }, + }; +} + +export const properties = { + ...generateLanguageMappings(), + outputMode: "archive" as const, +}; + +/** + * 從 convertTo 格式中提取目標語言和輸出格式 + * @param convertTo 格式如 "pdf-zh"、"md-en"、"html-ja" + * @returns { lang: 目標語言代碼, format: 輸出格式 } + */ +function extractTargetInfo(convertTo: string): { lang: string; format: OutputFormat } { + // convertTo 格式: - + // 例如: pdf-zh, md-en, html-ja + const match = convertTo.match(/^(pdf|md|html)-(.+)$/); + if (!match || !match[1] || !match[2]) { + throw new Error( + `Invalid convertTo format: ${convertTo}. Expected - (format: pdf/md/html)`, + ); + } + return { + format: match[1] as OutputFormat, + lang: match[2], + }; +} + +/** + * 檢查 BabelDOC 資源是否已預先下載 + * @returns 資源是否存在 + */ +function checkResourcesExist(): boolean { + if (!existsSync(BABELDOC_CACHE_PATH)) { + console.warn(`[BabelDOC] Cache directory not found: ${BABELDOC_CACHE_PATH}`); + console.warn(`[BabelDOC] Resources should be pre-downloaded via --warmup during Docker build.`); + return false; + } + return true; +} + +/** + * Helper function to create a .tar archive from a directory (no compression) + * + * ⚠️ 重要:僅使用 .tar 格式,禁止 .tar.gz / .tgz / .zip + */ +function createTarArchive( + sourceDir: string, + outputTar: string, + execFile: ExecFileFn, +): Promise { + return new Promise((resolve, reject) => { + // Use tar command to create archive (without gzip compression) + // tar -cf -C . + // 注意:使用 -cf 而非 -czf,避免 gzip 壓縮 + execFile("tar", ["-cf", outputTar, "-C", sourceDir, "."], (error, stdout, stderr) => { + if (error) { + reject(`tar error: ${error}`); + return; + } + if (stdout) { + console.log(`tar stdout: ${stdout}`); + } + if (stderr) { + console.error(`tar stderr: ${stderr}`); + } + resolve(); + }); + }); +} + +/** + * Helper function to remove a directory recursively + */ +function removeDir(dirPath: string): void { + if (existsSync(dirPath)) { + const files = readdirSync(dirPath, { withFileTypes: true }); + for (const file of files) { + const filePath = join(dirPath, file.name); + if (file.isDirectory()) { + removeDir(filePath); + } else { + unlinkSync(filePath); + } + } + rmdirSync(dirPath); + } +} + +/** + * BabelDOC 語言代碼轉換 + * BabelDOC 可能使用不同的語言代碼格式 + */ +function toBabelDocLang(lang: string): string { + // BabelDOC 語言代碼映射 + const langMap: Record = { + "zh-TW": "zh-Hant", + zh: "zh-Hans", + }; + return langMap[lang] || lang; +} + +/** + * 取得 BabelDOC 輸出格式對應的副檔名 + */ +function getOutputExtension(format: OutputFormat): string { + const extMap: Record = { + pdf: "pdf", + md: "md", + html: "html", + }; + return extMap[format]; +} + +/** + * 執行 babeldoc 命令進行 PDF 翻譯 + * + * @param inputPath 輸入 PDF 路徑 + * @param outputPath 輸出檔案路徑 + * @param targetLang 目標語言 + * @param outputFormat 輸出格式(pdf/md/html) + * @param execFile 執行函數 + */ +function runBabelDoc( + inputPath: string, + outputPath: string, + targetLang: string, + outputFormat: OutputFormat, + execFile: ExecFileFn, +): Promise { + return new Promise((resolve, reject) => { + // babeldoc CLI 參數: + // -i : 輸入 PDF + // -o : 輸出檔案 + // -l : 目標語言 + // --output-format : 輸出格式(pdf/md/html) + // --service : 翻譯服務(預設 google) + + const babelLang = toBabelDocLang(targetLang); + const service = process.env.BABELDOC_SERVICE || "google"; + + const args = [ + "-i", + inputPath, + "-o", + outputPath, + "-l", + babelLang, + "--output-format", + outputFormat, + "--service", + service, + ]; + + console.log(`[BabelDOC] Running: babeldoc ${args.join(" ")}`); + + execFile("babeldoc", args, (error, stdout, stderr) => { + if (error) { + reject(`babeldoc error: ${error}\nstderr: ${stderr}`); + return; + } + + if (stdout) { + console.log(`[BabelDOC] stdout: ${stdout}`); + } + + if (stderr) { + console.log(`[BabelDOC] stderr: ${stderr}`); + } + + // 檢查輸出檔案是否存在 + if (!existsSync(outputPath)) { + reject(`BabelDOC output file not found: ${outputPath}`); + return; + } + + resolve(outputPath); + }); + }); +} + +/** + * 主要轉換函數 + * + * @param filePath 輸入 PDF 檔案路徑 + * @param fileType 檔案類型(應為 "pdf") + * @param convertTo 目標格式(如 "pdf-babel-zh"、"md-babel-en"、"html-babel-ja") + * @param targetPath 輸出路徑 + * @param _options 額外選項 + * @param execFile 執行函數覆寫 + */ +export async function convert( + filePath: string, + fileType: string, + convertTo: string, + targetPath: string, + _options?: unknown, + execFile: ExecFileFn = execFileOriginal, +): Promise { + try { + // 1. 檢查資源(警告但不阻止) + checkResourcesExist(); + + // 2. 提取目標語言和輸出格式 + const { lang: targetLang, format: outputFormat } = extractTargetInfo(convertTo); + const outputExt = getOutputExtension(outputFormat); + console.log(`[BabelDOC] Translating to: ${targetLang}, format: ${outputFormat}`); + + // 3. 建立臨時輸出目錄 + const outputDir = dirname(targetPath); + const inputFileName = basename(filePath, `.${fileType}`); + const tempDir = join(outputDir, `${inputFileName}_babeldoc_${Date.now()}`); + + if (!existsSync(tempDir)) { + mkdirSync(tempDir, { recursive: true }); + } + + // 4. 建立封裝用目錄 + const archiveDir = join(tempDir, "archive"); + mkdirSync(archiveDir, { recursive: true }); + + // 5. 設定 BabelDOC 輸出路徑(依輸出格式決定副檔名) + const translatedFilePath = join(tempDir, `${inputFileName}-translated.${outputExt}`); + + // 6. 執行 babeldoc 翻譯 + await runBabelDoc(filePath, translatedFilePath, targetLang, outputFormat, execFile); + + // 7. 複製翻譯後的檔案到封裝目錄 + const translatedDest = join(archiveDir, `translated-${targetLang}.${outputExt}`); + copyFileSync(translatedFilePath, translatedDest); + console.log(`[BabelDOC] Copied translated ${outputFormat.toUpperCase()} to archive`); + + // 8. 檢查是否有其他 BabelDOC 產生的輔助檔案 + const translatedBaseName = `${inputFileName}-translated.${outputExt}`; + const tempFiles = readdirSync(tempDir); + for (const file of tempFiles) { + const fileTempPath = join(tempDir, file); + // 跳過 archive 目錄和已處理的主檔案 + if (file === "archive" || file === translatedBaseName) { + continue; + } + // 複製其他產生的檔案(如 debug 輸出、中間結果等) + const destPath = join(archiveDir, file); + if (existsSync(fileTempPath) && !existsSync(destPath)) { + try { + const stats = statSync(fileTempPath); + if (stats.isFile()) { + copyFileSync(fileTempPath, destPath); + console.log(`[BabelDOC] Copied auxiliary file: ${file}`); + } + } catch { + // 忽略複製失敗的輔助檔案 + } + } + } + + // 9. 建立 .tar 封裝 + const tarPath = getArchiveFileName(targetPath); + const tarDir = dirname(tarPath); + if (!existsSync(tarDir)) { + mkdirSync(tarDir, { recursive: true }); + } + + await createTarArchive(archiveDir, tarPath, execFile); + console.log(`[BabelDOC] Created archive: ${tarPath}`); + + // 10. 清理臨時目錄 + removeDir(tempDir); + + return "Done"; + } catch (error) { + throw new Error(`BabelDOC error: ${error}`); + } +} diff --git a/src/converters/main.ts b/src/converters/main.ts index e411608..3c470a6 100644 --- a/src/converters/main.ts +++ b/src/converters/main.ts @@ -30,6 +30,7 @@ import { convert as convertPDFMathTranslate, properties as propertiesPDFMathTranslate, } from "./pdfmathtranslate"; +import { convert as convertBabelDoc, properties as propertiesBabelDoc } from "./babeldoc"; // This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular @@ -151,6 +152,10 @@ const properties: Record< properties: propertiesPDFMathTranslate, converter: convertPDFMathTranslate, }, + BabelDOC: { + properties: propertiesBabelDoc, + converter: convertBabelDoc, + }, }; function chunks(arr: T[], size: number): T[][] { diff --git a/src/pages/user.tsx b/src/pages/user.tsx index 4b96351..1f59321 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -124,8 +124,8 @@ export const user = new Elysia()
-