diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b1a985..3307ee0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -242,6 +242,7 @@ jobs: potrace --version 2>/dev/null | head -1 || echo "potrace: N/A" dasel --version 2>/dev/null || echo "dasel: N/A" djxl --version 2>&1 | head -1 || echo "djxl: N/A" + deark -version 2>/dev/null | head -1 || echo "deark: N/A" echo "" echo "========================================" @@ -327,7 +328,7 @@ jobs: # 使用已啟動的 verify-test 容器 # 🔥 關鍵:所有 GUI 工具都使用 xvfb-run 包裹 - + FAILED=0 # 測試 1: Inkscape SVG → PNG(使用 xvfb-run) diff --git a/Dockerfile b/Dockerfile index a652696..1f05d44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -174,6 +174,26 @@ RUN echo "" && \ echo " ✅ resvg 安裝完成"; \ fi +# 階段 2.3:安裝 deark(從源碼編譯) +# deark 是一個用於解碼和轉換各種二進位格式的工具 +# @see https://github.com/jsummers/deark +RUN echo "" && \ + echo " 🔧 階段 2.3:安裝 deark..." && \ + apt-get update --fix-missing && apt-get install -y --no-install-recommends \ + build-essential \ + git \ + && cd /tmp && \ + git clone --depth 1 https://github.com/jsummers/deark.git && \ + cd deark && \ + make -j$(nproc) && \ + cp deark /usr/local/bin/deark && \ + chmod +x /usr/local/bin/deark && \ + cd / && rm -rf /tmp/deark && \ + apt-get remove -y build-essential git && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* && \ + echo " ✅ deark 安裝完成" + # 階段 3:影音處理工具 RUN echo "" && \ echo "========================================" && \ diff --git a/docs/功能說明/轉換器.md b/docs/功能說明/轉換器.md index de9dc55..7b134de 100644 --- a/docs/功能說明/轉換器.md +++ b/docs/功能說明/轉換器.md @@ -1,6 +1,6 @@ # 支援的轉換器 -ConvertX-CN 完整版已內建 24 種轉換器,支援 1000+ 種格式。 +ConvertX-CN 完整版已內建 25 種轉換器,支援 1000+ 種格式。 --- @@ -32,6 +32,7 @@ ConvertX-CN 完整版已內建 24 種轉換器,支援 1000+ 種格式。 | PDFMathTranslate | PDF 翻譯 | 1 | 15 | | BabelDOC | PDF 翻譯 | 1 | 45 | | OCRmyPDF | PDF OCR | 1 | 8 | +| **deark** | 解包/解析 | 100+ | 1 | --- @@ -467,7 +468,57 @@ ConvertX-CN 完整版已預載: | **電子書** | Calibre | | **3D 模型** | Assimp | | **PDF 工具** | MinerU、PDFMathTranslate、BabelDOC、OCRmyPDF | -| **其他** | Ghostscript、MuPDF、Poppler、libheif、libjxl | +| **其他** | Ghostscript、MuPDF、Poppler、libheif、libjxl、deark | + +--- + +## 檔案解包/解析 + +### deark + +deark 是一個命令列工具,用於解碼和轉換各種二進位格式。它可以解包壓縮檔案、分析舊格式圖片、提取嵌入資源等。 + +> 📦 **輸出格式**:`.tar` 封裝,包含所有解包出的檔案 + +**主要功能:** +- 解包壓縮檔案(ZIP、LHA、ARC 等舊格式) +- 轉換舊圖片格式(ICO、PCX、BMP 等) +- 提取可執行檔資源(EXE、DLL 內嵌圖示) +- 分析字型檔案(FON、FNT、PSF) + +
+輸入格式(100+ 種) + +**壓縮/封存格式:** +`zip`, `lha`, `lzh`, `arc`, `arj`, `zoo`, `z`, `gz`, `bz2`, `xz`, `cab`, `sit`, `sitx`, `stuffit`, `hqx`, `bin`, `macbin`, `cpio`, `rpm`, `deb`, `ar`, `a` + +**圖片格式:** +`ico`, `cur`, `ani`, `icns`, `bmp`, `dib`, `pcx`, `dcx`, `pict`, `pic`, `pct`, `wmf`, `emf`, `gem`, `img`, `mac`, `msp`, `iff`, `ilbm`, `lbm`, `ham`, `xbm`, `xpm`, `ras`, `sun`, `tga`, `vst`, `icb`, `vda`, `sgi`, `rgb`, `rgba`, `bw`, `int`, `inta`, `psd`, `xcf`, `ora`, `kra`, `psp`, `jbig`, `jbg`, `fpx` + +**字型格式:** +`fon`, `fnt`, `psf`, `bdf`, `pcf` + +**可執行檔/資源檔:** +`exe`, `dll`, `com`, `ne`, `mz`, `res`, `rsrc`, `icl` + +**其他格式:** +`swf`, `fla`, `amiga`, `adf`, `dms`, `d64`, `t64`, `prg`, `crt`, `tap`, `tzx`, `wav`, `riff`, `avi`, `ani` + +
+ +| 輸出格式 | 說明 | +| --------- | ---------------------------- | +| `extract` | 解包所有內容(輸出為 .tar) | + +**使用範例:** +1. 上傳 `.ico` 檔案 +2. 選擇 `deark` 轉換器 +3. 選擇 `extract` 輸出格式 +4. 下載 `.tar` 封裝(包含所有解包的 PNG 圖片) + +**參考資料:** +- [deark 官方網站](https://entropymine.com/deark/) +- [deark GitHub](https://github.com/jsummers/deark) --- diff --git a/src/converters/deark.ts b/src/converters/deark.ts new file mode 100644 index 0000000..cdfbc22 --- /dev/null +++ b/src/converters/deark.ts @@ -0,0 +1,286 @@ +import { execFile as execFileOriginal } from "node:child_process"; +import { mkdirSync, existsSync, readdirSync, unlinkSync, rmdirSync, statSync } from "node:fs"; +import { join, basename, dirname } from "node:path"; +import { ExecFileFn } from "./types"; + +/** + * deark 轉換器 + * + * deark 是一個用於解碼和轉換各種二進位格式的工具: + * - 解包壓縮檔案(ZIP、LHA、ARC 等) + * - 解析舊格式圖片(BMP、ICO、PCX、GIF 等) + * - 提取嵌入資源(EXE、遊戲檔案等) + * + * @see https://github.com/jsummers/deark + * @see https://entropymine.com/deark/ + */ + +export const properties = { + from: { + // 壓縮/封存格式 + archive: [ + "zip", + "lha", + "lzh", + "arc", + "arj", + "zoo", + "z", + "gz", + "bz2", + "xz", + "cab", + "sit", + "sitx", + "stuffit", + "hqx", + "bin", + "macbin", + "cpio", + "rpm", + "deb", + "ar", + "a", + ], + // 圖片格式 + images: [ + "ico", + "cur", + "ani", + "icns", + "bmp", + "dib", + "pcx", + "dcx", + "pict", + "pic", + "pct", + "wmf", + "emf", + "gem", + "img", + "mac", + "msp", + "iff", + "ilbm", + "lbm", + "ham", + "xbm", + "xpm", + "ras", + "sun", + "tga", + "vst", + "icb", + "vda", + "sgi", + "rgb", + "rgba", + "bw", + "int", + "inta", + "psd", + "xcf", + "ora", + "kra", + "psp", + "jbig", + "jbg", + "fpx", + ], + // 字型格式 + fonts: ["fon", "fnt", "psf", "bdf", "pcf"], + // 可執行檔 / 資源檔 + binary: ["exe", "dll", "com", "ne", "mz", "res", "rsrc", "icl"], + // 其他格式 + other: [ + "swf", + "fla", + "amiga", + "adf", + "dms", + "d64", + "t64", + "prg", + "crt", + "tap", + "tzx", + "wav", + "riff", + "avi", + "ani", + ], + }, + to: { + archive: ["extract"], + images: ["extract"], + fonts: ["extract"], + binary: ["extract"], + other: ["extract"], + }, + // deark 輸出可能是多個檔案,使用 archive 模式 + outputMode: "archive" as const, +}; + +/** + * Helper function to create a .tar archive from a directory + */ +function createTarArchive( + sourceDir: string, + outputTar: string, + execFile: ExecFileFn, +): Promise { + return new Promise((resolve, reject) => { + 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); + } +} + +/** + * Count files in a directory (including subdirectories) + */ +function countFiles(dirPath: string): number { + if (!existsSync(dirPath)) return 0; + + let count = 0; + const files = readdirSync(dirPath, { withFileTypes: true }); + for (const file of files) { + if (file.isDirectory()) { + count += countFiles(join(dirPath, file.name)); + } else { + count++; + } + } + return count; +} + +export async function convert( + filePath: string, + fileType: string, + convertTo: string, + targetPath: string, + options?: unknown, + execFile: ExecFileFn = execFileOriginal, +): Promise { + // Create a temporary output directory for deark + const outputDir = dirname(targetPath); + const inputFileName = basename(filePath, `.${fileType}`); + const dearkOutputDir = join(outputDir, `${inputFileName}_deark_extract`); + + // Ensure output directory exists + if (!existsSync(dearkOutputDir)) { + mkdirSync(dearkOutputDir, { recursive: true }); + } + + /** + * Execute deark with proper options + * + * deark 常用參數: + * -od 輸出目錄 + * -a 提取更多資料(包含縮圖等輔助檔案) + * -zip 輸出為 ZIP 格式(我們使用 tar) + * -l 僅列出檔案不提取 + * -m 指定處理模組 + */ + const runDeark = (): Promise => { + return new Promise((resolve, reject) => { + // Build deark command arguments + const args = [ + "-od", + dearkOutputDir, // Output directory + "-a", // Extract all (including auxiliary files) + "-nomodtime", // Don't modify file timestamps + filePath, // Input file + ]; + + console.log(`[deark] Running: deark ${args.join(" ")}`); + + execFile("deark", args, (error, stdout, stderr) => { + if (stdout) { + console.log(`deark stdout: ${stdout}`); + } + + if (stderr) { + console.error(`deark stderr: ${stderr}`); + } + + if (error) { + // Check for common deark errors + const errorStr = String(error) + String(stderr); + + if (errorStr.includes("Unknown or unsupported")) { + reject(new Error(`deark: 不支援此檔案格式 (${fileType})`)); + } else if (errorStr.includes("Failed to read")) { + reject(new Error(`deark: 無法讀取檔案 - ${filePath}`)); + } else { + reject(new Error(`deark error: ${error}`)); + } + return; + } + + resolve(); + }); + }); + }; + + // Execute deark + try { + await runDeark(); + } catch (error) { + // Cleanup on error + removeDir(dearkOutputDir); + throw error; + } + + // Check if any files were extracted + const fileCount = countFiles(dearkOutputDir); + if (fileCount === 0) { + removeDir(dearkOutputDir); + throw new Error(`deark: 沒有可提取的檔案 (${fileType})`); + } + + // Create .tar archive from the output directory + try { + const tarOutputPath = `${targetPath}.tar`; + await createTarArchive(dearkOutputDir, tarOutputPath, execFile); + + console.log(`[deark] Created tar archive: ${tarOutputPath} (${fileCount} files)`); + + // Cleanup temporary directory + removeDir(dearkOutputDir); + + return `Done - extracted ${fileCount} file(s)`; + } catch (error) { + removeDir(dearkOutputDir); + throw new Error(`Failed to create tar archive: ${error}`); + } +} diff --git a/src/converters/main.ts b/src/converters/main.ts index 4a16414..4dff1c8 100644 --- a/src/converters/main.ts +++ b/src/converters/main.ts @@ -5,6 +5,7 @@ import { normalizeFiletype, normalizeOutputFiletype } from "../helpers/normalize import { convert as convertassimp, properties as propertiesassimp } from "./assimp"; import { convert as convertCalibre, properties as propertiesCalibre } from "./calibre"; import { convert as convertDasel, properties as propertiesDasel } from "./dasel"; +import { convert as convertDeark, properties as propertiesDeark } from "./deark"; import { convert as convertDvisvgm, properties as propertiesDvisvgm } from "./dvisvgm"; import { convert as convertFFmpeg, properties as propertiesFFmpeg } from "./ffmpeg"; import { @@ -161,6 +162,10 @@ const properties: Record< properties: propertiesOcrMyPdf, converter: convertOcrMyPdf, }, + deark: { + properties: propertiesDeark, + converter: convertDeark, + }, }; function chunks(arr: T[], size: number): T[][] { diff --git a/tests/converters/deark.test.ts b/tests/converters/deark.test.ts new file mode 100644 index 0000000..8bfa57b --- /dev/null +++ b/tests/converters/deark.test.ts @@ -0,0 +1,79 @@ +import { test, expect, describe, mock } from "bun:test"; +import { convert, properties } from "../../src/converters/deark"; + +describe("deark converter", () => { + test("properties should have correct structure", () => { + expect(properties).toBeDefined(); + expect(properties.from).toBeDefined(); + expect(properties.to).toBeDefined(); + expect(properties.outputMode).toBe("archive"); + }); + + test("properties.from should contain archive formats", () => { + expect(properties.from.archive).toBeDefined(); + expect(properties.from.archive).toContain("zip"); + expect(properties.from.archive).toContain("lha"); + expect(properties.from.archive).toContain("arc"); + }); + + test("properties.from should contain image formats", () => { + expect(properties.from.images).toBeDefined(); + expect(properties.from.images).toContain("ico"); + expect(properties.from.images).toContain("bmp"); + expect(properties.from.images).toContain("pcx"); + }); + + test("properties.to should have extract option for all categories", () => { + for (const category of Object.keys(properties.from)) { + expect(properties.to[category]).toBeDefined(); + expect(properties.to[category]).toContain("extract"); + } + }); + + test("convert resolves when execFile succeeds", async () => { + const mockExecFile = mock((cmd: string, args: string[], callback: Function) => { + // First call is deark, second call is tar + callback(null, "Success", ""); + }); + + // Mock fs functions would be needed for full test + // This is a simplified test + try { + await convert( + "/tmp/test.zip", + "zip", + "extract", + "/tmp/output/test", + {}, + mockExecFile as any, + ); + } catch (error) { + // Expected to fail due to fs operations not being mocked + expect(error).toBeDefined(); + } + }); + + test("convert rejects when deark fails", async () => { + const mockExecFile = mock((cmd: string, args: string[], callback: Function) => { + callback(new Error("deark failed"), "", "Unknown or unsupported format"); + }); + + try { + await convert( + "/tmp/test.unknown", + "unknown", + "extract", + "/tmp/output/test", + {}, + mockExecFile as any, + ); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + expect(String(error)).toContain("不支援此檔案格式"); + } + }); +}); + +// Skip common tests as deark has different behavior (archive output) +test.skip("dummy - required to trigger test detection", () => {});