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:
Your Name 2026-01-22 17:41:12 +08:00
parent 6d4ed58637
commit 4ca92e3a22
5 changed files with 852 additions and 19 deletions

View file

@ -234,9 +234,15 @@ RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \
# 階段 12安裝 Python 工具pipx+ huggingface_hub用於模型下載 # 階段 12安裝 Python 工具pipx+ huggingface_hub用於模型下載
# 注意Debian bookworm 使用 PEP 668需要 --break-system-packages 來安裝系統級套件 # 注意Debian bookworm 使用 PEP 668需要 --break-system-packages 來安裝系統級套件
RUN pipx install "markitdown[all]" \ RUN pipx install "markitdown[all]" \
&& pipx install "pdf2zh" \
&& pip3 install --no-cache-dir --break-system-packages huggingface_hub && pip3 install --no-cache-dir --break-system-packages huggingface_hub
# 階段 12-A安裝 pdf2zhPDFMathTranslate 引擎)
RUN pipx install "pdf2zh"
# 階段 12-B安裝 babeldocBabelDOC 引擎)
# BabelDOC 是一個 PDF 翻譯工具,與 pdf2zh 類似但使用不同的翻譯方式
RUN pipx install "babeldoc" || echo "⚠️ babeldoc 安裝失敗,跳過..."
# 階段 13安裝 mineru可能在 arm64 上有問題,加入錯誤處理) # 階段 13安裝 mineru可能在 arm64 上有問題,加入錯誤處理)
RUN pipx install "mineru[all]" || echo "⚠️ mineru 安裝失敗(可能是 arm64 相容性問題),跳過..." RUN pipx install "mineru[all]" || echo "⚠️ mineru 安裝失敗(可能是 arm64 相容性問題),跳過..."
@ -294,11 +300,16 @@ RUN mkdir -p /models/pdfmathtranslate && \
# 階段 14-BBabelDOC Warmup預載入所有資源 # 階段 14-BBabelDOC Warmup預載入所有資源
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# 說明babeldoc --warmup 會下載所有必要的字型和模型資源 # 說明babeldoc --warmup 會下載所有必要的字型和模型資源
# 這確保 pdf2zh 執行時不會有任何隱式下載 # 這確保 BabelDOC 執行時不會有任何隱式下載
# 注意:分開執行以避免記憶體壓力
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
RUN echo "📥 [2/6] 執行 BabelDOC warmup..." && \ RUN echo "📥 [2/6] 執行 BabelDOC warmup..." && \
babeldoc --warmup 2>&1 || echo "⚠️ BabelDOC warmup 可能已完成或無需 warmup" && \ if command -v babeldoc >/dev/null 2>&1; then \
echo "✅ BabelDOC warmup 完成" babeldoc --warmup 2>&1 || echo "⚠️ BabelDOC warmup 失敗或無需 warmup"; \
else \
echo "⚠️ babeldoc 命令不存在,跳過 warmup"; \
fi && \
echo "✅ BabelDOC warmup 步驟完成"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# 階段 14-CPDFMathTranslate 字型下載 # 階段 14-CPDFMathTranslate 字型下載
@ -374,6 +385,14 @@ RUN echo "📥 [6/6] 驗證模型並清理快取..." && \
echo "🔹 PDFMathTranslate 字型:" && \ echo "🔹 PDFMathTranslate 字型:" && \
ls -lh /app/*.ttf 2>/dev/null || echo " ⚠️ 無字型檔案" && \ ls -lh /app/*.ttf 2>/dev/null || echo " ⚠️ 無字型檔案" && \
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 模型目錄:" && \ echo "🔹 MinerU 模型目錄:" && \
if [ -d "/root/.cache/huggingface/hub/PDF-Extract-Kit-1.0" ]; then \ if [ -d "/root/.cache/huggingface/hub/PDF-Extract-Kit-1.0" ]; then \
echo " ✅ MinerU Pipeline 模型目錄存在"; \ echo " ✅ MinerU Pipeline 模型目錄存在"; \
@ -381,9 +400,6 @@ RUN echo "📥 [6/6] 驗證模型並清理快取..." && \
else \ else \
echo " ⚠️ MinerU Pipeline 模型目錄不存在(可能需要 runtime 下載)"; \ echo " ⚠️ MinerU Pipeline 模型目錄不存在(可能需要 runtime 下載)"; \
fi && \ fi && \
echo "" && \
echo "🔹 BabelDOC 快取:" && \
ls -la /root/.cache/babeldoc 2>/dev/null || echo " 快取位置可能不同" && \
echo "========================================" && \ echo "========================================" && \
# 清理 pip 快取(保留模型) # 清理 pip 快取(保留模型)
rm -rf /root/.cache/pip && \ rm -rf /root/.cache/pip && \
@ -393,6 +409,10 @@ RUN echo "📥 [6/6] 驗證模型並清理快取..." && \
ENV PDFMATHTRANSLATE_MODELS_PATH="/models/pdfmathtranslate" ENV PDFMATHTRANSLATE_MODELS_PATH="/models/pdfmathtranslate"
ENV NOTO_FONT_PATH="/app/GoNotoKurrent-Regular.ttf" ENV NOTO_FONT_PATH="/app/GoNotoKurrent-Regular.ttf"
# BabelDOC 環境變數
ENV BABELDOC_CACHE_PATH="/root/.cache/babeldoc"
ENV BABELDOC_SERVICE="google"
# MinerU 環境變數(強制使用本地模型) # MinerU 環境變數(強制使用本地模型)
ENV MINERU_MODEL_SOURCE="local" ENV MINERU_MODEL_SOURCE="local"
ENV HF_HUB_OFFLINE="1" ENV HF_HUB_OFFLINE="1"

352
src/converters/babeldoc.ts Normal file
View 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}`);
}
}

View file

@ -30,6 +30,7 @@ import {
convert as convertPDFMathTranslate, convert as convertPDFMathTranslate,
properties as propertiesPDFMathTranslate, properties as propertiesPDFMathTranslate,
} from "./pdfmathtranslate"; } 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 // 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, properties: propertiesPDFMathTranslate,
converter: convertPDFMathTranslate, converter: convertPDFMathTranslate,
}, },
BabelDOC: {
properties: propertiesBabelDoc,
converter: convertBabelDoc,
},
}; };
function chunks<T>(arr: T[], size: number): T[][] { function chunks<T>(arr: T[], size: number): T[][] {

View file

@ -124,8 +124,8 @@ export const user = new Elysia()
</header> </header>
<form method="post" action={`${WEBROOT}/register`} class="p-4"> <form method="post" action={`${WEBROOT}/register`} class="p-4">
<fieldset class="mb-4 flex flex-col gap-4"> <fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1" safe> <label class="flex flex-col gap-1">
{t("auth", "email")} <span safe>{t("auth", "email")}</span>
<input <input
type="email" type="email"
name="email" name="email"
@ -135,8 +135,8 @@ export const user = new Elysia()
required required
/> />
</label> </label>
<label class="flex flex-col gap-1" safe> <label class="flex flex-col gap-1">
{t("auth", "password")} <span safe>{t("auth", "password")}</span>
<input <input
type="password" type="password"
name="password" name="password"
@ -191,8 +191,8 @@ export const user = new Elysia()
<article class="article"> <article class="article">
<form method="post" class="flex flex-col gap-4"> <form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4"> <fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1" safe> <label class="flex flex-col gap-1">
{t("auth", "email")} <span safe>{t("auth", "email")}</span>
<input <input
type="email" type="email"
name="email" name="email"
@ -202,8 +202,8 @@ export const user = new Elysia()
required required
/> />
</label> </label>
<label class="flex flex-col gap-1" safe> <label class="flex flex-col gap-1">
{t("auth", "password")} <span safe>{t("auth", "password")}</span>
<input <input
type="password" type="password"
name="password" name="password"
@ -313,8 +313,8 @@ export const user = new Elysia()
<article class="article"> <article class="article">
<form method="post" class="flex flex-col gap-4"> <form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4"> <fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1" safe> <label class="flex flex-col gap-1">
{t("auth", "email")} <span safe>{t("auth", "email")}</span>
<input <input
type="email" type="email"
name="email" name="email"
@ -324,8 +324,8 @@ export const user = new Elysia()
required required
/> />
</label> </label>
<label class="flex flex-col gap-1" safe> <label class="flex flex-col gap-1">
{t("auth", "password")} <span safe>{t("auth", "password")}</span>
<input <input
type="password" type="password"
name="password" name="password"

View 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");
});
});