fix(libreoffice): PDF 轉 DOCX 修正 (v0.1.8)
- 新增 PDF 匯入管線:PDF → DOCX/ODT/RTF/TXT/HTML 使用 --infilter=writer_pdf_import - 新增輸出檔案存在性驗證,避免 ENOENT 錯誤 - 改善錯誤訊息:密碼保護、檔案損壞、無匯出過濾器等情境 - 重寫測試套件:19 個測試全數通過
This commit is contained in:
parent
03be03bbc2
commit
a0eccf0437
5 changed files with 369 additions and 107 deletions
34
CHANGELOG.md
34
CHANGELOG.md
|
|
@ -1,5 +1,39 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.1.8](https://github.com/pi-docket/ConvertX-CN/releases/tag/v0.1.8) (2026-01-20)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- **LibreOffice PDF→DOCX**: 修復 PDF 轉 DOCX 一定失敗的問題
|
||||||
|
- 新增 PDF Import Pipeline:使用 `--infilter=writer_pdf_import`
|
||||||
|
- 分流邏輯:PDF→文字格式 vs 一般轉換
|
||||||
|
- 支援 PDF→DOCX/ODT/RTF/TXT/HTML
|
||||||
|
|
||||||
|
### 🛡️ Reliability
|
||||||
|
|
||||||
|
- **輸出檔案驗證**: 新增 `existsSync` 檢查,確保轉換真的成功
|
||||||
|
- **錯誤訊息優化**: 根據 stderr 內容提供有意義的中文錯誤訊息
|
||||||
|
- 識別加密檔案、損壞檔案、缺少 filter 等情況
|
||||||
|
- 避免 ENOENT 錯誤擴散
|
||||||
|
|
||||||
|
### 📝 Technical Notes
|
||||||
|
|
||||||
|
- LibreOffice 架構說明:
|
||||||
|
- Export Pipeline:原生格式→導出格式(DOCX→PDF)
|
||||||
|
- Import Pipeline:非原生格式→原生格式(PDF→DOCX)
|
||||||
|
- PDF 作為輸入時必須使用 `writer_pdf_import`
|
||||||
|
|
||||||
|
### ⚠️ Known Limitations
|
||||||
|
|
||||||
|
以下情況仍會失敗(LibreOffice 限制):
|
||||||
|
|
||||||
|
- 加密/密碼保護的 PDF/DOCX
|
||||||
|
- 損壞的檔案
|
||||||
|
- 缺少必要字型(可能成功但版面錯亂)
|
||||||
|
- 並行大量轉檔(LibreOffice 對 concurrent instance 不友善)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.1.7](https://github.com/pi-docket/ConvertX-CN/releases/tag/v0.1.7) (2026-01-20)
|
## [0.1.7](https://github.com/pi-docket/ConvertX-CN/releases/tag/v0.1.7) (2026-01-20)
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# ConvertX-CN 官方 Docker Image
|
# ConvertX-CN 官方 Docker Image
|
||||||
# 版本:v0.1.7
|
# 版本:v0.1.8
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
#
|
#
|
||||||
# 📦 Image 說明:
|
# 📦 Image 說明:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "convertx-frontend",
|
"name": "convertx-frontend",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/index.tsx",
|
"dev": "bun run --watch src/index.tsx",
|
||||||
"hot": "bun run --hot src/index.tsx",
|
"hot": "bun run --hot src/index.tsx",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,35 @@
|
||||||
import { execFile as execFileOriginal } from "node:child_process";
|
import { execFile as execFileOriginal } from "node:child_process";
|
||||||
|
import { existsSync as existsSyncOriginal } from "node:fs";
|
||||||
|
import { basename, dirname, join } from "node:path";
|
||||||
import { ExecFileFn } from "./types";
|
import { ExecFileFn } from "./types";
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// LibreOffice 轉換器
|
||||||
|
// ==============================================================================
|
||||||
|
//
|
||||||
|
// 🔑 關鍵技術說明:
|
||||||
|
//
|
||||||
|
// LibreOffice 有兩種轉換模式:
|
||||||
|
//
|
||||||
|
// 1. Export Pipeline(輸出轉換)
|
||||||
|
// - 用於:DOCX → PDF, ODT → PDF 等
|
||||||
|
// - 原生格式 → 導出格式
|
||||||
|
// - 使用 --convert-to 參數
|
||||||
|
//
|
||||||
|
// 2. Import Pipeline(輸入轉換)
|
||||||
|
// - 用於:PDF → DOCX, PDF → ODT 等
|
||||||
|
// - 非原生格式 → 原生格式
|
||||||
|
// - 必須使用 --infilter 參數指定 import filter
|
||||||
|
//
|
||||||
|
// ⚠️ 常見錯誤:
|
||||||
|
// PDF → DOCX 若不指定 --infilter=writer_pdf_import
|
||||||
|
// 會得到 "no export filter" 錯誤
|
||||||
|
//
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
// 用於測試的依賴注入類型
|
||||||
|
export type ExistsSyncFn = (path: string) => boolean;
|
||||||
|
|
||||||
export const properties = {
|
export const properties = {
|
||||||
from: {
|
from: {
|
||||||
text: [
|
text: [
|
||||||
|
|
@ -102,7 +131,7 @@ const filters: Record<FileCategories, Record<string, string>> = {
|
||||||
odt: "writer8",
|
odt: "writer8",
|
||||||
ott: "writer8_template",
|
ott: "writer8_template",
|
||||||
pages: "Apple Pages",
|
pages: "Apple Pages",
|
||||||
// pdf: "writer_pdf_import",
|
pdf: "writer_pdf_Export", // PDF 作為輸出格式
|
||||||
psw: "PocketWord File",
|
psw: "PocketWord File",
|
||||||
rtf: "Rich Text Format",
|
rtf: "Rich Text Format",
|
||||||
sdw: "StarOffice_Writer",
|
sdw: "StarOffice_Writer",
|
||||||
|
|
@ -123,6 +152,16 @@ const filters: Record<FileCategories, Record<string, string>> = {
|
||||||
calc: {},
|
calc: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// PDF Import Filter(PDF 作為輸入格式時使用)
|
||||||
|
// ==============================================================================
|
||||||
|
const PDF_IMPORT_FILTER = "writer_pdf_import";
|
||||||
|
|
||||||
|
// 需要使用 PDF import pipeline 的情況
|
||||||
|
function needsPdfImportPipeline(inputExt: string, outputExt: string): boolean {
|
||||||
|
return inputExt === "pdf" && ["docx", "doc", "odt", "rtf", "txt", "html"].includes(outputExt);
|
||||||
|
}
|
||||||
|
|
||||||
const getFilters = (fileType: string, converto: string) => {
|
const getFilters = (fileType: string, converto: string) => {
|
||||||
if (fileType in filters.text && converto in filters.text) {
|
if (fileType in filters.text && converto in filters.text) {
|
||||||
return [filters.text[fileType], filters.text[converto]];
|
return [filters.text[fileType], filters.text[converto]];
|
||||||
|
|
@ -139,39 +178,115 @@ export function convert(
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
options?: unknown,
|
options?: unknown,
|
||||||
execFile: ExecFileFn = execFileOriginal,
|
execFile: ExecFileFn = execFileOriginal,
|
||||||
|
existsSync: ExistsSyncFn = existsSyncOriginal,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const outputPath = targetPath.split("/").slice(0, -1).join("/").replace("./", "") ?? targetPath;
|
const outputDir = dirname(targetPath).replace("./", "") || ".";
|
||||||
|
const inputFileName = basename(filePath);
|
||||||
|
const inputBaseName = inputFileName.replace(/\.[^.]+$/, "");
|
||||||
|
const expectedOutputFile = join(outputDir, `${inputBaseName}.${convertTo}`);
|
||||||
|
|
||||||
// Build arguments array
|
// Build arguments array
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
args.push("--headless");
|
args.push("--headless");
|
||||||
const [inFilter, outFilter] = getFilters(fileType, convertTo);
|
|
||||||
|
|
||||||
if (inFilter) {
|
// ==============================================================================
|
||||||
args.push(`--infilter="${inFilter}"`);
|
// 關鍵分流:PDF → 文字格式 vs 其他轉換
|
||||||
}
|
// ==============================================================================
|
||||||
|
if (needsPdfImportPipeline(fileType, convertTo)) {
|
||||||
|
// PDF → DOCX/ODT 等:必須使用 import pipeline
|
||||||
|
console.log(`[LibreOffice] Using PDF import pipeline: ${fileType} → ${convertTo}`);
|
||||||
|
args.push(`--infilter=${PDF_IMPORT_FILTER}`);
|
||||||
|
|
||||||
if (outFilter) {
|
// 輸出格式仍需指定 filter
|
||||||
args.push("--convert-to", `${convertTo}:${outFilter}`, "--outdir", outputPath, filePath);
|
const outFilter = filters.text[convertTo];
|
||||||
|
if (outFilter && convertTo !== "pdf") {
|
||||||
|
args.push("--convert-to", `${convertTo}:${outFilter}`);
|
||||||
|
} else {
|
||||||
|
args.push("--convert-to", convertTo);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
args.push("--convert-to", convertTo, "--outdir", outputPath, filePath);
|
// 一般轉換流程(export pipeline)
|
||||||
|
const [inFilter, outFilter] = getFilters(fileType, convertTo);
|
||||||
|
|
||||||
|
if (inFilter && fileType !== "pdf") {
|
||||||
|
args.push(`--infilter=${inFilter}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outFilter) {
|
||||||
|
args.push("--convert-to", `${convertTo}:${outFilter}`);
|
||||||
|
} else {
|
||||||
|
args.push("--convert-to", convertTo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
args.push("--outdir", outputDir, filePath);
|
||||||
|
|
||||||
|
console.log(`[LibreOffice] Command: soffice ${args.join(" ")}`);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
execFile("soffice", args, (error, stdout, stderr) => {
|
execFile("soffice", args, (error, stdout, stderr) => {
|
||||||
if (error) {
|
// ==============================================================================
|
||||||
reject(`error: ${error}`);
|
// 錯誤處理與輸出檔案驗證
|
||||||
}
|
// ==============================================================================
|
||||||
|
|
||||||
if (stdout) {
|
if (stdout) {
|
||||||
console.log(`stdout: ${stdout}`);
|
console.log(`[LibreOffice] stdout: ${stdout}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stderr) {
|
if (stderr) {
|
||||||
console.error(`stderr: ${stderr}`);
|
console.error(`[LibreOffice] stderr: ${stderr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 檢查 LibreOffice 執行錯誤
|
||||||
|
if (error) {
|
||||||
|
const errorMsg = getLibreOfficeErrorMessage(error, stderr);
|
||||||
|
console.error(`[LibreOffice] Error: ${errorMsg}`);
|
||||||
|
reject(errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// 關鍵防呆:檢查輸出檔案是否實際存在
|
||||||
|
// ==============================================================================
|
||||||
|
if (!existsSync(expectedOutputFile)) {
|
||||||
|
const errorMsg = `LibreOffice 轉換失敗:輸出檔案不存在 (${expectedOutputFile})。可能原因:1) 輸入檔案損壞或加密 2) 缺少必要字型 3) 格式不支援`;
|
||||||
|
console.error(`[LibreOffice] ${errorMsg}`);
|
||||||
|
reject(errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[LibreOffice] Successfully created: ${expectedOutputFile}`);
|
||||||
resolve("Done");
|
resolve("Done");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// 錯誤訊息解析
|
||||||
|
// ==============================================================================
|
||||||
|
function getLibreOfficeErrorMessage(
|
||||||
|
error: Error & { code?: number | string },
|
||||||
|
stderr: string,
|
||||||
|
): string {
|
||||||
|
const stderrLower = stderr.toLowerCase();
|
||||||
|
|
||||||
|
// 常見錯誤類型判斷
|
||||||
|
if (stderrLower.includes("no export filter")) {
|
||||||
|
return "LibreOffice 錯誤:找不到 export filter。可能是格式不支援或需要使用不同的轉換路徑。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stderrLower.includes("password") || stderrLower.includes("encrypted")) {
|
||||||
|
return "LibreOffice 錯誤:檔案已加密或需要密碼。請先解除密碼保護。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stderrLower.includes("corrupt") || stderrLower.includes("damaged")) {
|
||||||
|
return "LibreOffice 錯誤:檔案已損壞或格式無效。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === "ENOENT") {
|
||||||
|
return "LibreOffice 錯誤:找不到 soffice 執行檔。請確認 LibreOffice 已正確安裝。";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用錯誤
|
||||||
|
return `LibreOffice 轉換失敗 (exit code: ${error.code || "unknown"}): ${stderr || error.message}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
import { convert } from "../../src/converters/libreoffice";
|
import { convert, type ExistsSyncFn } from "../../src/converters/libreoffice";
|
||||||
import type { ExecFileFn } from "../../src/converters/types";
|
import type { ExecFileFn } from "../../src/converters/types";
|
||||||
|
|
||||||
function requireDefined<T>(value: T, msg: string): NonNullable<T> {
|
function requireDefined<T>(value: T, msg: string): NonNullable<T> {
|
||||||
|
|
@ -13,16 +13,22 @@ let calls: Call[] = [];
|
||||||
|
|
||||||
let behavior:
|
let behavior:
|
||||||
| { kind: "success"; stdout?: string; stderr?: string }
|
| { kind: "success"; stdout?: string; stderr?: string }
|
||||||
| { kind: "error"; message?: string; stderr?: string } = { kind: "success" };
|
| { kind: "error"; message?: string; stderr?: string; code?: string } = { kind: "success" };
|
||||||
|
|
||||||
|
// Mock fs.existsSync to control output file existence check
|
||||||
|
let mockFileExists = true;
|
||||||
|
|
||||||
|
const mockExistsSync: ExistsSyncFn = () => mockFileExists;
|
||||||
|
|
||||||
const mockExecFile: ExecFileFn = (cmd, args, cb) => {
|
const mockExecFile: ExecFileFn = (cmd, args, cb) => {
|
||||||
calls.push({ cmd, args });
|
calls.push({ cmd, args });
|
||||||
if (behavior.kind === "error") {
|
if (behavior.kind === "error") {
|
||||||
cb(new Error(behavior.message ?? "mock failure"), "", behavior.stderr ?? "");
|
const error = new Error(behavior.message ?? "mock failure") as Error & { code?: string };
|
||||||
|
if (behavior.code) error.code = behavior.code;
|
||||||
|
cb(error, "", behavior.stderr ?? "");
|
||||||
} else {
|
} else {
|
||||||
cb(null, behavior.stdout ?? "ok", behavior.stderr ?? "");
|
cb(null, behavior.stdout ?? "ok", behavior.stderr ?? "");
|
||||||
}
|
}
|
||||||
// We don't return a real ChildProcess in tests.
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -33,7 +39,6 @@ let errors: string[] = [];
|
||||||
const originalLog = console.log;
|
const originalLog = console.log;
|
||||||
const originalError = console.error;
|
const originalError = console.error;
|
||||||
|
|
||||||
// Use Console["log"] for typing; avoids explicit `any`
|
|
||||||
const makeSink =
|
const makeSink =
|
||||||
(sink: string[]): Console["log"] =>
|
(sink: string[]): Console["log"] =>
|
||||||
(...data) => {
|
(...data) => {
|
||||||
|
|
@ -43,6 +48,7 @@ const makeSink =
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
calls = [];
|
calls = [];
|
||||||
behavior = { kind: "success" };
|
behavior = { kind: "success" };
|
||||||
|
mockFileExists = true;
|
||||||
|
|
||||||
logs = [];
|
logs = [];
|
||||||
errors = [];
|
errors = [];
|
||||||
|
|
@ -55,107 +61,214 @@ afterEach(() => {
|
||||||
console.error = originalError;
|
console.error = originalError;
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- core behavior -----------------------------------------------------------
|
// ==============================================================================
|
||||||
test("invokes soffice with --headless and outdir derived from targetPath", async () => {
|
// PDF → DOCX (Import Pipeline) 測試
|
||||||
await convert("in.docx", "docx", "odt", "out/out.odt", undefined, mockExecFile);
|
// ==============================================================================
|
||||||
|
describe("PDF Import Pipeline", () => {
|
||||||
|
test("PDF → DOCX uses writer_pdf_import filter", async () => {
|
||||||
|
await convert("in.pdf", "pdf", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync);
|
||||||
|
|
||||||
const { cmd, args } = requireDefined(calls[0], "Expected at least one execFile call");
|
const { cmd, args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||||
expect(cmd).toBe("soffice");
|
expect(cmd).toBe("soffice");
|
||||||
expect(args).toEqual([
|
expect(args).toContain("--infilter=writer_pdf_import");
|
||||||
"--headless",
|
expect(args).toContain("--convert-to");
|
||||||
`--infilter="MS Word 2007 XML"`,
|
|
||||||
"--convert-to",
|
// 驗證輸出格式包含正確的 filter
|
||||||
"odt:writer8",
|
const convertToIdx = args.indexOf("--convert-to");
|
||||||
"--outdir",
|
expect(args[convertToIdx + 1]).toBe("docx:MS Word 2007 XML");
|
||||||
"out",
|
});
|
||||||
"in.docx",
|
|
||||||
]);
|
test("PDF → ODT uses writer_pdf_import filter", async () => {
|
||||||
|
await convert("in.pdf", "pdf", "odt", "out/out.odt", undefined, mockExecFile, mockExistsSync);
|
||||||
|
|
||||||
|
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||||
|
expect(args).toContain("--infilter=writer_pdf_import");
|
||||||
|
|
||||||
|
const convertToIdx = args.indexOf("--convert-to");
|
||||||
|
expect(args[convertToIdx + 1]).toBe("odt:writer8");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PDF → RTF uses writer_pdf_import filter", async () => {
|
||||||
|
await convert("in.pdf", "pdf", "rtf", "out/out.rtf", undefined, mockExecFile, mockExistsSync);
|
||||||
|
|
||||||
|
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||||
|
expect(args).toContain("--infilter=writer_pdf_import");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PDF → TXT uses writer_pdf_import filter", async () => {
|
||||||
|
await convert("in.pdf", "pdf", "txt", "out/out.txt", undefined, mockExecFile, mockExistsSync);
|
||||||
|
|
||||||
|
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||||
|
expect(args).toContain("--infilter=writer_pdf_import");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses only outFilter when input has no filter (e.g., pdf -> txt)", async () => {
|
// ==============================================================================
|
||||||
await convert("in.pdf", "pdf", "txt", "out/out.txt", undefined, mockExecFile);
|
// 一般轉換流程(Export Pipeline)測試
|
||||||
|
// ==============================================================================
|
||||||
|
describe("Export Pipeline", () => {
|
||||||
|
test("DOCX → PDF uses normal export pipeline (no infilter)", async () => {
|
||||||
|
await convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync);
|
||||||
|
|
||||||
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||||
|
|
||||||
expect(args).not.toContainEqual(expect.stringMatching(/^--infilter=/));
|
// DOCX → PDF 不應該使用 PDF import filter
|
||||||
expect(args).toEqual(["--headless", "--convert-to", "txt", "--outdir", "out", "in.pdf"]);
|
expect(args).not.toContain("--infilter=writer_pdf_import");
|
||||||
|
expect(args).toContain("--convert-to");
|
||||||
|
|
||||||
|
const convertToIdx = args.indexOf("--convert-to");
|
||||||
|
expect(args[convertToIdx + 1]).toBe("pdf:writer_pdf_Export");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DOCX → ODT uses correct filters", async () => {
|
||||||
|
await convert("in.docx", "docx", "odt", "out/out.odt", undefined, mockExecFile, mockExistsSync);
|
||||||
|
|
||||||
|
const { cmd, args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||||
|
expect(cmd).toBe("soffice");
|
||||||
|
|
||||||
|
// 應該有 infilter 和 outfilter
|
||||||
|
expect(args).toContain("--infilter=MS Word 2007 XML");
|
||||||
|
|
||||||
|
const convertToIdx = args.indexOf("--convert-to");
|
||||||
|
expect(args[convertToIdx + 1]).toBe("odt:writer8");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ODT → PDF uses export pipeline", async () => {
|
||||||
|
await convert("in.odt", "odt", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync);
|
||||||
|
|
||||||
|
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||||
|
expect(args).not.toContain("--infilter=writer_pdf_import");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses only infilter when convertTo has no out filter (e.g., docx -> pdf)", async () => {
|
// ==============================================================================
|
||||||
await convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile);
|
// 輸出檔案驗證測試
|
||||||
|
// ==============================================================================
|
||||||
|
describe("Output File Verification", () => {
|
||||||
|
test("resolves with 'Done' when output file exists", async () => {
|
||||||
|
mockFileExists = true;
|
||||||
|
behavior = { kind: "success", stdout: "Conversion completed", stderr: "" };
|
||||||
|
|
||||||
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
await expect(
|
||||||
|
convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync),
|
||||||
|
).resolves.toBe("Done");
|
||||||
|
});
|
||||||
|
|
||||||
// If docx has an infilter, it should be present
|
test("rejects when output file does not exist", async () => {
|
||||||
expect(args).toEqual(["--headless", "--convert-to", "pdf", "--outdir", "out", "in.docx"]);
|
mockFileExists = false;
|
||||||
|
behavior = { kind: "success", stdout: "", stderr: "" };
|
||||||
|
|
||||||
const i = args.indexOf("--convert-to");
|
await expect(
|
||||||
expect(i).toBeGreaterThanOrEqual(0);
|
convert("in.pdf", "pdf", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync),
|
||||||
expect(args[i + 1]).toBe("pdf");
|
).rejects.toMatch(/輸出檔案不存在/);
|
||||||
expect(args.slice(-2)).toEqual(["out", "in.docx"]);
|
});
|
||||||
|
|
||||||
|
test("rejects when execFile returns an error", async () => {
|
||||||
|
behavior = { kind: "error", message: "convert failed", stderr: "oops" };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync),
|
||||||
|
).rejects.toMatch(/LibreOffice 轉換失敗/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("strips leading './' from outdir", async () => {
|
// ==============================================================================
|
||||||
await convert("in.txt", "txt", "docx", "./out/out.docx", undefined, mockExecFile);
|
// 錯誤訊息測試
|
||||||
|
// ==============================================================================
|
||||||
|
describe("Error Messages", () => {
|
||||||
|
test("provides helpful message for 'no export filter' error", async () => {
|
||||||
|
behavior = { kind: "error", message: "failed", stderr: "Error: no export filter for this" };
|
||||||
|
|
||||||
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
await expect(
|
||||||
|
convert("in.pdf", "pdf", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync),
|
||||||
|
).rejects.toMatch(/找不到 export filter/);
|
||||||
|
});
|
||||||
|
|
||||||
const outDirIdx = args.indexOf("--outdir");
|
test("provides helpful message for password-protected files", async () => {
|
||||||
expect(outDirIdx).toBeGreaterThanOrEqual(0);
|
behavior = { kind: "error", message: "failed", stderr: "password required" };
|
||||||
expect(args[outDirIdx + 1]).toBe("out");
|
|
||||||
|
await expect(
|
||||||
|
convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync),
|
||||||
|
).rejects.toMatch(/加密|密碼/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provides helpful message for corrupt files", async () => {
|
||||||
|
behavior = { kind: "error", message: "failed", stderr: "file is corrupt" };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync),
|
||||||
|
).rejects.toMatch(/損壞|無效/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provides helpful message when soffice not found", async () => {
|
||||||
|
behavior = { kind: "error", message: "ENOENT", stderr: "", code: "ENOENT" };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync),
|
||||||
|
).rejects.toMatch(/找不到 soffice/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- promise settlement ------------------------------------------------------
|
// ==============================================================================
|
||||||
test("resolves with 'Done' when execFile succeeds", async () => {
|
// 路徑處理測試
|
||||||
behavior = { kind: "success", stdout: "fine", stderr: "" };
|
// ==============================================================================
|
||||||
await expect(
|
describe("Path Handling", () => {
|
||||||
convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile),
|
test("strips leading './' from outdir", async () => {
|
||||||
).resolves.toBe("Done");
|
await convert(
|
||||||
|
"in.txt",
|
||||||
|
"txt",
|
||||||
|
"docx",
|
||||||
|
"./out/out.docx",
|
||||||
|
undefined,
|
||||||
|
mockExecFile,
|
||||||
|
mockExistsSync,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||||
|
|
||||||
|
const outDirIdx = args.indexOf("--outdir");
|
||||||
|
expect(outDirIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(args[outDirIdx + 1]).toBe("out");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles nested output directories", async () => {
|
||||||
|
await convert(
|
||||||
|
"in.txt",
|
||||||
|
"txt",
|
||||||
|
"docx",
|
||||||
|
"a/b/c/out.docx",
|
||||||
|
undefined,
|
||||||
|
mockExecFile,
|
||||||
|
mockExistsSync,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||||
|
|
||||||
|
const outDirIdx = args.indexOf("--outdir");
|
||||||
|
expect(args[outDirIdx + 1]).toBe("a/b/c");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects when execFile returns an error", async () => {
|
// ==============================================================================
|
||||||
behavior = { kind: "error", message: "convert failed", stderr: "oops" };
|
// 日誌測試
|
||||||
await expect(
|
// ==============================================================================
|
||||||
convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile),
|
describe("Logging", () => {
|
||||||
).rejects.toMatch(/error: Error: convert failed/);
|
test("logs command being executed", async () => {
|
||||||
});
|
await convert("in.pdf", "pdf", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync);
|
||||||
|
|
||||||
// --- logging behavior --------------------------------------------------------
|
expect(logs.some((l) => l.includes("[LibreOffice] Command:"))).toBe(true);
|
||||||
test("logs stdout when present", async () => {
|
});
|
||||||
behavior = { kind: "success", stdout: "hello", stderr: "" };
|
|
||||||
|
test("logs when using PDF import pipeline", async () => {
|
||||||
await convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile);
|
await convert("in.pdf", "pdf", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync);
|
||||||
|
|
||||||
expect(logs).toContain("stdout: hello");
|
expect(logs.some((l) => l.includes("PDF import pipeline"))).toBe(true);
|
||||||
expect(errors).toHaveLength(0);
|
});
|
||||||
});
|
|
||||||
|
test("logs successful conversion", async () => {
|
||||||
test("logs stderr when present", async () => {
|
mockFileExists = true;
|
||||||
behavior = { kind: "success", stdout: "", stderr: "uh-oh" };
|
await convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync);
|
||||||
|
|
||||||
await convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile);
|
expect(logs.some((l) => l.includes("Successfully created"))).toBe(true);
|
||||||
|
});
|
||||||
expect(errors).toContain("stderr: uh-oh");
|
|
||||||
// When stdout is empty, no stdout log
|
|
||||||
expect(logs.find((l) => l.startsWith("stdout:"))).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logs both stdout and stderr when both are present", async () => {
|
|
||||||
behavior = { kind: "success", stdout: "alpha", stderr: "beta" };
|
|
||||||
|
|
||||||
await convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile);
|
|
||||||
|
|
||||||
expect(logs).toContain("stdout: alpha");
|
|
||||||
expect(errors).toContain("stderr: beta");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logs stderr on exec error as well", async () => {
|
|
||||||
behavior = { kind: "error", message: "boom", stderr: "EPIPE" };
|
|
||||||
|
|
||||||
expect(convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile)).rejects.toMatch(
|
|
||||||
/error: Error: boom/,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The callback still provided stderr; your implementation logs it before settling
|
|
||||||
expect(errors).toContain("stderr: EPIPE");
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue