feat: pdf encryption image extraction rendering and table reading
This commit is contained in:
1 parent
23d292fdff
commit
84d73f1b86
5 files changed
+598
-47
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PDFDocument, StandardFonts } from "pdf-lib";
|
||||
import { PNG } from "pngjs";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./pdf";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown>) {
|
||||
const handler = getHandler(id);
|
||||
expect(handler, id).toBeTruthy();
|
||||
return handler!(context, node(id), args, component);
|
||||
}
|
||||
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
|
||||
|
||||
describe("PDF high-level capabilities", () => {
|
||||
it("creates a pdf fixture", async () => {
|
||||
const directory = await mkdtempSafe();
|
||||
const pdf = await PDFDocument.create();
|
||||
const page = pdf.addPage([220, 120]);
|
||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const png = new PNG({ width: 8, height: 8 });
|
||||
png.data.fill(255); png.data[0] = 20; png.data[1] = 90; png.data[2] = 200; png.data[3] = 255;
|
||||
page.drawImage(await pdf.embedPng(PNG.sync.write(png)), { x: 140, y: 70, width: 40, height: 40 });
|
||||
for (const [value, x, y] of [["Name", 24, 90], ["Age", 120, 90], ["Alice", 24, 72], ["30", 120, 72]] as Array<[string, number, number]>) page.drawText(value, { x, y, size: 9, font });
|
||||
await writeFile(path.join(directory, "source.pdf"), await pdf.save());
|
||||
const filePath = path.join(directory, "source.pdf");
|
||||
expect((await readFile(path.join(directory, "source.pdf"))).byteLength).toBeGreaterThan(0);
|
||||
const images = await run("PDF_GetPdfImg", { fileInPath: filePath, pdfRange: "all", pdfNum: "1", imgSavePath: directory, imageType: "png" }) as string[];
|
||||
expect(images.length).toBeGreaterThan(0);
|
||||
expect((await readFile(images[0])).subarray(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
|
||||
await expect(run("PDF_GetPdfTable", { fileInPath: filePath, pdfRange: "all", pdfNum: "1" })).resolves.toEqual([["Name", "Age"], ["Alice", "30"]]);
|
||||
|
||||
const encryptedPath = path.join(directory, "encrypted.pdf");
|
||||
await writeFile(encryptedPath, await readFile(filePath));
|
||||
await expect(run("PDF_PdfEncryption", { fileInPath: encryptedPath, passWord: "secret" })).resolves.toBe(encryptedPath);
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const opened = await pdfjs.getDocument({ data: new Uint8Array(await readFile(encryptedPath)), password: "secret" }).promise;
|
||||
expect(opened.numPages).toBe(1);
|
||||
});
|
||||
});
|
||||
async function mkdtempSafe(): Promise<string> {
|
||||
const directory = await (await import("node:fs/promises")).mkdtemp(path.join(os.tmpdir(), "rpa-pdf-high-"));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import fsSync from "node:fs";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
|
||||
@@ -60,3 +62,203 @@ register("PDF_GetPdfText", async (_ctx, node, args) => {
|
||||
}
|
||||
return chunks.join("\n");
|
||||
});
|
||||
|
||||
type PdfJsDocument = Awaited<ReturnType<typeof import("pdfjs-dist/legacy/build/pdf.mjs")["getDocument"]>["promise"]>;
|
||||
|
||||
function standardFontUrl(): string | undefined {
|
||||
const candidates = [
|
||||
process.env.RPA_PDFJS_STANDARD_FONTS,
|
||||
path.join(process.cwd(), "node_modules", "pdfjs-dist", "standard_fonts"),
|
||||
path.join(process.execPath, "resources", "app.asar", "node_modules", "pdfjs-dist", "standard_fonts"),
|
||||
path.join(process.execPath, "..", "resources", "app.asar", "node_modules", "pdfjs-dist", "standard_fonts")
|
||||
].filter(Boolean) as string[];
|
||||
const found = candidates.find((candidate) => fsSync.existsSync(candidate));
|
||||
return found ? pathToFileURL(path.join(found, path.sep)).href : undefined;
|
||||
}
|
||||
|
||||
class LocalStandardFontFactory {
|
||||
baseUrl: string;
|
||||
constructor(options: { baseUrl: string }) { this.baseUrl = options.baseUrl; }
|
||||
async fetch({ filename }: { filename: string }): Promise<Uint8Array> {
|
||||
return new Uint8Array(await fs.readFile(path.join(fileURLToPath(this.baseUrl), filename)));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPdf(filePath: string, password: string) {
|
||||
const canvasModule = await import("@napi-rs/canvas");
|
||||
const globals = globalThis as unknown as Record<string, unknown>;
|
||||
globals.Path2D = canvasModule.Path2D;
|
||||
globals.DOMMatrix = canvasModule.DOMMatrix;
|
||||
globals.ImageData = canvasModule.ImageData;
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
return { pdfjs, document: await pdfjs.getDocument({ data: new Uint8Array(await fs.readFile(filePath)), password: password || undefined, isEvalSupported: false, useSystemFonts: false, standardFontDataUrl: standardFontUrl(), StandardFontDataFactory: LocalStandardFontFactory }).promise };
|
||||
}
|
||||
|
||||
function pageIndices(document: PdfJsDocument, raw: unknown): number[] {
|
||||
const range = String(raw ?? "all").trim().toLowerCase();
|
||||
if (!range || range === "all") return Array.from({ length: document.numPages }, (_, index) => index + 1);
|
||||
const result = new Set<number>();
|
||||
for (const part of range.split(/[,,;;\s]+/).filter(Boolean)) {
|
||||
const match = part.match(/^(\d+)-(\d+)$/);
|
||||
if (match) {
|
||||
const start = Math.max(1, Number(match[1])); const end = Math.min(document.numPages, Number(match[2]));
|
||||
for (let index = start; index <= end; index += 1) result.add(index);
|
||||
} else {
|
||||
const index = Number(part);
|
||||
if (index >= 1 && index <= document.numPages) result.add(Math.floor(index));
|
||||
}
|
||||
}
|
||||
const values = [...result].sort((left, right) => left - right);
|
||||
if (!values.length) throw new Error(`PDF 页码范围无效:${range}`);
|
||||
return values;
|
||||
}
|
||||
|
||||
async function renderPage(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>, scale = 2): Promise<Buffer> {
|
||||
const canvasModule = await import("@napi-rs/canvas");
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = canvasModule.createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "white"; context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
await page.render({ canvasContext: context as unknown as CanvasRenderingContext2D, viewport }).promise;
|
||||
return canvas.toBuffer("image/png");
|
||||
}
|
||||
|
||||
function saveImage(folder: string, prefix: string, suffix: string, data: Buffer, type: string): string {
|
||||
const ext = /jpe?g/i.test(type) ? "jpg" : "png";
|
||||
const output = path.join(path.resolve(folder), `${prefix}_${suffix}_${Date.now()}.${ext}`);
|
||||
fsSync.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fsSync.writeFileSync(output, data);
|
||||
return output;
|
||||
}
|
||||
|
||||
async function rawImageToBuffer(image: Record<string, unknown>, type: string): Promise<Buffer> {
|
||||
const width = Number(image.width || 0); const height = Number(image.height || 0);
|
||||
const data = image.data;
|
||||
if (!width || !height || !(data instanceof Uint8Array) || data.length === 0) throw new Error("PDF 图片数据无效");
|
||||
if (data[0] === 0xff && data[1] === 0xd8) return Buffer.from(data);
|
||||
const canvasModule = await import("@napi-rs/canvas");
|
||||
const canvas = canvasModule.createCanvas(width, height); const context = canvas.getContext("2d");
|
||||
const target = context.createImageData(width, height); const kind = Number(image.kind || 0);
|
||||
if (kind === 1) {
|
||||
const bytesPerRow = Math.ceil(width / 8);
|
||||
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
||||
const bit = (data[y * bytesPerRow + (x >> 3)] >> (7 - (x & 7))) & 1; const output = (y * width + x) * 4; const color = bit ? 255 : 0;
|
||||
target.data[output] = color; target.data[output + 1] = color; target.data[output + 2] = color; target.data[output + 3] = 255;
|
||||
}
|
||||
} else if (kind === 3) { target.data.set(data.subarray(0, Math.min(data.length, target.data.length))); }
|
||||
else {
|
||||
for (let source = 0, targetIndex = 0; source < data.length && targetIndex < target.data.length; source += 3, targetIndex += 4) {
|
||||
target.data[targetIndex] = data[source]; target.data[targetIndex + 1] = data[source + 1] ?? 0; target.data[targetIndex + 2] = data[source + 2] ?? 0; target.data[targetIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
context.putImageData(target, 0, 0);
|
||||
return /jpe?g/i.test(type) ? canvas.toBuffer("image/jpeg") : canvas.toBuffer("image/png");
|
||||
}
|
||||
|
||||
async function objectImage(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>, objId: unknown): Promise<Record<string, unknown> | null> {
|
||||
if (typeof objId !== "string") return null;
|
||||
const objects = objId.startsWith("g_") ? page.commonObjs : page.objs;
|
||||
try { if (objects.has(objId)) return objects.get(objId) as Record<string, unknown>; } catch {}
|
||||
return await new Promise((resolve) => { try { objects.get(objId, (value: unknown) => resolve(value as Record<string, unknown>)); } catch { resolve(null); } });
|
||||
}
|
||||
|
||||
const encryption: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
|
||||
const password = String(args.passWord ?? args.password ?? "");
|
||||
if (!password) throw new RuntimeError("validation_error", "PDF密码为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const { PDFDocument } = await import("@cantoo/pdf-lib");
|
||||
const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true });
|
||||
document.encrypt({ userPassword: password, ownerPassword: password, permissions: { printing: "highResolution", copying: true, modifying: false } });
|
||||
const temporary = `${filePath}.${Date.now()}.enc`;
|
||||
await fs.writeFile(temporary, await document.save());
|
||||
await fs.rename(temporary, filePath);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
const extractImages: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
|
||||
const folder = required(text(args, ["imgSavePath", "pdfSavePath", "folder", "path"]), "图片保存路径", node.id, node.componentId);
|
||||
const type = text(args, ["imageType", "type"], "png");
|
||||
const prefix = text(args, ["imageName", "name"], "pdf-image");
|
||||
const { pdfjs, document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
|
||||
const outputs: string[] = [];
|
||||
for (const pageNumber of pageIndices(document, args.pdfNum)) {
|
||||
const page = await document.getPage(pageNumber);
|
||||
const opList = await page.getOperatorList();
|
||||
let sequence = 0;
|
||||
for (let index = 0; index < opList.fnArray.length; index += 1) {
|
||||
const op = opList.fnArray[index]; const commandArgs = opList.argsArray[index] as unknown[];
|
||||
let image: Record<string, unknown> | null = null;
|
||||
if (op === pdfjs.OPS.paintImageXObject || op === pdfjs.OPS.paintImageXObjectRepeat) image = await objectImage(page, commandArgs[0]);
|
||||
else if (op === pdfjs.OPS.paintInlineImageXObject) image = commandArgs[0] as Record<string, unknown>;
|
||||
else if (op === pdfjs.OPS.paintInlineImageXObjectGroup) image = commandArgs[0] as Record<string, unknown>;
|
||||
if (!image) continue;
|
||||
sequence += 1;
|
||||
outputs.push(saveImage(folder, `${prefix}-p${pageNumber}`, String(sequence), await rawImageToBuffer(image, type), type));
|
||||
}
|
||||
}
|
||||
return outputs;
|
||||
};
|
||||
|
||||
const pdfToImages: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
|
||||
const folder = required(text(args, ["pdfSavePath", "imgSavePath", "folder", "path"]), "图片保存路径", node.id, node.componentId);
|
||||
const type = text(args, ["imageType", "type"], "png"); const prefix = text(args, ["imageName", "name"], "pdf-page");
|
||||
const { document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
|
||||
const outputs: string[] = [];
|
||||
for (const pageNumber of pageIndices(document, args.pdfNum)) {
|
||||
const buffer = await renderPage(await document.getPage(pageNumber));
|
||||
outputs.push(saveImage(folder, prefix, String(pageNumber), buffer, type));
|
||||
}
|
||||
return outputs;
|
||||
};
|
||||
|
||||
interface TextToken { x: number; y: number; value: string; }
|
||||
|
||||
async function extractTableRows(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>): Promise<string[][]> {
|
||||
const content = await page.getTextContent();
|
||||
const tokens = content.items.flatMap((item) => {
|
||||
if (!("str" in item) || !item.str.trim()) return [];
|
||||
const transform = (item as { transform?: number[] }).transform ?? [1,0,0,1,0,0];
|
||||
return [{ x: Number(transform[4] || 0), y: Number(transform[5] || 0), value: item.str.trim() }];
|
||||
});
|
||||
if (tokens.length < 2) return [];
|
||||
const lines = new Map<number, TextToken[]>();
|
||||
for (const token of [...tokens].sort((left, right) => right.y - left.y || left.x - right.x)) {
|
||||
const lineKey = [...lines.keys()].find((key) => Math.abs(key - token.y) <= 3.5) ?? token.y;
|
||||
if (!lines.has(lineKey)) lines.set(lineKey, []);
|
||||
lines.get(lineKey)!.push(token);
|
||||
}
|
||||
const lineList = [...lines.entries()].map(([y, values]) => ({ y, values: values.sort((left, right) => left.x - right.x) })).sort((left, right) => right.y - left.y);
|
||||
const counts = new Map<number, number>();
|
||||
for (const line of lineList) for (const token of line.values) {
|
||||
const key = Math.round(token.x / 5) * 5;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
const columns = [...counts.entries()].filter(([, count]) => count >= Math.min(2, lineList.length)).sort((left, right) => left[0] - right[0]).map(([key]) => key);
|
||||
if (columns.length < 2) return [];
|
||||
const rows: string[][] = [];
|
||||
for (const line of lineList) {
|
||||
const cells = columns.map(() => "");
|
||||
for (const token of line.values) {
|
||||
let nearest = 0;
|
||||
for (let index = 1; index < columns.length; index += 1) if (Math.abs(token.x - columns[index]) < Math.abs(token.x - columns[nearest])) nearest = index;
|
||||
cells[nearest] = cells[nearest] ? `${cells[nearest]} ${token.value}` : token.value;
|
||||
}
|
||||
if (cells.filter(Boolean).length >= 2) rows.push(cells);
|
||||
}
|
||||
return rows.length >= 2 ? rows : [];
|
||||
}
|
||||
|
||||
const readTables: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
|
||||
const { document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
|
||||
const rows: string[][] = [];
|
||||
for (const pageNumber of pageIndices(document, args.pdfNum)) rows.push(...await extractTableRows(await document.getPage(pageNumber)));
|
||||
return rows;
|
||||
};
|
||||
|
||||
register("PDF_PdfEncryption", encryption);
|
||||
register("PDF_GetPdfImg", extractImages);
|
||||
register("PDF_PdfToImg", pdfToImages);
|
||||
register("PDF_GetPdfTable", readTables);
|
||||
Reference in new issue
Block a user