fix(inkscape): use headless-safe export syntax (--export-type/--export-filename)

Breaking change from Inkscape 1.0+:
- Old syntax: inkscape input.png -o output.svg (triggers GTK init)
- New syntax: inkscape input.png --export-type=svg --export-filename=output.svg

This is the correct headless-safe approach, no need for xvfb.

Ref: https://inkscape.org/doc/inkscape-man.html
This commit is contained in:
Your Name 2026-01-23 00:01:24 +08:00
parent b5992a84e0
commit 26304a295e
3 changed files with 52 additions and 80 deletions

View file

@ -167,14 +167,13 @@ RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \
# 階段 4圖像處理工具
# 注意bookworm 使用 imagemagick版本 6trixie 才有 imagemagick-7
# 注意:xvfb 用於 Inkscape 在 headless 環境運行(需要虛擬 X11 顯示器)
# 注意:Inkscape 1.0+ 使用 --export-type/--export-filename 語法,支援 headless 執行,不需要 xvfb
RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \
imagemagick \
inkscape \
libheif-examples \
libjxl-tools \
libvips-tools \
xvfb \
&& rm -rf /var/lib/apt/lists/*
# 階段 5文件處理工具

View file

@ -1,4 +1,5 @@
import { execFile as execFileOriginal } from "node:child_process";
import { extname } from "node:path";
import { ExecFileFn } from "./types";
export const properties = {
@ -32,12 +33,16 @@ export const properties = {
* Inkscape
*
* Headless
* Inkscape X11 Docker/Server
* 使 xvfb-run
* Inkscape 1.0+ headless
* X11 xvfb
*
* 🔧
* 1. 使 xvfb-run X11
* 2. xvfb-run GDK_BACKEND=svg SVG
* 🔧 headless-safe
* inkscape input.png --export-type=svg --export-filename=output.svg
*
* GTK
* inkscape input.png -o output.svg
*
* https://inkscape.org/doc/inkscape-man.html
*/
export function convert(
filePath: string,
@ -48,45 +53,18 @@ export function convert(
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
// 使用 xvfb-run 執行 Inkscape建立虛擬 X11 顯示器)
// -a: 自動尋找可用的 display number
// --server-args: 設定虛擬螢幕解析度
const xvfbArgs = [
"-a",
"--server-args=-screen 0 1024x768x24",
"inkscape",
filePath,
"-o",
targetPath,
];
// 從目標路徑取得輸出格式(移除開頭的點)
const exportType = extname(targetPath).slice(1).toLowerCase();
execFile("xvfb-run", xvfbArgs, (error: Error | null, stdout: string, stderr: string) => {
// 使用 Inkscape 1.0+ 的 headless-safe 命令列語法
// --export-type: 明確指定輸出格式
// --export-filename: 指定輸出檔案路徑
// 這種語法不會初始化 GTK因此在無 DISPLAY 的環境也能運作
const args = [filePath, `--export-type=${exportType}`, `--export-filename=${targetPath}`];
execFile("inkscape", args, (error: Error | null, stdout: string, stderr: string) => {
if (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");
},
);
reject(`error: ${error}`);
return;
}

View file

@ -3,17 +3,37 @@ import { convert } from "../../src/converters/inkscape";
import type { ExecFileException } from "node:child_process";
import { ExecFileFn } from "../../src/converters/types";
// Inkscape 使用 xvfb-run 包裝,需要自訂測試
// 模擬 xvfb-run 成功執行的情況
// Inkscape 測試
// 使用 Inkscape 1.0+ 的 headless-safe 命令列語法:
// inkscape input.png --export-type=svg --export-filename=output.svg
test("convert resolves when xvfb-run succeeds", async () => {
test("convert uses correct headless-safe arguments", async () => {
let capturedCmd = "";
let capturedArgs: string[] = [];
const mockExecFile: ExecFileFn = (
cmd: string,
args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
capturedCmd = cmd;
capturedArgs = args;
callback(null, "Conversion complete", "");
};
await convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile);
expect(capturedCmd).toBe("inkscape");
expect(capturedArgs).toEqual(["input.svg", "--export-type=png", "--export-filename=output.png"]);
});
test("convert resolves when inkscape succeeds", async () => {
const mockExecFile: ExecFileFn = (
cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
// xvfb-run 成功
if (cmd === "xvfb-run") {
if (cmd === "inkscape") {
callback(null, "Conversion complete", "");
}
};
@ -22,38 +42,13 @@ test("convert resolves when xvfb-run succeeds", async () => {
expect(result).toBe("Done");
});
test("convert falls back to direct inkscape when xvfb-run fails", async () => {
let callCount = 0;
test("convert rejects when inkscape fails", async () => {
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") {
if (cmd === "inkscape") {
callback(new Error("inkscape failed"), "", "");
}
};
@ -77,7 +72,7 @@ test("convert logs stdout when present", async () => {
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
if (cmd === "xvfb-run") {
if (cmd === "inkscape") {
callback(null, "Fake stdout", "");
}
};
@ -102,16 +97,16 @@ test("convert logs stderr when present (non-fatal warning)", async () => {
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
if (cmd === "xvfb-run") {
if (cmd === "inkscape") {
// Inkscape 經常輸出警告到 stderr但轉換仍成功
callback(null, "", "Gtk-WARNING: some warning");
callback(null, "", "Some warning message");
}
};
await convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(loggedMessage).toBe("stderr: Gtk-WARNING: some warning");
expect(loggedMessage).toBe("stderr: Some warning message");
});
test.skip("dummy - required to trigger test detection", () => {});