From c33f9e2d63dc3ac5b2f0d06aa91d33e225d2d83e Mon Sep 17 00:00:00 2001 From: Kirill Klimuk Date: Thu, 5 Nov 2026 16:50:19 -0800 Subject: [PATCH] fix(render): stop Word blocking on "update the fields?" when it opens a render A document with a field flagged w:dirty, or with in its settings, makes Word ask "This document contains fields that may refer to other files. Do you want to update the fields in this document?" when it opens the file. `set display alerts to none` does not suppress the question, so an unattended render waited on a modal on the user's screen while Word bounced in the Dock. Both Word engines already open a staged copy. stageForWord now removes both triggers from that copy, so the render shows exactly what clicking "No" shows: the field results saved in the file. The user's document is never touched. Only the parts carrying the markup are rewritten, as text. An unreadable zip is copied as-is, so Word's corrupt-file path and its RENDER_FAILED are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/core/render/CLAUDE.md | 5 ++ src/core/render/engines/stage-for-word.ts | 74 ++++++++++++++++++ src/core/render/engines/word-mac.ts | 4 +- src/core/render/engines/word-windows.ts | 4 +- tests/core/render/stage-for-word.test.ts | 91 +++++++++++++++++++++++ 5 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 src/core/render/engines/stage-for-word.ts create mode 100644 tests/core/render/stage-for-word.test.ts 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"); + }); +});