Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/core/render/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<w:updateFields>` — 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 /
Expand Down
74 changes: 74 additions & 0 deletions src/core/render/engines/stage-for-word.ts
Original file line number Diff line number Diff line change
@@ -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
* `<w:fldChar w:fldCharType="begin">` / `<w:fldSimple>` (verified: such a file
* popped the modal, its dirty-free twin rendered cleanly), or the document-level
* `<w:updateFields w:val="true"/>` 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<void> {
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<Pkg | null> {
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<boolean> {
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$/;

/** `<w:updateFields …/>` (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 (`<w:fldChar … w:dirty="true"/>`,
* `<w:fldSimple … w:dirty="1">`), 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;
4 changes: 3 additions & 1 deletion src/core/render/engines/word-mac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/core/render/engines/word-windows.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions tests/core/render/stage-for-word.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:dirty="true" w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r><w:fldSimple w:instr=" NUMPAGES " w:dirty="1"><w:r><w:t>4</w:t></w:r></w:fldSimple><w:r><w:t>dirty="true" stays as text</w:t></w:r></w:p><w:sectPr/></w:body></w:document>`;
const FOOTER = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r></w:p></w:ftr>`;
const SETTINGS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:zoom w:percent="100"/><w:updateFields w:val="true"/><w:defaultTabStop w:val="720"/></w:settings>`;
const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>`;

async function buildDocx(parts: Record<string, string>): Promise<string> {
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('<w:fldChar w:fldCharType="begin"/>');
expect(document).toContain('<w:fldSimple w:instr=" NUMPAGES ">');
expect(document).not.toMatch(/<w:fld[^>]*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(
'<w:fldChar w:fldCharType="begin"/>',
);
const settings = await pkg.readText("word/settings.xml");
expect(settings).not.toContain("updateFields");
expect(settings).toContain('<w:zoom w:percent="100"/><w:defaultTabStop');
expect(await pkg.readText("word/styles.xml")).toBe(STYLES);

// The user's file is untouched.
const original = await Pkg.open(input);
expect(await original.readText("word/settings.xml")).toBe(SETTINGS);
});

test("a document without the flags is copied byte-for-byte", async () => {
const input = await buildDocx({
"word/document.xml": DOCUMENT.replace(/ w:dirty="[^"]*"/g, ""),
"word/settings.xml": SETTINGS.replace(
'<w:updateFields w:val="true"/>',
"",
),
});
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");
});
});
Loading