feat: integrate MinerU document to Markdown converter
- Add MinerU converter engine (src/converters/mineru.ts) - Support md-t (table as Markdown) and md-i (table as image) output formats - Input formats: pdf, ppt, pptx, xls, xlsx, doc, docx - Output as ZIP archive containing Markdown and images - Update Dockerfile to install magic-pdf via pipx - Add zip utility for archive creation - Update normalizeFiletype to map md-t/md-i to zip extension - Add comprehensive tests for MinerU converter - Update README with MinerU documentation
This commit is contained in:
parent
e577658231
commit
e5ca364563
6 changed files with 383 additions and 4 deletions
|
|
@ -139,8 +139,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
pipx \
|
pipx \
|
||||||
# === 系統工具 ===
|
# === 系統工具 ===
|
||||||
locales \
|
locales \
|
||||||
|
zip \
|
||||||
# === 清理 ===
|
# === 清理 ===
|
||||||
&& pipx install "markitdown[all]" \
|
&& pipx install "markitdown[all]" \
|
||||||
|
&& pipx install magic-pdf \
|
||||||
# 清理 apt cache
|
# 清理 apt cache
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -165,11 +165,23 @@ docker compose up -d
|
||||||
| Pandoc | 文件 | 100+ |
|
| Pandoc | 文件 | 100+ |
|
||||||
| Calibre | 電子書 | 40+ |
|
| Calibre | 電子書 | 40+ |
|
||||||
| Inkscape | 向量圖 | 20+ |
|
| Inkscape | 向量圖 | 20+ |
|
||||||
|
| MinerU | 文件→MD | 2 |
|
||||||
|
|
||||||
完整列表 → [docs/converters.md](docs/converters.md)
|
完整列表 → [docs/converters.md](docs/converters.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MinerU
|
||||||
|
|
||||||
|
ConvertX 內建 MinerU 轉換引擎,可將文件轉換為 Markdown。
|
||||||
|
|
||||||
|
- md-t
|
||||||
|
- md-i
|
||||||
|
|
||||||
|
輸出格式為 ZIP。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 語言支援
|
## 語言支援
|
||||||
|
|
||||||
支援 **65 種語言**,包含繁體中文、簡體中文、英文、日文、韓文等。
|
支援 **65 種語言**,包含繁體中文、簡體中文、英文、日文、韓文等。
|
||||||
|
|
@ -215,7 +227,7 @@ docker compose --profile api up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
| 服務 | 端口 | 說明 |
|
| 服務 | 端口 | 說明 |
|
||||||
| ---------- | ---- | ----------------- |
|
| ---------- | ---- | -------------- |
|
||||||
| Web UI | 3000 | 網頁介面 |
|
| Web UI | 3000 | 網頁介面 |
|
||||||
| API Server | 3001 | REST & GraphQL |
|
| API Server | 3001 | REST & GraphQL |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { convert as convertVtracer, properties as propertiesVtracer } from "./vt
|
||||||
import { convert as convertVcf, properties as propertiesVcf } from "./vcf";
|
import { convert as convertVcf, properties as propertiesVcf } from "./vcf";
|
||||||
import { convert as convertxelatex, properties as propertiesxelatex } from "./xelatex";
|
import { convert as convertxelatex, properties as propertiesxelatex } from "./xelatex";
|
||||||
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
|
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
|
||||||
|
import { convert as convertMineru, properties as propertiesMineru } from "./mineru";
|
||||||
|
|
||||||
// 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
|
||||||
|
|
||||||
|
|
@ -137,6 +138,10 @@ const properties: Record<
|
||||||
properties: propertiesMarkitdown,
|
properties: propertiesMarkitdown,
|
||||||
converter: convertMarkitdown,
|
converter: convertMarkitdown,
|
||||||
},
|
},
|
||||||
|
mineru: {
|
||||||
|
properties: propertiesMineru,
|
||||||
|
converter: convertMineru,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function chunks<T>(arr: T[], size: number): T[][] {
|
function chunks<T>(arr: T[], size: number): T[][] {
|
||||||
|
|
|
||||||
152
src/converters/mineru.ts
Normal file
152
src/converters/mineru.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { execFile as execFileOriginal } from "node:child_process";
|
||||||
|
import { mkdirSync, existsSync, readdirSync, unlinkSync, rmdirSync } from "node:fs";
|
||||||
|
import { join, basename, dirname } from "node:path";
|
||||||
|
import { ExecFileFn } from "./types";
|
||||||
|
|
||||||
|
export const properties = {
|
||||||
|
from: {
|
||||||
|
document: ["pdf", "ppt", "pptx", "xls", "xlsx", "doc", "docx"],
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
document: ["md-t", "md-i"],
|
||||||
|
},
|
||||||
|
outputMode: "archive" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a ZIP archive from a directory
|
||||||
|
*/
|
||||||
|
function createZipArchive(
|
||||||
|
sourceDir: string,
|
||||||
|
outputZip: string,
|
||||||
|
execFile: ExecFileFn,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Use zip command to create archive
|
||||||
|
execFile(
|
||||||
|
"zip",
|
||||||
|
["-r", outputZip, "."],
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
reject(`zip error: ${error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stdout) {
|
||||||
|
console.log(`zip stdout: ${stdout}`);
|
||||||
|
}
|
||||||
|
if (stderr) {
|
||||||
|
console.error(`zip stderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
{ cwd: sourceDir },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convert(
|
||||||
|
filePath: string,
|
||||||
|
fileType: string,
|
||||||
|
convertTo: string,
|
||||||
|
targetPath: string,
|
||||||
|
options?: unknown,
|
||||||
|
execFile: ExecFileFn = execFileOriginal,
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Determine mode based on target format
|
||||||
|
// md-t = table mode (tables as markdown)
|
||||||
|
// md-i = image mode (tables as images)
|
||||||
|
const tableMode = convertTo === "md-t" ? "latex" : "html";
|
||||||
|
|
||||||
|
// Create a temporary output directory for MinerU
|
||||||
|
const outputDir = dirname(targetPath);
|
||||||
|
const inputFileName = basename(filePath, `.${fileType}`);
|
||||||
|
const mineruOutputDir = join(outputDir, `${inputFileName}_mineru_${convertTo}`);
|
||||||
|
|
||||||
|
// Ensure output directory exists
|
||||||
|
if (!existsSync(mineruOutputDir)) {
|
||||||
|
mkdirSync(mineruOutputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build MinerU command arguments
|
||||||
|
// MinerU CLI: magic-pdf -p <input> -o <output_dir> -m auto
|
||||||
|
const args = [
|
||||||
|
"-p",
|
||||||
|
filePath,
|
||||||
|
"-o",
|
||||||
|
mineruOutputDir,
|
||||||
|
"-m",
|
||||||
|
"auto",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add table mode option if md-i (render tables as images)
|
||||||
|
if (convertTo === "md-i") {
|
||||||
|
args.push("--table-mode", "image");
|
||||||
|
} else {
|
||||||
|
args.push("--table-mode", "markdown");
|
||||||
|
}
|
||||||
|
|
||||||
|
execFile("magic-pdf", args, async (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
reject(`mineru error: ${error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdout) {
|
||||||
|
console.log(`mineru stdout: ${stdout}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
console.error(`mineru stderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// MinerU outputs to a subdirectory, find the actual output
|
||||||
|
const mineruActualOutput = join(mineruOutputDir, "auto");
|
||||||
|
|
||||||
|
// Create ZIP archive from the output directory
|
||||||
|
const zipPath = targetPath.endsWith(".zip")
|
||||||
|
? targetPath
|
||||||
|
: `${targetPath}.zip`;
|
||||||
|
|
||||||
|
// Ensure the parent directory exists
|
||||||
|
const zipDir = dirname(zipPath);
|
||||||
|
if (!existsSync(zipDir)) {
|
||||||
|
mkdirSync(zipDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the actual MinerU output directory for zipping
|
||||||
|
const outputToZip = existsSync(mineruActualOutput)
|
||||||
|
? mineruActualOutput
|
||||||
|
: mineruOutputDir;
|
||||||
|
|
||||||
|
await createZipArchive(outputToZip, zipPath, execFile);
|
||||||
|
|
||||||
|
// Clean up the temporary directory
|
||||||
|
removeDir(mineruOutputDir);
|
||||||
|
|
||||||
|
resolve("Done");
|
||||||
|
} catch (zipError) {
|
||||||
|
reject(`Failed to create ZIP archive: ${zipError}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,10 @@ export const normalizeOutputFiletype = (filetype: string): string => {
|
||||||
case "markdown_mmd":
|
case "markdown_mmd":
|
||||||
case "markdown":
|
case "markdown":
|
||||||
return "md";
|
return "md";
|
||||||
|
// MinerU output formats - output as ZIP
|
||||||
|
case "md-t":
|
||||||
|
case "md-i":
|
||||||
|
return "zip";
|
||||||
default:
|
default:
|
||||||
return lowercaseFiletype;
|
return lowercaseFiletype;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
204
tests/converters/mineru.test.ts
Normal file
204
tests/converters/mineru.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
|
||||||
|
import { convert, properties } from "../../src/converters/mineru";
|
||||||
|
import type { ExecFileException } from "node:child_process";
|
||||||
|
import { ExecFileFn } from "../../src/converters/types";
|
||||||
|
import { mkdirSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
// Skip common tests as MinerU has different behavior (archive output)
|
||||||
|
test.skip("dummy - required to trigger test detection", () => {});
|
||||||
|
|
||||||
|
describe("MinerU converter properties", () => {
|
||||||
|
test("should have correct input formats", () => {
|
||||||
|
expect(properties.from.document).toContain("pdf");
|
||||||
|
expect(properties.from.document).toContain("ppt");
|
||||||
|
expect(properties.from.document).toContain("pptx");
|
||||||
|
expect(properties.from.document).toContain("xls");
|
||||||
|
expect(properties.from.document).toContain("xlsx");
|
||||||
|
expect(properties.from.document).toContain("doc");
|
||||||
|
expect(properties.from.document).toContain("docx");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have correct output formats", () => {
|
||||||
|
expect(properties.to.document).toContain("md-t");
|
||||||
|
expect(properties.to.document).toContain("md-i");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have archive output mode", () => {
|
||||||
|
expect(properties.outputMode).toBe("archive");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MinerU converter md-t mode", () => {
|
||||||
|
const testDir = "./test-output-mineru-t";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
if (!existsSync(testDir)) {
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (existsSync(testDir)) {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should call magic-pdf with markdown table mode for md-t", async () => {
|
||||||
|
let magicPdfArgs: string[] = [];
|
||||||
|
|
||||||
|
const mockExecFile: ExecFileFn = (
|
||||||
|
cmd: string,
|
||||||
|
args: string[],
|
||||||
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
|
options?: any,
|
||||||
|
) => {
|
||||||
|
if (cmd === "magic-pdf") {
|
||||||
|
magicPdfArgs = args;
|
||||||
|
// Simulate MinerU creating output
|
||||||
|
const outputDir = args[3]; // -o argument
|
||||||
|
if (outputDir && !existsSync(outputDir)) {
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const autoDir = join(outputDir, "auto");
|
||||||
|
if (!existsSync(autoDir)) {
|
||||||
|
mkdirSync(autoDir, { recursive: true });
|
||||||
|
}
|
||||||
|
writeFileSync(join(autoDir, "output.md"), "# Test\n\n| Col1 | Col2 |\n|---|---|\n| A | B |");
|
||||||
|
callback(null, "MinerU conversion complete", "");
|
||||||
|
} else if (cmd === "zip") {
|
||||||
|
// Simulate zip creation
|
||||||
|
callback(null, "Archive created", "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetPath = join(testDir, "output.zip");
|
||||||
|
await convert("test.pdf", "pdf", "md-t", targetPath, undefined, mockExecFile);
|
||||||
|
|
||||||
|
expect(magicPdfArgs).toContain("--table-mode");
|
||||||
|
expect(magicPdfArgs).toContain("markdown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MinerU converter md-i mode", () => {
|
||||||
|
const testDir = "./test-output-mineru-i";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
if (!existsSync(testDir)) {
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (existsSync(testDir)) {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should call magic-pdf with image table mode for md-i", async () => {
|
||||||
|
let capturedArgs: string[] = [];
|
||||||
|
|
||||||
|
const mockExecFile: ExecFileFn = (
|
||||||
|
cmd: string,
|
||||||
|
args: string[],
|
||||||
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
|
options?: any,
|
||||||
|
) => {
|
||||||
|
if (cmd === "magic-pdf") {
|
||||||
|
capturedArgs = args;
|
||||||
|
// Simulate MinerU creating output
|
||||||
|
const outputDir = args[3]; // -o argument
|
||||||
|
if (outputDir && !existsSync(outputDir)) {
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const autoDir = join(outputDir, "auto");
|
||||||
|
if (!existsSync(autoDir)) {
|
||||||
|
mkdirSync(autoDir, { recursive: true });
|
||||||
|
}
|
||||||
|
writeFileSync(join(autoDir, "output.md"), "# Test\n\n");
|
||||||
|
callback(null, "MinerU conversion complete", "");
|
||||||
|
} else if (cmd === "zip") {
|
||||||
|
// Simulate zip creation
|
||||||
|
callback(null, "Archive created", "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetPath = join(testDir, "output.zip");
|
||||||
|
await convert("test.pdf", "pdf", "md-i", targetPath, undefined, mockExecFile);
|
||||||
|
|
||||||
|
expect(capturedArgs).toContain("--table-mode");
|
||||||
|
expect(capturedArgs).toContain("image");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MinerU converter ZIP output", () => {
|
||||||
|
const testDir = "./test-output-mineru-zip";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
if (!existsSync(testDir)) {
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (existsSync(testDir)) {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should create ZIP archive from output", async () => {
|
||||||
|
let zipCalled = false;
|
||||||
|
let zipArgs: string[] = [];
|
||||||
|
|
||||||
|
const mockExecFile: ExecFileFn = (
|
||||||
|
cmd: string,
|
||||||
|
args: string[],
|
||||||
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
|
options?: any,
|
||||||
|
) => {
|
||||||
|
if (cmd === "magic-pdf") {
|
||||||
|
// Simulate MinerU creating output
|
||||||
|
const outputDir = args[3]; // -o argument
|
||||||
|
if (outputDir && !existsSync(outputDir)) {
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const autoDir = join(outputDir, "auto");
|
||||||
|
if (!existsSync(autoDir)) {
|
||||||
|
mkdirSync(autoDir, { recursive: true });
|
||||||
|
}
|
||||||
|
writeFileSync(join(autoDir, "output.md"), "# Test content");
|
||||||
|
mkdirSync(join(autoDir, "images"), { recursive: true });
|
||||||
|
writeFileSync(join(autoDir, "images", "img1.png"), "fake image data");
|
||||||
|
callback(null, "MinerU conversion complete", "");
|
||||||
|
} else if (cmd === "zip") {
|
||||||
|
zipCalled = true;
|
||||||
|
zipArgs = args;
|
||||||
|
callback(null, "Archive created", "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetPath = join(testDir, "output.zip");
|
||||||
|
const result = await convert("test.pdf", "pdf", "md-t", targetPath, undefined, mockExecFile);
|
||||||
|
|
||||||
|
expect(result).toBe("Done");
|
||||||
|
expect(zipCalled).toBe(true);
|
||||||
|
expect(zipArgs).toContain("-r");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject on magic-pdf error", async () => {
|
||||||
|
const mockExecFile: ExecFileFn = (
|
||||||
|
cmd: string,
|
||||||
|
args: string[],
|
||||||
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
|
options?: any,
|
||||||
|
) => {
|
||||||
|
if (cmd === "magic-pdf") {
|
||||||
|
callback(new Error("MinerU failed") as ExecFileException, "", "Error processing file");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetPath = join(testDir, "output.zip");
|
||||||
|
expect(convert("test.pdf", "pdf", "md-t", targetPath, undefined, mockExecFile))
|
||||||
|
.rejects.toMatch(/mineru error/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue