import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { OfficeError } from "../core/errors.ts"; import { cleanupStaged, createOutputDirectory, planOutput, publishStagedFile, requireInputFile, type OutputPlan, } from "../core/paths.ts"; import { makePreviewDirectory, makeWorkDirectory, removeWorkDirectory } from "../core/temp.ts"; import { normalizeMarkdownWithoutTables } from "./content.ts"; import { WordRuntime } from "./runtime.ts"; import { resolveWordStyle, type StylePreset, type WordStyle, type WordStyleInput } from "./styles.ts"; export type DocumentType = "general" | "report" | "contract" | "news"; export type TrackedView = "accepted" | "current" | "baseline"; export interface WordArtifactResult { outputPath: string; validation: unknown; previewDirectory: string; previewPaths: string[]; attempts: number; warnings: string[]; style?: WordStyle; appliedFonts?: { chineseBodyFont: string; latinBodyFont: string; headingFont: string }; } export interface ShapeInfo { scope: string; name: string; type: number; hasText: boolean; text: string | null; complex: boolean; } export interface WordInspection { path: string; protectionType: number; trackRevisions: boolean; paragraphs: number; tables: number; inlineShapes: number; sections: number; shapes: ShapeInfo[]; textBoxCount: number; complexShapeCount: number; } function parseOutputPaths(stdout: string): string[] { return stdout.split(/\r?\n/) .map((line) => line.trim()) .filter((line) => Boolean(line) && !line.startsWith("{")) .map((line) => path.normalize(line)); } async function validateStaged(runtime: WordRuntime, stagedPath: string): Promise { return await runtime.validateDocx(stagedPath); } async function renderValidated( runtime: WordRuntime, inputPath: string, requestedDirectory?: string, ): Promise<{ directory: string; paths: string[]; attempts: number }> { const directory = requestedDirectory ? createOutputDirectory(requestedDirectory, runtime.options.cwd) : makePreviewDirectory(); let lastError: unknown; for (let attempt = 1; attempt <= 2; attempt += 1) { try { const result = await runtime.docxOk([ "render", inputPath, "--out", directory, "--engine", "word", "--format", "png", ], 300_000); const paths = parseOutputPaths(result.stdout).filter((candidate) => existsSync(candidate)); if (paths.length === 0) throw new OfficeError("Word 渲染未生成页面图片。", "WORD_RENDER_EMPTY"); return { directory, paths, attempts: attempt }; } catch (error) { lastError = error; if (existsSync(directory)) { for (const entry of readdirSync(directory)) rmSync(path.join(directory, entry), { recursive: true, force: true }); } } } if (!requestedDirectory) rmSync(directory, { recursive: true, force: true }); throw lastError; } async function finalizeDocx( runtime: WordRuntime, plan: OutputPlan, requestedPreviewDirectory?: string, warnings: string[] = [], ): Promise { let previewDirectory: string | undefined; try { const validation = await validateStaged(runtime, plan.stagedPath); const rendered = await renderValidated(runtime, plan.stagedPath, requestedPreviewDirectory); previewDirectory = rendered.directory; publishStagedFile(plan); return { outputPath: plan.finalPath, validation, previewDirectory: rendered.directory, previewPaths: rendered.paths, attempts: rendered.attempts, warnings, }; } catch (error) { cleanupStaged(plan); if (previewDirectory && !requestedPreviewDirectory) rmSync(previewDirectory, { recursive: true, force: true }); throw error; } } function styleCommands(style: WordStyle): string[][] { return [ ["styles", "set-default-font", "$FILE", style.chineseBodyFont, "--size", String(style.bodySize), "--all"], ["styles", "set", "$FILE", "--at", "Title", "--font", style.headingFont, "--size", String(style.titleSize), "--bold", "--alignment", "center", "--line-spacing", String(style.lineSpacing)], ["styles", "set", "$FILE", "--at", "Heading1", "--font", style.headingFont, "--size", String(style.heading1Size), "--bold", "--color", "252525", "--space-before", "24", "--space-after", "18", "--line-spacing", String(style.lineSpacing)], ["styles", "set", "$FILE", "--at", "Heading2", "--font", style.headingFont, "--size", String(style.heading2Size), "--bold", "--color", "252525", "--space-before", "17", "--space-after", "7", "--line-spacing", String(style.lineSpacing)], ["styles", "set", "$FILE", "--at", "Heading3", "--font", style.headingFont, "--size", String(style.heading3Size), "--bold", "--color", style.accentColor, "--space-before", "12", "--space-after", "5", "--line-spacing", String(style.lineSpacing)], ]; } function writeJsonl(directory: string, name: string, value: Record): string { const filePath = path.join(directory, name); writeFileSync(filePath, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); return filePath; } interface StyleApplicationResult { preset: string; tablesConverted: number; appliedFonts: { chineseBodyFont: string; latinBodyFont: string; headingFont: string }; } function normalizeFont(value: string): string { const normalized = value.trim().toLocaleLowerCase(); // Word COM returns localized family names on some Windows installations even // when the requested (and installed) face uses its English name. const aliases: Record = { "microsoft yahei": "microsoft yahei", "微软雅黑": "microsoft yahei", "simsun": "simsun", "宋体": "simsun", "simhei": "simhei", "黑体": "simhei", "fangsong": "fangsong", "仿宋": "fangsong", "kaiti": "kaiti", "楷体": "kaiti", }; return aliases[normalized] ?? normalized; } function verifyAppliedFonts(style: WordStyle, applied: StyleApplicationResult["appliedFonts"]): void { const expected = { chineseBodyFont: style.chineseBodyFont, latinBodyFont: style.latinBodyFont, headingFont: style.headingFont, }; const mismatches = Object.entries(expected) .filter(([key, value]) => normalizeFont(applied[key as keyof typeof applied]) !== normalizeFont(value)) .map(([key, value]) => `${key}: 请求 ${value},实际 ${applied[key as keyof typeof applied]}`); if (mismatches.length > 0) { throw new OfficeError(`Word 未按请求应用字体:${mismatches.join(";")}。`, "FONT_SUBSTITUTED", { expected, applied }); } } export async function wordCreate(runtime: WordRuntime, options: { outputPath: string; title?: string; author?: string; documentType?: DocumentType; markdown?: string; text?: string; stylePreset?: StylePreset; style?: WordStyleInput; overwrite?: boolean; previewDirectory?: string; }): Promise { if ((options.markdown === undefined) === (options.text === undefined)) { throw new OfficeError("必须且只能提供 markdown 或 text。", "CONTENT_REQUIRED"); } const plan = planOutput({ outputPath: options.outputPath, cwd: runtime.options.cwd, extension: ".docx", overwrite: options.overwrite }); const work = makeWorkDirectory(); try { const normalized = options.markdown !== undefined ? normalizeMarkdownWithoutTables(options.markdown) : { markdown: options.text ?? "", convertedTables: 0 }; const contentPath = path.join(work, options.markdown !== undefined ? "content.md" : "content.txt"); writeFileSync(contentPath, normalized.markdown, { encoding: "utf8", mode: 0o600 }); const type = options.documentType ?? "general"; const style = resolveWordStyle(type, options.stylePreset, options.style); await runtime.requireFonts([style.chineseBodyFont, style.latinBodyFont, style.headingFont]); const args = ["create", plan.stagedPath, options.markdown !== undefined ? "--from" : "--text-file", contentPath, "--margins", "1", "--force"]; if (options.title) args.push("--title", options.title); if (options.author) args.push("--author", options.author); await runtime.docxOk(args); for (const command of styleCommands(style)) { await runtime.docxOk(command.map((part) => part === "$FILE" ? plan.stagedPath : part)); } const styled = await runtime.withJsonPayload("style-editorial.ps1", { path: plan.stagedPath, documentType: type, title: options.title ?? "", style, convertTables: true, }, 180_000); verifyAppliedFonts(style, styled.appliedFonts); const convertedTables = normalized.convertedTables + styled.tablesConverted; const warnings = convertedTables > 0 ? [`已将 ${convertedTables} 个表格转换为普通段落;word_create 默认不生成表格。`] : []; return { ...await finalizeDocx(runtime, plan, options.previewDirectory, warnings), style, appliedFonts: styled.appliedFonts }; } catch (error) { cleanupStaged(plan); throw error; } finally { removeWorkDirectory(work); } } export async function wordApplyStyle(runtime: WordRuntime, options: { inputPath: string; documentType?: DocumentType; stylePreset?: StylePreset; style?: WordStyleInput; outputPath?: string; overwrite?: boolean; previewDirectory?: string; }): Promise { const inputPath = requireInputFile(options.inputPath, runtime.options.cwd, [".docx"]); const type = options.documentType ?? "general"; const style = resolveWordStyle(type, options.stylePreset, options.style); const plan = planOutput({ inputPath, outputPath: options.outputPath, cwd: runtime.options.cwd, extension: ".docx", overwrite: options.overwrite }); try { await runtime.requireFonts([style.chineseBodyFont, style.latinBodyFont, style.headingFont]); copyFileSync(inputPath, plan.stagedPath); const styled = await runtime.withJsonPayload("style-editorial.ps1", { path: plan.stagedPath, documentType: type, title: "", style, convertTables: false, }, 180_000); verifyAppliedFonts(style, styled.appliedFonts); return { ...await finalizeDocx(runtime, plan, options.previewDirectory), style, appliedFonts: styled.appliedFonts }; } catch (error) { cleanupStaged(plan); throw error; } } export async function wordRead(runtime: WordRuntime, options: { inputPath: string; view?: TrackedView; comments?: boolean; from?: string; to?: string; ast?: boolean; }): Promise<{ inputPath: string; content: string; ast: boolean }> { const inputPath = requireInputFile(options.inputPath, runtime.options.cwd, [".docx"]); const args = ["read", inputPath]; if (options.ast) args.push("--ast"); else { args.push(`--${options.view ?? "accepted"}`); if (options.comments) args.push("--comments"); if (options.from) args.push("--from", options.from); if (options.to) args.push("--to", options.to); } const result = await runtime.docxOk(args); return { inputPath, content: result.stdout, ast: options.ast === true }; } export async function wordInspect(runtime: WordRuntime, input: string): Promise<{ inputPath: string; validation: unknown; com: WordInspection; }> { const inputPath = requireInputFile(input, runtime.options.cwd, [".docx"]); const validation = await runtime.validateDocx(inputPath); const com = await runtime.powershellJson("inspect.ps1", ["-Path", inputPath], 180_000); return { inputPath, validation, com }; } export async function wordReplace(runtime: WordRuntime, options: { inputPath: string; pattern: string; replacement: string; outputPath?: string; all?: boolean; regex?: boolean; ignoreCase?: boolean; track?: boolean; overwrite?: boolean; previewDirectory?: string; }): Promise { if (!options.pattern) throw new OfficeError("替换模式不能为空。", "EMPTY_PATTERN"); const inputPath = requireInputFile(options.inputPath, runtime.options.cwd, [".docx"]); const plan = planOutput({ inputPath, outputPath: options.outputPath, cwd: runtime.options.cwd, extension: ".docx", overwrite: options.overwrite }); const inspection = await runtime.powershellJson("inspect.ps1", ["-Path", inputPath], 180_000); const textShapes = inspection.shapes.filter((shape) => shape.hasText && shape.text); if ((options.regex || options.ignoreCase) && textShapes.length > 0) { throw new OfficeError("文档含文本框;正则或忽略大小写替换无法保证覆盖形状文本,已失败关闭。", "SHAPE_REPLACE_UNSAFE"); } const shapeTargets = textShapes.filter((shape) => shape.text!.includes(options.pattern)); if (shapeTargets.some((shape) => shape.complex)) { throw new OfficeError("目标文字位于复杂形状中,需用户确认后另行处理。", "COMPLEX_SHAPE_TARGET", { shapes: shapeTargets }); } if (options.track && shapeTargets.length > 0) { throw new OfficeError("Word 对文本框文字修订的兼容性无法稳定验证;带修订的文本框替换已失败关闭。", "SHAPE_TRACKING_UNSUPPORTED"); } const work = makeWorkDirectory(); let bodyChanged = false; let shapeReplacements = 0; try { const batch = writeJsonl(work, "replace.jsonl", { pattern: options.pattern, replacement: options.replacement, ...(options.all ? { all: true } : {}), ...(options.regex ? { regex: true } : {}), ...(options.ignoreCase ? { ignoreCase: true } : {}), ...(options.track ? { track: true } : {}), }); const result = await runtime.docx(["replace", inputPath, "--batch", batch, "-o", plan.stagedPath]); if (result.code === 0) bodyChanged = true; else if (result.code === 3 && shapeTargets.length > 0) copyFileSync(inputPath, plan.stagedPath); else { throw new OfficeError("正文中未找到目标文字,且没有可安全处理的文本框匹配。", "MATCH_NOT_FOUND", { exitCode: result.code, stdout: result.stdout.slice(-2000), stderr: result.stderr.slice(-2000), }); } const shouldReplaceShapes = shapeTargets.length > 0 && (options.all || !bodyChanged); if (shouldReplaceShapes) { const shapeResult = await runtime.withJsonPayload<{ replacements: number }>("replace-shapes.ps1", { path: plan.stagedPath, find: options.pattern, replace: options.replacement, all: options.all === true, track: options.track === true, }, 180_000); shapeReplacements = shapeResult.replacements; } const artifact = await finalizeDocx(runtime, plan, options.previewDirectory, textShapes.length > 0 ? ["已使用 Word COM 检查文本框;复杂形状目标会失败关闭。"] : []); return { ...artifact, bodyChanged, shapeReplacements }; } catch (error) { cleanupStaged(plan); throw error; } finally { removeWorkDirectory(work); } } export async function wordEdit(runtime: WordRuntime, options: { inputPath: string; locator: string; text?: string; markdown?: string; outputPath?: string; track?: boolean; overwrite?: boolean; previewDirectory?: string; }): Promise { if ((options.text === undefined) === (options.markdown === undefined)) { throw new OfficeError("必须且只能提供 text 或 markdown。", "CONTENT_REQUIRED"); } const inputPath = requireInputFile(options.inputPath, runtime.options.cwd, [".docx"]); const plan = planOutput({ inputPath, outputPath: options.outputPath, cwd: runtime.options.cwd, extension: ".docx", overwrite: options.overwrite }); const work = makeWorkDirectory(); try { const batch = writeJsonl(work, "edit.jsonl", { at: options.locator, ...(options.text !== undefined ? { text: options.text } : { markdown: options.markdown }), ...(options.track ? { track: true } : {}), }); await runtime.docxOk(["edit", inputPath, "--batch", batch, "-o", plan.stagedPath]); return await finalizeDocx(runtime, plan, options.previewDirectory); } catch (error) { cleanupStaged(plan); throw error; } finally { removeWorkDirectory(work); } } export async function wordCommentAdd(runtime: WordRuntime, options: { inputPath: string; locator?: string; anchor?: string; text: string; author?: string; outputPath?: string; overwrite?: boolean; previewDirectory?: string; }): Promise { if ((options.locator === undefined) === (options.anchor === undefined)) { throw new OfficeError("批注必须且只能提供 locator 或 anchor。", "COMMENT_TARGET_REQUIRED"); } const inputPath = requireInputFile(options.inputPath, runtime.options.cwd, [".docx"]); const plan = planOutput({ inputPath, outputPath: options.outputPath, cwd: runtime.options.cwd, extension: ".docx", overwrite: options.overwrite }); const args = ["comments", "add", inputPath]; if (options.locator) args.push("--at", options.locator); if (options.anchor) args.push("--anchor", options.anchor); args.push("--text", options.text); if (options.author) args.push("--author", options.author); args.push("-o", plan.stagedPath); try { await runtime.docxOk(args); return await finalizeDocx(runtime, plan, options.previewDirectory); } catch (error) { cleanupStaged(plan); throw error; } } export async function wordTrackChanges(runtime: WordRuntime, options: { inputPath: string; action: "list" | "on" | "off" | "accept" | "reject"; locator?: string; all?: boolean; outputPath?: string; overwrite?: boolean; previewDirectory?: string; }): Promise<{ inputPath: string; changes?: unknown } | WordArtifactResult> { const inputPath = requireInputFile(options.inputPath, runtime.options.cwd, [".docx"]); if (options.action === "list") { const changes = await runtime.docxJson(["track-changes", "list", inputPath, "--json"]); return { inputPath, changes }; } const plan = planOutput({ inputPath, outputPath: options.outputPath, cwd: runtime.options.cwd, extension: ".docx", overwrite: options.overwrite }); const args = ["track-changes", options.action, inputPath]; if (options.action === "accept" || options.action === "reject") { if (options.all) args.push("--all"); else if (options.locator) args.push("--at", options.locator); else throw new OfficeError("接受或拒绝修订时必须提供 locator 或 all=true。", "TRACKED_CHANGE_TARGET_REQUIRED"); } args.push("-o", plan.stagedPath); try { await runtime.docxOk(args); return await finalizeDocx(runtime, plan, options.previewDirectory); } catch (error) { cleanupStaged(plan); throw error; } } export async function wordDiff(runtime: WordRuntime, input: string, against: string): Promise<{ inputPath: string; againstPath: string; diff: string }> { const inputPath = requireInputFile(input, runtime.options.cwd, [".docx"]); const againstPath = requireInputFile(against, runtime.options.cwd, [".docx", ".md", ".txt"]); const result = await runtime.docxOk(["diff", inputPath, "--against", againstPath]); return { inputPath, againstPath, diff: result.stdout }; } export async function wordValidate(runtime: WordRuntime, input: string): Promise<{ inputPath: string; validation: unknown }> { const inputPath = requireInputFile(input, runtime.options.cwd, [".docx"]); const validation = await runtime.validateDocx(inputPath); return { inputPath, validation }; } export async function wordRender(runtime: WordRuntime, input: string, outputDirectory?: string): Promise<{ inputPath: string; previewDirectory: string; previewPaths: string[]; attempts: number }> { const inputPath = requireInputFile(input, runtime.options.cwd, [".docx"]); const result = await renderValidated(runtime, inputPath, outputDirectory); return { inputPath, previewDirectory: result.directory, previewPaths: result.paths, attempts: result.attempts }; } export async function wordConvertPdf(runtime: WordRuntime, options: { inputPath: string; outputPath?: string; overwrite?: boolean; previewDirectory?: string; }): Promise<{ outputPath: string; previewDirectory: string; previewPaths: string[]; pages: number }> { const inputPath = requireInputFile(options.inputPath, runtime.options.cwd, [".docx"]); const plan = planOutput({ inputPath, outputPath: options.outputPath, cwd: runtime.options.cwd, extension: ".pdf", overwrite: options.overwrite }); let previewDirectory: string | undefined; try { const converted = await runtime.withJsonPayload<{ pages: number }>("convert-pdf.ps1", { inputPath, outputPath: plan.stagedPath, }, 300_000); const header = readFileSync(plan.stagedPath).subarray(0, 5).toString("ascii"); if (header !== "%PDF-") throw new OfficeError("Word COM 输出不是有效 PDF。", "PDF_VALIDATION_FAILED"); const rendered = await renderValidated(runtime, inputPath, options.previewDirectory); previewDirectory = rendered.directory; publishStagedFile(plan); return { outputPath: plan.finalPath, previewDirectory: rendered.directory, previewPaths: rendered.paths, pages: converted.pages }; } catch (error) { cleanupStaged(plan); if (previewDirectory && !options.previewDirectory) rmSync(previewDirectory, { recursive: true, force: true }); throw error; } } export async function wordConvertLegacy(runtime: WordRuntime, options: { inputPath: string; outputPath?: string; confirmed: boolean; overwrite?: boolean; previewDirectory?: string; }): Promise { if (options.confirmed !== true) { throw new OfficeError("旧版 .doc 转换需要用户明确确认。", "LEGACY_CONVERSION_CONFIRMATION_REQUIRED"); } const inputPath = requireInputFile(options.inputPath, runtime.options.cwd, [".doc"]); const plan = planOutput({ inputPath, outputPath: options.outputPath, cwd: runtime.options.cwd, extension: ".docx", overwrite: options.overwrite }); try { await runtime.withJsonPayload("convert-legacy.ps1", { inputPath, outputPath: plan.stagedPath }, 300_000); return await finalizeDocx(runtime, plan, options.previewDirectory, ["旧版 .doc 已通过 Microsoft Word COM 转换为 .docx。"]); } catch (error) { cleanupStaged(plan); throw error; } } export async function wordComments(runtime: WordRuntime, options: { inputPath: string; action: "list" | "reply" | "resolve" | "reopen" | "delete"; locator?: string; text?: string; author?: string; outputPath?: string; overwrite?: boolean; previewDirectory?: string; }): Promise<{ inputPath: string; comments: unknown } | WordArtifactResult> { const inputPath = requireInputFile(options.inputPath, runtime.options.cwd, [".docx"]); if (options.action === "list") { const comments = await runtime.docxJson(["comments", "list", inputPath]); return { inputPath, comments }; } if (!options.locator) throw new OfficeError("批注操作需要 locator。", "COMMENT_TARGET_REQUIRED"); const plan = planOutput({ inputPath, outputPath: options.outputPath, cwd: runtime.options.cwd, extension: ".docx", overwrite: options.overwrite }); const verb = options.action === "reopen" ? "resolve" : options.action; const args = ["comments", verb, inputPath, "--at", options.locator]; if (options.action === "reply") { if (!options.text) throw new OfficeError("回复批注需要 text。", "COMMENT_TEXT_REQUIRED"); args.push("--text", options.text); if (options.author) args.push("--author", options.author); } if (options.action === "reopen") args.push("--unset"); args.push("-o", plan.stagedPath); try { await runtime.docxOk(args); return await finalizeDocx(runtime, plan, options.previewDirectory); } catch (error) { cleanupStaged(plan); throw error; } } export async function wordTable(runtime: WordRuntime, options: { inputPath: string; action: "create" | "insert-row" | "delete-row" | "set-widths" | "format"; locator?: string; placement?: "before" | "after" | "start" | "end"; rows?: number; columns?: number; position?: number; cells?: string[]; widths?: string; tableWidth?: string; borders?: "single" | "double" | "none"; layout?: "autofit" | "fixed"; shade?: string; verticalAlign?: "top" | "center" | "bottom"; horizontalAlign?: "left" | "center" | "right" | "justify"; outputPath?: string; overwrite?: boolean; previewDirectory?: string; }): Promise { const inputPath = requireInputFile(options.inputPath, runtime.options.cwd, [".docx"]); const plan = planOutput({ inputPath, outputPath: options.outputPath, cwd: runtime.options.cwd, extension: ".docx", overwrite: options.overwrite }); const args = ["tables", options.action, inputPath]; if (options.action === "create") { if (!options.rows || !options.columns) throw new OfficeError("创建表格需要 rows 和 columns。", "TABLE_SIZE_REQUIRED"); const placement = options.placement ?? "end"; if (placement === "start") args.push("--at-start"); else if (placement === "end") args.push("--at-end"); else { if (!options.locator) throw new OfficeError("before/after 放置需要 locator。", "TABLE_LOCATOR_REQUIRED"); args.push(`--${placement}`, options.locator); } args.push("--rows", String(options.rows), "--cols", String(options.columns)); if (options.widths) args.push("--widths", options.widths); if (options.tableWidth) args.push("--table-width", options.tableWidth); if (options.borders) args.push("--borders", options.borders); if (options.layout) args.push("--layout", options.layout); } else { if (!options.locator) throw new OfficeError("表格操作需要 locator。", "TABLE_LOCATOR_REQUIRED"); args.push("--at", options.locator); if (options.action === "insert-row") { if (options.position !== undefined) args.push("--position", String(options.position)); if (options.cells) args.push("--cells", options.cells.join(",")); } if (options.action === "set-widths") { if (!options.widths) throw new OfficeError("set-widths 需要 widths。", "TABLE_WIDTHS_REQUIRED"); args.push("--widths", options.widths); } if (options.action === "format") { if (options.shade) args.push("--shade", options.shade); if (options.verticalAlign) args.push("--valign", options.verticalAlign); if (options.horizontalAlign) args.push("--halign", options.horizontalAlign); if (!options.shade && !options.verticalAlign && !options.horizontalAlign) { throw new OfficeError("format 至少需要一种格式参数。", "TABLE_FORMAT_REQUIRED"); } } } args.push("-o", plan.stagedPath); try { await runtime.docxOk(args); return await finalizeDocx(runtime, plan, options.previewDirectory); } catch (error) { cleanupStaged(plan); throw error; } }