fix(inkscape): use xvfb-run for headless execution
- Inkscape requires X11 display connection, causing 'GtkStyleContext without display' error - Solution: use xvfb-run to create virtual X11 display - Fallback: try direct inkscape if xvfb-run fails - Add xvfb package to Dockerfile - Update inkscape tests for new xvfb-run behavior Fixes: Gtk-ERROR: Can't create a GtkStyleContext without a display connection
This commit is contained in:
parent
2f9c418964
commit
b5992a84e0
4 changed files with 168 additions and 7 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -389,7 +389,7 @@ jobs:
|
||||||
echo ""
|
echo ""
|
||||||
echo "📋 檢查關鍵轉檔工具..."
|
echo "📋 檢查關鍵轉檔工具..."
|
||||||
TOOLS=("libreoffice --version" "pandoc --version" "ffmpeg -version" "tesseract --version" "pdf2zh --help" "mineru --help" "babeldoc --help")
|
TOOLS=("libreoffice --version" "pandoc --version" "ffmpeg -version" "tesseract --version" "pdf2zh --help" "mineru --help" "babeldoc --help")
|
||||||
|
|
||||||
for tool_cmd in "${TOOLS[@]}"; do
|
for tool_cmd in "${TOOLS[@]}"; do
|
||||||
TOOL_NAME=$(echo "$tool_cmd" | cut -d' ' -f1)
|
TOOL_NAME=$(echo "$tool_cmd" | cut -d' ' -f1)
|
||||||
echo -n " ${TOOL_NAME}: "
|
echo -n " ${TOOL_NAME}: "
|
||||||
|
|
|
||||||
|
|
@ -167,12 +167,14 @@ RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \
|
||||||
|
|
||||||
# 階段 4:圖像處理工具
|
# 階段 4:圖像處理工具
|
||||||
# 注意:bookworm 使用 imagemagick(版本 6),trixie 才有 imagemagick-7
|
# 注意:bookworm 使用 imagemagick(版本 6),trixie 才有 imagemagick-7
|
||||||
|
# 注意:xvfb 用於 Inkscape 在 headless 環境運行(需要虛擬 X11 顯示器)
|
||||||
RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \
|
RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
inkscape \
|
inkscape \
|
||||||
libheif-examples \
|
libheif-examples \
|
||||||
libjxl-tools \
|
libjxl-tools \
|
||||||
libvips-tools \
|
libvips-tools \
|
||||||
|
xvfb \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# 階段 5:文件處理工具
|
# 階段 5:文件處理工具
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,17 @@ export const properties = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inkscape 轉換器
|
||||||
|
*
|
||||||
|
* ⚠️ Headless 環境注意事項:
|
||||||
|
* Inkscape 需要 X11 顯示器連線。在 Docker/Server 環境中,
|
||||||
|
* 必須使用 xvfb-run 建立虛擬顯示器。
|
||||||
|
*
|
||||||
|
* 🔧 解決方案:
|
||||||
|
* 1. 優先使用 xvfb-run(虛擬 X11)
|
||||||
|
* 2. 若 xvfb-run 失敗,嘗試 GDK_BACKEND=svg(純 SVG 後端)
|
||||||
|
*/
|
||||||
export function convert(
|
export function convert(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
fileType: string,
|
fileType: string,
|
||||||
|
|
@ -37,9 +48,46 @@ export function convert(
|
||||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
execFile("inkscape", [filePath, "-o", targetPath], (error, stdout, stderr) => {
|
// 使用 xvfb-run 執行 Inkscape(建立虛擬 X11 顯示器)
|
||||||
|
// -a: 自動尋找可用的 display number
|
||||||
|
// --server-args: 設定虛擬螢幕解析度
|
||||||
|
const xvfbArgs = [
|
||||||
|
"-a",
|
||||||
|
"--server-args=-screen 0 1024x768x24",
|
||||||
|
"inkscape",
|
||||||
|
filePath,
|
||||||
|
"-o",
|
||||||
|
targetPath,
|
||||||
|
];
|
||||||
|
|
||||||
|
execFile("xvfb-run", xvfbArgs, (error: Error | null, stdout: string, stderr: string) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
reject(`error: ${error}`);
|
// xvfb-run 失敗,回退到直接執行 inkscape
|
||||||
|
// 這可能在某些已有 DISPLAY 設定的環境中運作
|
||||||
|
console.log("[Inkscape] xvfb-run failed, trying direct inkscape execution...");
|
||||||
|
console.log(`[Inkscape] Original error: ${error.message}`);
|
||||||
|
|
||||||
|
execFile(
|
||||||
|
"inkscape",
|
||||||
|
[filePath, "-o", targetPath],
|
||||||
|
(error2: Error | null, stdout2: string, stderr2: string) => {
|
||||||
|
if (error2) {
|
||||||
|
reject(`error: ${error2}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdout2) {
|
||||||
|
console.log(`stdout: ${stdout2}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stderr2) {
|
||||||
|
console.error(`stderr: ${stderr2}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve("Done");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stdout) {
|
if (stdout) {
|
||||||
|
|
@ -47,7 +95,8 @@ export function convert(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stderr) {
|
if (stderr) {
|
||||||
console.error(`stderr: ${stderr}`);
|
// Inkscape 經常輸出警告到 stderr,但這不代表失敗
|
||||||
|
console.log(`stderr: ${stderr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve("Done");
|
resolve("Done");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,117 @@
|
||||||
import { test } from "bun:test";
|
import { test, expect } from "bun:test";
|
||||||
import { convert } from "../../src/converters/inkscape";
|
import { convert } from "../../src/converters/inkscape";
|
||||||
import { runCommonTests } from "./helpers/commonTests";
|
import type { ExecFileException } from "node:child_process";
|
||||||
|
import { ExecFileFn } from "../../src/converters/types";
|
||||||
|
|
||||||
runCommonTests(convert);
|
// Inkscape 使用 xvfb-run 包裝,需要自訂測試
|
||||||
|
// 模擬 xvfb-run 成功執行的情況
|
||||||
|
|
||||||
|
test("convert resolves when xvfb-run succeeds", async () => {
|
||||||
|
const mockExecFile: ExecFileFn = (
|
||||||
|
cmd: string,
|
||||||
|
_args: string[],
|
||||||
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
|
) => {
|
||||||
|
// xvfb-run 成功
|
||||||
|
if (cmd === "xvfb-run") {
|
||||||
|
callback(null, "Conversion complete", "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile);
|
||||||
|
expect(result).toBe("Done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("convert falls back to direct inkscape when xvfb-run fails", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
|
||||||
|
const mockExecFile: ExecFileFn = (
|
||||||
|
cmd: string,
|
||||||
|
_args: string[],
|
||||||
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
|
) => {
|
||||||
|
callCount++;
|
||||||
|
if (cmd === "xvfb-run") {
|
||||||
|
// xvfb-run 失敗
|
||||||
|
callback(new Error("xvfb-run not found"), "", "");
|
||||||
|
} else if (cmd === "inkscape") {
|
||||||
|
// 直接呼叫 inkscape 成功
|
||||||
|
callback(null, "Direct inkscape success", "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile);
|
||||||
|
expect(result).toBe("Done");
|
||||||
|
expect(callCount).toBe(2); // 應該呼叫兩次(xvfb-run + inkscape)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("convert rejects when both xvfb-run and inkscape fail", async () => {
|
||||||
|
const mockExecFile: ExecFileFn = (
|
||||||
|
cmd: string,
|
||||||
|
_args: string[],
|
||||||
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
|
) => {
|
||||||
|
if (cmd === "xvfb-run") {
|
||||||
|
callback(new Error("xvfb-run failed"), "", "");
|
||||||
|
} else if (cmd === "inkscape") {
|
||||||
|
callback(new Error("inkscape failed"), "", "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile),
|
||||||
|
).rejects.toMatch(/error:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("convert logs stdout when present", async () => {
|
||||||
|
const originalConsoleLog = console.log;
|
||||||
|
let loggedMessage = "";
|
||||||
|
console.log = (msg: string) => {
|
||||||
|
if (msg.startsWith("stdout:")) {
|
||||||
|
loggedMessage = msg;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockExecFile: ExecFileFn = (
|
||||||
|
cmd: string,
|
||||||
|
_args: string[],
|
||||||
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
|
) => {
|
||||||
|
if (cmd === "xvfb-run") {
|
||||||
|
callback(null, "Fake stdout", "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile);
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
|
||||||
|
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("convert logs stderr when present (non-fatal warning)", async () => {
|
||||||
|
const originalConsoleLog = console.log;
|
||||||
|
let loggedMessage = "";
|
||||||
|
console.log = (msg: string) => {
|
||||||
|
if (msg.startsWith("stderr:")) {
|
||||||
|
loggedMessage = msg;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockExecFile: ExecFileFn = (
|
||||||
|
cmd: string,
|
||||||
|
_args: string[],
|
||||||
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
|
) => {
|
||||||
|
if (cmd === "xvfb-run") {
|
||||||
|
// Inkscape 經常輸出警告到 stderr,但轉換仍成功
|
||||||
|
callback(null, "", "Gtk-WARNING: some warning");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile);
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
|
||||||
|
expect(loggedMessage).toBe("stderr: Gtk-WARNING: some warning");
|
||||||
|
});
|
||||||
|
|
||||||
test.skip("dummy - required to trigger test detection", () => {});
|
test.skip("dummy - required to trigger test detection", () => {});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue