import { Type } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { OfficeError } from "../core/errors.ts"; import { createPdfThroughWord } from "../office/workflows.ts"; import { requestMaskedSecret } from "../ui/masked-input.ts"; import { WordRuntime } from "../word/runtime.ts"; import { chooseStyle } from "../word/style-dialog.ts"; import { StylePresetSchema, StyleSchema } from "../word/style-schema.ts"; import { pdfDecrypt, pdfEncrypt, pdfExtract, pdfFillForm, pdfMerge, pdfMetadataGet, pdfMetadataSet, pdfOverlayText, pdfRender, pdfRotate, pdfSplit, pdfStampImage, pdfValidate, pdfWatermark, } from "./operations.ts"; import { PDF_PINS, PdfRuntime } from "./runtime.ts"; const OptionalPath = Type.Optional(Type.String()); const Overwrite = Type.Optional(Type.Boolean({ description: "Only true after explicit user approval" })); const Preview = Type.Optional(Type.String()); function result(label: string, details: unknown) { return { content: [{ type: "text" as const, text: `${label}\n${JSON.stringify(details, null, 2)}` }], details }; } function runtime(projectRoot: string, cwd: string, signal?: AbortSignal) { return new PdfRuntime(projectRoot, cwd, signal); } function wordRuntime(pi: ExtensionAPI, projectRoot: string, cwd: string, signal?: AbortSignal): WordRuntime { return new WordRuntime({ projectRoot, cwd, exec: (command, args, options) => pi.exec(command, args, { ...(options?.cwd ? { cwd: options.cwd } : {}), ...(options?.timeout !== undefined ? { timeout: options.timeout } : {}), ...((signal ?? options?.signal) ? { signal: (signal ?? options?.signal)! } : {}), }), ...(signal ? { signal } : {}), }); } function missingPins(status: Awaited>): string[] { return PDF_PINS.filter((pin) => { const [name, version] = pin.split("=="); const found = status.packages[name!]; return !found?.available || found.version !== version; }); } async function setupFlow(projectRoot: string, ctx: ExtensionContext, signal?: AbortSignal) { const pdf = runtime(projectRoot, ctx.cwd, signal); const before = await pdf.status(true); const missing = missingPins(before); if (before.source === "private" && missing.length === 0) return { installed: false, reason: "already-ready", status: before }; const checklist = missing.length > 0 ? missing : [...PDF_PINS]; if (!ctx.hasUI) throw new OfficeError(`需要交互确认后才能创建私有环境。待安装:${checklist.join(", ")}`, "SETUP_CONFIRMATION_REQUIRED"); const confirmed = await ctx.ui.confirm( "安装 pi-word-pdf 私有 PDF 环境?", `仅写入项目 .runtime/python,不修改全局 Python。\n\n将安装:\n${checklist.map((item) => `• ${item}`).join("\n")}`, signal ? { signal } : undefined, ); if (!confirmed) throw new OfficeError("用户取消了私有 PDF 环境安装。", "SETUP_CANCELLED"); const after = await pdf.setupPrivate(); return { installed: true, location: pdf.privatePython, status: after }; } export function registerPdfTools(pi: ExtensionAPI, projectRoot: string): void { pi.registerTool({ name: "office_setup", label: "Office Setup", description: "Show the exact pinned PDF dependency checklist and ask once before creating a private Python environment. Never changes global Python.", parameters: Type.Object({}, { additionalProperties: false }), async execute(_id, _params, signal, _update, ctx) { return result("私有 PDF 环境状态:", await setupFlow(projectRoot, ctx, signal)); }, }); pi.registerCommand("word-pdf-setup", { description: "Confirm and install the pinned private PDF runtime", handler: async (_args, ctx) => { try { ctx.ui.notify(JSON.stringify(await setupFlow(projectRoot, ctx), null, 2), "info"); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); pi.registerTool({ name: "pdf_create", label: "PDF Create", description: "Create a styled, high-fidelity PDF through a temporary DOCX and Microsoft Word COM, then validate and render the PDF. In an interactive UI, choose a preset or custom typography when no style is supplied. The temporary DOCX is deleted.", parameters: Type.Object({ outputPath: Type.String(), title: Type.Optional(Type.String()), author: Type.Optional(Type.String()), documentType: Type.Optional(Type.Union([Type.Literal("general"), Type.Literal("report"), Type.Literal("contract"), Type.Literal("news")])), markdown: Type.Optional(Type.String()), text: Type.Optional(Type.String()), stylePreset: StylePresetSchema, style: StyleSchema, overwrite: Overwrite, previewDirectory: Preview, }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { const selectedStyle = p.stylePreset === undefined && p.style === undefined && c.hasUI ? await chooseStyle(c) : {}; const output = await createPdfThroughWord(wordRuntime(pi, projectRoot, c.cwd, s), runtime(projectRoot, c.cwd, s), { ...p, ...selectedStyle }); return result("PDF 已通过临时 DOCX 创建;中间文件已删除。", output); }, }); pi.registerTool({ name: "pdf_validate", label: "PDF Validate", description: "Validate PDF structure and openability without modifying it.", parameters: Type.Object({ inputPath: Type.String() }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("PDF 验证结果:", await pdfValidate(runtime(projectRoot, c.cwd, s), p.inputPath)); } }); pi.registerTool({ name: "pdf_metadata_get", label: "PDF Metadata Get", description: "Read PDF metadata and page count.", parameters: Type.Object({ inputPath: Type.String() }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("PDF 元数据:", await pdfMetadataGet(runtime(projectRoot, c.cwd, s), p.inputPath)); } }); pi.registerTool({ name: "pdf_metadata_set", label: "PDF Metadata Set", description: "Set or remove PDF metadata and produce a validated, rendered copy.", parameters: Type.Object({ inputPath: Type.String(), metadata: Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()])), outputPath: OptionalPath, overwrite: Overwrite, previewDirectory: Preview }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("PDF 元数据设置完成。", await pdfMetadataSet(runtime(projectRoot, c.cwd, s), p)); } }); pi.registerTool({ name: "pdf_extract", label: "PDF Extract", description: "Extract text and tables with pdfplumber. No OCR.", parameters: Type.Object({ inputPath: Type.String(), tables: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("PDF 提取结果:", await pdfExtract(runtime(projectRoot, c.cwd, s), p.inputPath, p.tables)); } }); pi.registerTool({ name: "pdf_merge", label: "PDF Merge", description: "Merge two or more PDFs into a validated, rendered output.", parameters: Type.Object({ inputPaths: Type.Array(Type.String(), { minItems: 2 }), outputPath: OptionalPath, overwrite: Overwrite, previewDirectory: Preview }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("PDF 合并完成。", await pdfMerge(runtime(projectRoot, c.cwd, s), p)); } }); pi.registerTool({ name: "pdf_split", label: "PDF Split", description: "Extract an inclusive page range into a new validated PDF.", parameters: Type.Object({ inputPath: Type.String(), startPage: Type.Integer({ minimum: 1 }), endPage: Type.Optional(Type.Integer({ minimum: 1 })), outputPath: OptionalPath, overwrite: Overwrite, previewDirectory: Preview }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("PDF 拆分完成。", await pdfSplit(runtime(projectRoot, c.cwd, s), p)); } }); pi.registerTool({ name: "pdf_rotate", label: "PDF Rotate", description: "Rotate selected or all PDF pages.", parameters: Type.Object({ inputPath: Type.String(), angle: Type.Union([Type.Literal(90), Type.Literal(180), Type.Literal(270)]), pages: Type.Optional(Type.Array(Type.Integer({ minimum: 1 }))), outputPath: OptionalPath, overwrite: Overwrite, previewDirectory: Preview }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("PDF 旋转完成。", await pdfRotate(runtime(projectRoot, c.cwd, s), p)); } }); pi.registerTool({ name: "pdf_watermark", label: "PDF Watermark", description: "Add a Chinese-capable text watermark. No Helvetica fallback.", parameters: Type.Object({ inputPath: Type.String(), text: Type.String(), pages: Type.Optional(Type.Array(Type.Integer({ minimum: 1 }))), fontSize: Type.Optional(Type.Number({ minimum: 1 })), color: Type.Optional(Type.String()), opacity: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })), rotation: Type.Optional(Type.Number()), outputPath: OptionalPath, overwrite: Overwrite, previewDirectory: Preview }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("PDF 水印完成。", await pdfWatermark(runtime(projectRoot, c.cwd, s), p)); } }); pi.registerTool({ name: "pdf_overlay_text", label: "PDF Overlay Text", description: "Overlay text on an ordinary non-fillable PDF page. Coordinates are PDF points from bottom-left.", parameters: Type.Object({ inputPath: Type.String(), page: Type.Integer({ minimum: 1 }), text: Type.String(), x: Type.Number(), y: Type.Number(), fontSize: Type.Optional(Type.Number({ minimum: 1 })), color: Type.Optional(Type.String()), outputPath: OptionalPath, overwrite: Overwrite, previewDirectory: Preview }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("PDF 文字覆盖完成。", await pdfOverlayText(runtime(projectRoot, c.cwd, s), p)); } }); pi.registerTool({ name: "pdf_stamp_image", label: "PDF Stamp Image", description: "Place a signature or stamp image on a PDF page. This is not certificate signing.", parameters: Type.Object({ inputPath: Type.String(), imagePath: Type.String(), page: Type.Integer({ minimum: 1 }), x: Type.Number(), y: Type.Number(), width: Type.Number({ exclusiveMinimum: 0 }), height: Type.Number({ exclusiveMinimum: 0 }), outputPath: OptionalPath, overwrite: Overwrite, previewDirectory: Preview }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("签名/印章图片已放置。", await pdfStampImage(runtime(projectRoot, c.cwd, s), p)); } }); pi.registerTool({ name: "pdf_fill_form", label: "PDF Fill Form", description: "Fill existing AcroForm fields; unknown field names fail closed.", parameters: Type.Object({ inputPath: Type.String(), fields: Type.Record(Type.String(), Type.String()), outputPath: OptionalPath, overwrite: Overwrite, previewDirectory: Preview }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("PDF 表单填写完成。", await pdfFillForm(runtime(projectRoot, c.cwd, s), p)); } }); pi.registerTool({ name: "pdf_render", label: "PDF Render", description: "Render PDF pages to PNG with pypdfium2 for visual inspection.", parameters: Type.Object({ inputPath: Type.String(), outputDirectory: OptionalPath, pages: Type.Optional(Type.Array(Type.Integer({ minimum: 1 }))) }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { return result("PDF 页面已渲染。", await pdfRender(runtime(projectRoot, c.cwd, s), p.inputPath, p.outputDirectory, p.pages)); } }); pi.registerTool({ name: "pdf_encrypt", label: "PDF Encrypt", description: "Encrypt a PDF with AES-256. Password is collected in a masked UI and sent only through worker stdin.", parameters: Type.Object({ inputPath: Type.String(), outputPath: OptionalPath, overwrite: Overwrite, previewDirectory: Preview }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { if (!c.hasUI) throw new OfficeError("PDF 加密需要交互式掩码输入界面。", "MASKED_UI_REQUIRED"); const password = await requestMaskedSecret(c, "输入 PDF 密码", s); const confirm = await requestMaskedSecret(c, "再次输入 PDF 密码", s); if (password !== confirm) throw new OfficeError("两次密码不一致。", "PASSWORD_MISMATCH"); const output = await pdfEncrypt(runtime(projectRoot, c.cwd, s), p, password); return result("PDF 已加密。密码未写入参数、命令行或日志。", output); }, }); pi.registerTool({ name: "pdf_decrypt", label: "PDF Decrypt", description: "Decrypt a PDF. Password is collected in a masked UI and sent only through worker stdin.", parameters: Type.Object({ inputPath: Type.String(), outputPath: OptionalPath, overwrite: Overwrite, previewDirectory: Preview }, { additionalProperties: false }), async execute(_i, p, s, _u, c) { if (!c.hasUI) throw new OfficeError("PDF 解密需要交互式掩码输入界面。", "MASKED_UI_REQUIRED"); const password = await requestMaskedSecret(c, "输入 PDF 密码", s); const output = await pdfDecrypt(runtime(projectRoot, c.cwd, s), p, password); return result("PDF 已解密。密码未写入参数、命令行或日志。", output); }, }); }