diff --git a/src/core/render/CLAUDE.md b/src/core/render/CLAUDE.md index 98fda52..21533c8 100644 --- a/src/core/render/CLAUDE.md +++ b/src/core/render/CLAUDE.md @@ -16,6 +16,11 @@ src/core/render/ Word sandbox Container word-windows.ts — Microsoft Word via PowerShell COM (`ExportAsFixedFormat`) + stage-for-word.ts — `stageForWord`: the staged copy both Word engines + open, minus `w:dirty` field flags and + `` — either makes Word block on open + with "update the fields?" (alerts-none doesn't + suppress it), hanging unattended renders libreoffice.ts — `soffice --headless --convert-to pdf` (cross-platform) split-pdf.ts — PDF → PNG/JPG via @hyzyla/pdfium (WASM) + pngjs / diff --git a/src/core/render/engines/stage-for-word.ts b/src/core/render/engines/stage-for-word.ts new file mode 100644 index 0000000..b0279a7 --- /dev/null +++ b/src/core/render/engines/stage-for-word.ts @@ -0,0 +1,74 @@ +import { Pkg } from "../../ast/document/package"; + +/** Copy `inputDocx` to `stagedDocx` for Word to open — minus the markup that + * makes Word BLOCK on open with a question nobody is there to answer. + * + * Word asks "This document contains fields that may refer to other files. Do + * you want to update the fields in this document?" when it opens a document + * whose fields are flagged stale: a `w:dirty="true"` on a field's + * `` / `` (verified: such a file + * popped the modal, its dirty-free twin rendered cleanly), or the document-level + * `` in `settings.xml` (Word writes it after + * inserting a TOC). `set display alerts to none` does not suppress the prompt — + * it sat on the user's screen while renders waited on it, Word bouncing in the + * Dock — so an unattended render hung until a human clicked. + * + * Stripping both from the STAGED copy renders exactly what clicking "No" shows + * (the field results saved in the file) and never touches the user's document. + * Only parts that carry the markup are rewritten — as text, so nothing else in + * them is re-serialized; every other part is copied as stored. Word for Windows + * opens the same staged copy, so a prompt there can't wedge the COM call either. + * + * An input that isn't a readable zip is copied as-is: Word's own corrupt-file + * handling (and the honest RENDER_FAILED it produces) stays unchanged. */ +export async function stageForWord( + inputDocx: string, + stagedDocx: string, +): Promise { + const pkg = await openOrNull(inputDocx); + const changed = pkg ? await stripUpdatePrompts(pkg) : false; + if (!pkg || !changed) { + await Bun.write(stagedDocx, Bun.file(inputDocx)); + return; + } + await pkg.save(stagedDocx); +} + +async function openOrNull(path: string): Promise { + try { + return await Pkg.open(path); + } catch { + return null; + } +} + +/** Remove the update-fields-on-open flag and every dirty field marker from the + * parts that can hold them. Returns whether anything changed. */ +async function stripUpdatePrompts(pkg: Pkg): Promise { + let changed = false; + for (const part of pkg.listParts()) { + if (!PROMPTING_PART.test(part)) continue; + const xml = await pkg.readText(part); + const stripped = xml.replace(UPDATE_FIELDS, "").replace(DIRTY_FIELD, "$1"); + if (stripped === xml) continue; + pkg.writeText(part, stripped); + changed = true; + } + return changed; +} + +/** The parts a field (or the settings flag) can live in: the body, headers, + * footers, notes, comments, and settings. */ +const PROMPTING_PART = + /^word\/(?:document|settings|footnotes|endnotes|comments|header\d*|footer\d*)\.xml$/; + +/** `` (any prefix; self-closing, or an empty open/close + * pair). CT_OnOff has no children, so a text-level strip is exact. */ +const UPDATE_FIELDS = + /<(?:[A-Za-z_][\w.-]*:)?updateFields\b[^>]*?(?:\/>|>\s*<\/(?:[A-Za-z_][\w.-]*:)?updateFields>)/g; + +/** A `dirty` attribute on a field marker (``, + * ``), captured up to the attribute so the replace + * keeps the rest of the tag. `dirty` exists only on these two elements. */ +const DIRTY_FIELD = + /(<(?:[A-Za-z_][\w.-]*:)?(?:fldChar|fldSimple)\b[^>]*?)\s+(?:[A-Za-z_][\w.-]*:)?dirty\s*=\s*(?:"[^"]*"|'[^']*')/g; diff --git a/src/core/render/engines/word-mac.ts b/src/core/render/engines/word-mac.ts index 3e81874..1942b86 100644 --- a/src/core/render/engines/word-mac.ts +++ b/src/core/render/engines/word-mac.ts @@ -10,6 +10,7 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { stageForWord } from "./stage-for-word"; import { type RenderEngine, RenderEngineError } from "./types"; /** Word for Mac engine. Drives Microsoft Word via `osascript` to open the @@ -43,7 +44,8 @@ export const wordMacEngine: RenderEngine = { const stagedDocx = join(stageDir, stagedName); const stagedPdf = join(stageDir, `.docx-cli-render-${tag}.pdf`); try { - await Bun.write(stagedDocx, Bun.file(inputDocx)); + // Staged WITHOUT "update fields on open" — see stageForWord. + await stageForWord(inputDocx, stagedDocx); // Word's AppleScript `save as` with `format PDF` is the canonical // way to export a PDF from Word for Mac. `close ... saving no` // suppresses the "Save changes?" prompt (we already wrote the diff --git a/src/core/render/engines/word-windows.ts b/src/core/render/engines/word-windows.ts index 8c0004e..a752908 100644 --- a/src/core/render/engines/word-windows.ts +++ b/src/core/render/engines/word-windows.ts @@ -1,6 +1,7 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { stageForWord } from "./stage-for-word"; import { type RenderEngine, RenderEngineError } from "./types"; /** Word for Windows engine. Drives Microsoft Word via PowerShell's COM @@ -39,7 +40,8 @@ export const wordWindowsEngine: RenderEngine = { const stagedDocx = `${stage}.docx`; const stagedPdf = `${stage}.pdf`; try { - await Bun.write(stagedDocx, Bun.file(inputDocx)); + // Staged WITHOUT "update fields on open" — see stageForWord. + await stageForWord(inputDocx, stagedDocx); const script = [ "$ErrorActionPreference = 'Stop'", "$word = New-Object -ComObject Word.Application", diff --git a/tests/core/render/stage-for-word.test.ts b/tests/core/render/stage-for-word.test.ts new file mode 100644 index 0000000..b4151ed --- /dev/null +++ b/tests/core/render/stage-for-word.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Pkg } from "../../../src/core/ast/document/package"; +import { stageForWord } from "../../../src/core/render/engines/stage-for-word"; + +// Word for Mac blocks on open with "This document contains fields that may +// refer to other files. Do you want to update the fields…?" when a field is +// flagged dirty or settings ask to update fields on open — and `display alerts +// to none` doesn't suppress it, so an unattended render hung on the user's +// screen. The staged copy Word opens must carry neither. + +const DOCUMENT = ` + PAGE 14dirty="true" stays as text`; +const FOOTER = ` +`; +const SETTINGS = ` +`; +const STYLES = ``; + +async function buildDocx(parts: Record): Promise { + const path = join(mkdtempSync(join(tmpdir(), "stage-for-word-")), "in.docx"); + const pkg = Pkg.empty(path); + for (const [name, content] of Object.entries(parts)) + pkg.writeText(name, content); + await pkg.save(); + return path; +} + +function stagedPath(input: string): string { + return input.replace(/in\.docx$/, "staged.docx"); +} + +describe("stageForWord", () => { + test("strips update-on-open and dirty field flags from every field part", async () => { + const input = await buildDocx({ + "word/document.xml": DOCUMENT, + "word/footer1.xml": FOOTER, + "word/settings.xml": SETTINGS, + "word/styles.xml": STYLES, + }); + const staged = stagedPath(input); + await stageForWord(input, staged); + + const pkg = await Pkg.open(staged); + const document = await pkg.readText("word/document.xml"); + expect(document).toContain(''); + expect(document).toContain(''); + expect(document).not.toMatch(/]*dirty/); + // Text that merely spells the attribute is content, not markup. + expect(document).toContain('dirty="true" stays as text'); + expect(await pkg.readText("word/footer1.xml")).toContain( + '', + ); + const settings = await pkg.readText("word/settings.xml"); + expect(settings).not.toContain("updateFields"); + expect(settings).toContain(' { + const input = await buildDocx({ + "word/document.xml": DOCUMENT.replace(/ w:dirty="[^"]*"/g, ""), + "word/settings.xml": SETTINGS.replace( + '', + "", + ), + }); + const staged = stagedPath(input); + await stageForWord(input, staged); + expect(await Bun.file(staged).bytes()).toEqual( + await Bun.file(input).bytes(), + ); + }); + + test("a file that isn't a zip is copied as-is for Word's own error path", async () => { + const input = join( + mkdtempSync(join(tmpdir(), "stage-for-word-")), + "in.docx", + ); + await Bun.write(input, "not a zip"); + const staged = stagedPath(input); + await stageForWord(input, staged); + expect(await Bun.file(staged).text()).toBe("not a zip"); + }); +});