release: v0.1.11 - BabelDOC 翻譯引擎與穩定性改進
## 新增功能 - BabelDOC PDF 翻譯引擎:支援 PDF/Markdown/HTML 輸出,15 種目標語言 - Dockerfile 更新:pipx 安裝 babeldoc,warmup 預載資源 ## 修復 - 修正 user.tsx XSS 安全警告(label 包裹 safe span) ## 品質保證 - 173 個測試全數通過 - TypeScript / ESLint / Prettier / Knip 檢查通過
This commit is contained in:
parent
6d4ed58637
commit
4ca92e3a22
5 changed files with 852 additions and 19 deletions
34
Dockerfile
34
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"
|
||||
|
|
|
|||
352
src/converters/babeldoc.ts
Normal file
352
src/converters/babeldoc.ts
Normal file
|
|
@ -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-<lang>.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<string, string[]>;
|
||||
to: Record<string, string[]>;
|
||||
} {
|
||||
// BabelDOC 輸出格式:
|
||||
// - pdf-<lang>: 輸出 PDF
|
||||
// - md-<lang>: 輸出 Markdown
|
||||
// - html-<lang>: 輸出 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 格式: <format>-<lang>
|
||||
// 例如: 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>-<lang> (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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use tar command to create archive (without gzip compression)
|
||||
// tar -cf <output.tar> -C <sourceDir> .
|
||||
// 注意:使用 -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<string, string> = {
|
||||
"zh-TW": "zh-Hant",
|
||||
zh: "zh-Hans",
|
||||
};
|
||||
return langMap[lang] || lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得 BabelDOC 輸出格式對應的副檔名
|
||||
*/
|
||||
function getOutputExtension(format: OutputFormat): string {
|
||||
const extMap: Record<OutputFormat, string> = {
|
||||
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<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// babeldoc CLI 參數:
|
||||
// -i <input>: 輸入 PDF
|
||||
// -o <output>: 輸出檔案
|
||||
// -l <lang>: 目標語言
|
||||
// --output-format <format>: 輸出格式(pdf/md/html)
|
||||
// --service <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<string> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T>(arr: T[], size: number): T[][] {
|
||||
|
|
|
|||
|
|
@ -124,8 +124,8 @@ export const user = new Elysia()
|
|||
</header>
|
||||
<form method="post" action={`${WEBROOT}/register`} class="p-4">
|
||||
<fieldset class="mb-4 flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "email")}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span safe>{t("auth", "email")}</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
|
|
@ -135,8 +135,8 @@ export const user = new Elysia()
|
|||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "password")}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span safe>{t("auth", "password")}</span>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
|
|
@ -191,8 +191,8 @@ export const user = new Elysia()
|
|||
<article class="article">
|
||||
<form method="post" class="flex flex-col gap-4">
|
||||
<fieldset class="mb-4 flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "email")}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span safe>{t("auth", "email")}</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
|
|
@ -202,8 +202,8 @@ export const user = new Elysia()
|
|||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "password")}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span safe>{t("auth", "password")}</span>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
|
|
@ -313,8 +313,8 @@ export const user = new Elysia()
|
|||
<article class="article">
|
||||
<form method="post" class="flex flex-col gap-4">
|
||||
<fieldset class="mb-4 flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "email")}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span safe>{t("auth", "email")}</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
|
|
@ -324,8 +324,8 @@ export const user = new Elysia()
|
|||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "password")}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span safe>{t("auth", "password")}</span>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
|
|
|
|||
456
tests/converters/babeldoc.test.ts
Normal file
456
tests/converters/babeldoc.test.ts
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
||||
import { convert, properties } from "../../src/converters/babeldoc";
|
||||
import type { ExecFileException } from "node:child_process";
|
||||
import { ExecFileFn } from "../../src/converters/types";
|
||||
import { mkdirSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
// Skip common tests as BabelDOC has different behavior (archive output)
|
||||
test.skip("dummy - required to trigger test detection", () => {});
|
||||
|
||||
describe("BabelDOC converter properties", () => {
|
||||
test("should have correct input formats", () => {
|
||||
expect(properties.from.document).toContain("pdf");
|
||||
});
|
||||
|
||||
test("should have correct output formats for PDF with multiple languages", () => {
|
||||
expect(properties.to.document).toContain("pdf-en");
|
||||
expect(properties.to.document).toContain("pdf-zh");
|
||||
expect(properties.to.document).toContain("pdf-zh-TW");
|
||||
expect(properties.to.document).toContain("pdf-ja");
|
||||
expect(properties.to.document).toContain("pdf-ko");
|
||||
expect(properties.to.document).toContain("pdf-de");
|
||||
expect(properties.to.document).toContain("pdf-fr");
|
||||
});
|
||||
|
||||
test("should have correct output formats for Markdown with multiple languages", () => {
|
||||
expect(properties.to.document).toContain("md-en");
|
||||
expect(properties.to.document).toContain("md-zh");
|
||||
expect(properties.to.document).toContain("md-ja");
|
||||
});
|
||||
|
||||
test("should have correct output formats for HTML with multiple languages", () => {
|
||||
expect(properties.to.document).toContain("html-en");
|
||||
expect(properties.to.document).toContain("html-zh");
|
||||
expect(properties.to.document).toContain("html-ja");
|
||||
});
|
||||
|
||||
test("should have archive output mode", () => {
|
||||
expect(properties.outputMode).toBe("archive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BabelDOC converter - Chinese translation", () => {
|
||||
const testDir = "./test-output-babeldoc-zh";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
// Create a dummy PDF file for testing
|
||||
writeFileSync(testInputFile, "%PDF-1.4\n%Test PDF content");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("should call babeldoc with correct language argument for zh", async () => {
|
||||
let babeldocArgs: string[] = [];
|
||||
let babeldocCalled = false;
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (cmd === "babeldoc") {
|
||||
babeldocCalled = true;
|
||||
babeldocArgs = args;
|
||||
|
||||
// Simulate babeldoc creating output file
|
||||
const outputIndex = args.indexOf("-o");
|
||||
if (outputIndex !== -1 && args[outputIndex + 1]) {
|
||||
const outputPath = args[outputIndex + 1];
|
||||
// Create parent directory if needed
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(outputPath, "%PDF-1.4\n%Translated content");
|
||||
}
|
||||
callback(null, "Translation complete", "");
|
||||
} else if (cmd === "tar") {
|
||||
// Simulate .tar creation (no compression)
|
||||
// Verify it's creating .tar not .tar.gz
|
||||
const tarArgs = args;
|
||||
expect(tarArgs[0]).toBe("-cf"); // -cf for tar (not -czf for gzip)
|
||||
callback(null, "Archive created", "");
|
||||
}
|
||||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
await convert(testInputFile, "pdf", "pdf-zh", targetPath, undefined, mockExecFile);
|
||||
|
||||
expect(babeldocCalled).toBe(true);
|
||||
expect(babeldocArgs).toContain("-i");
|
||||
expect(babeldocArgs).toContain("-o");
|
||||
expect(babeldocArgs).toContain("-l");
|
||||
expect(babeldocArgs).toContain("zh-Hans"); // BabelDOC uses zh-Hans for simplified Chinese
|
||||
expect(babeldocArgs).toContain("--output-format");
|
||||
expect(babeldocArgs).toContain("pdf");
|
||||
expect(babeldocArgs).toContain("--service");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BabelDOC converter - English translation", () => {
|
||||
const testDir = "./test-output-babeldoc-en";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(testInputFile, "%PDF-1.4\n%Test PDF content");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("should call babeldoc with correct language argument for en", async () => {
|
||||
let babeldocArgs: string[] = [];
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (cmd === "babeldoc") {
|
||||
babeldocArgs = args;
|
||||
const outputIndex = args.indexOf("-o");
|
||||
if (outputIndex !== -1 && args[outputIndex + 1]) {
|
||||
const outputPath = args[outputIndex + 1];
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(outputPath, "%PDF-1.4\n%Translated");
|
||||
}
|
||||
callback(null, "Translation complete", "");
|
||||
} else if (cmd === "tar") {
|
||||
callback(null, "Archive created", "");
|
||||
}
|
||||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
await convert(testInputFile, "pdf", "pdf-en", targetPath, undefined, mockExecFile);
|
||||
|
||||
expect(babeldocArgs).toContain("-l");
|
||||
expect(babeldocArgs).toContain("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BabelDOC converter - Traditional Chinese", () => {
|
||||
const testDir = "./test-output-babeldoc-zhtw";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(testInputFile, "%PDF-1.4\n%Test PDF content");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("should call babeldoc with correct language argument for zh-TW", async () => {
|
||||
let babeldocArgs: string[] = [];
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (cmd === "babeldoc") {
|
||||
babeldocArgs = args;
|
||||
const outputIndex = args.indexOf("-o");
|
||||
if (outputIndex !== -1 && args[outputIndex + 1]) {
|
||||
const outputPath = args[outputIndex + 1];
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(outputPath, "%PDF-1.4\n%Translated");
|
||||
}
|
||||
callback(null, "Translation complete", "");
|
||||
} else if (cmd === "tar") {
|
||||
callback(null, "Archive created", "");
|
||||
}
|
||||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
await convert(testInputFile, "pdf", "pdf-zh-TW", targetPath, undefined, mockExecFile);
|
||||
|
||||
expect(babeldocArgs).toContain("-l");
|
||||
expect(babeldocArgs).toContain("zh-Hant"); // BabelDOC uses zh-Hant for traditional Chinese
|
||||
});
|
||||
});
|
||||
|
||||
describe("BabelDOC converter - Output structure", () => {
|
||||
const testDir = "./test-output-babeldoc-structure";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(testInputFile, "%PDF-1.4\n%Test PDF content");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("should create archive with translated-<lang>.pdf", async () => {
|
||||
let tarSourceDir = "";
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (cmd === "babeldoc") {
|
||||
const outputIndex = args.indexOf("-o");
|
||||
if (outputIndex !== -1 && args[outputIndex + 1]) {
|
||||
const outputPath = args[outputIndex + 1];
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(outputPath, "%PDF-1.4\n%Translated content");
|
||||
}
|
||||
callback(null, "Translation complete", "");
|
||||
} else if (cmd === "tar") {
|
||||
// Capture the source directory for the tar archive
|
||||
const cIndex = args.indexOf("-C");
|
||||
if (cIndex !== -1 && args[cIndex + 1]) {
|
||||
tarSourceDir = args[cIndex + 1];
|
||||
}
|
||||
callback(null, "Archive created", "");
|
||||
}
|
||||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
await convert(testInputFile, "pdf", "pdf-ko", targetPath, undefined, mockExecFile);
|
||||
|
||||
// The archive source directory should end with "archive"
|
||||
expect(tarSourceDir).toContain("archive");
|
||||
});
|
||||
|
||||
test("should only use .tar format, not .tar.gz", async () => {
|
||||
let tarCommand: string[] = [];
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (cmd === "babeldoc") {
|
||||
const outputIndex = args.indexOf("-o");
|
||||
if (outputIndex !== -1 && args[outputIndex + 1]) {
|
||||
const outputPath = args[outputIndex + 1];
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(outputPath, "%PDF-1.4\n%Translated");
|
||||
}
|
||||
callback(null, "Translation complete", "");
|
||||
} else if (cmd === "tar") {
|
||||
tarCommand = args;
|
||||
callback(null, "Archive created", "");
|
||||
}
|
||||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
await convert(testInputFile, "pdf", "pdf-de", targetPath, undefined, mockExecFile);
|
||||
|
||||
// Should use -cf (no gzip) not -czf
|
||||
expect(tarCommand[0]).toBe("-cf");
|
||||
expect(tarCommand).not.toContain("-czf");
|
||||
expect(tarCommand).not.toContain("-z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BabelDOC converter - Error handling", () => {
|
||||
const testDir = "./test-output-babeldoc-error";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(testInputFile, "%PDF-1.4\n%Test PDF content");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("should reject with error when babeldoc fails", async () => {
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (cmd === "babeldoc") {
|
||||
const error = new Error("Translation failed") as ExecFileException;
|
||||
error.code = 1;
|
||||
callback(error, "", "babeldoc: Translation error");
|
||||
}
|
||||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
|
||||
await expect(
|
||||
convert(testInputFile, "pdf", "pdf-zh", targetPath, undefined, mockExecFile),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should reject when no output PDF is generated", async () => {
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (cmd === "babeldoc") {
|
||||
// Don't create output file, simulating failed translation
|
||||
callback(null, "Complete", "");
|
||||
}
|
||||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
|
||||
await expect(
|
||||
convert(testInputFile, "pdf", "pdf-zh", targetPath, undefined, mockExecFile),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BabelDOC converter - Markdown output format", () => {
|
||||
const testDir = "./test-output-babeldoc-md";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(testInputFile, "%PDF-1.4\n%Test PDF content");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("should call babeldoc with md output format", async () => {
|
||||
let babeldocArgs: string[] = [];
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (cmd === "babeldoc") {
|
||||
babeldocArgs = args;
|
||||
const outputIndex = args.indexOf("-o");
|
||||
if (outputIndex !== -1 && args[outputIndex + 1]) {
|
||||
const outputPath = args[outputIndex + 1];
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(outputPath, "# Translated Markdown Content");
|
||||
}
|
||||
callback(null, "Translation complete", "");
|
||||
} else if (cmd === "tar") {
|
||||
callback(null, "Archive created", "");
|
||||
}
|
||||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
await convert(testInputFile, "pdf", "md-zh", targetPath, undefined, mockExecFile);
|
||||
|
||||
expect(babeldocArgs).toContain("-l");
|
||||
expect(babeldocArgs).toContain("zh-Hans");
|
||||
expect(babeldocArgs).toContain("--output-format");
|
||||
expect(babeldocArgs).toContain("md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BabelDOC converter - HTML output format", () => {
|
||||
const testDir = "./test-output-babeldoc-html";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(testInputFile, "%PDF-1.4\n%Test PDF content");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("should call babeldoc with html output format", async () => {
|
||||
let babeldocArgs: string[] = [];
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (cmd === "babeldoc") {
|
||||
babeldocArgs = args;
|
||||
const outputIndex = args.indexOf("-o");
|
||||
if (outputIndex !== -1 && args[outputIndex + 1]) {
|
||||
const outputPath = args[outputIndex + 1];
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(outputPath, "<html><body>Translated HTML</body></html>");
|
||||
}
|
||||
callback(null, "Translation complete", "");
|
||||
} else if (cmd === "tar") {
|
||||
callback(null, "Archive created", "");
|
||||
}
|
||||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
await convert(testInputFile, "pdf", "html-ja", targetPath, undefined, mockExecFile);
|
||||
|
||||
expect(babeldocArgs).toContain("-l");
|
||||
expect(babeldocArgs).toContain("ja");
|
||||
expect(babeldocArgs).toContain("--output-format");
|
||||
expect(babeldocArgs).toContain("html");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue