feat: pdf encryption image extraction rendering and table reading

This commit is contained in:
yu committed 2026-10-30 17:26:09 +08:00
1 parent 23d292fdff
commit 84d73f1b86
5 files changed
+598 -47

No files matched your search

+54
View File
@@ -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;
}
+203 -1
View File
@@ -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);