Skip to content

Commit 04c0e5c

Browse files
committed
fix(microsoft-word): strip XML-forbidden control characters from generated documents
1 parent 4db1054 commit 04c0e5c

2 files changed

Lines changed: 31 additions & 3 deletions

File tree

apps/sim/lib/microsoft-word/document.server.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import {
1212
replaceTextInDocx,
1313
} from '@/lib/microsoft-word/document.server'
1414

15+
/** The characters XML 1.0 forbids in a text node, as a fresh (unstateful) matcher. */
16+
const INVALID_XML_CHARS_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/
17+
1518
/** Reads one XML part out of a generated package. */
1619
async function readPart(buffer: Buffer, path = 'word/document.xml'): Promise<string> {
1720
const zip = await JSZip.loadAsync(buffer)
@@ -89,6 +92,22 @@ describe('buildDocxFromContent', () => {
8992
expect(text).toContain('2 * 3 = 6')
9093
})
9194

95+
it('strips control characters that would make the package unopenable', async () => {
96+
// The docx package writes run text into the XML verbatim, so a forbidden
97+
// character reaching it produces invalid XML 1.0 rather than a rendering bug.
98+
const buffer = await buildDocxFromContent(`before${String.fromCharCode(8)}after`)
99+
const xml = await readDocumentXml(buffer)
100+
101+
expect(INVALID_XML_CHARS_PATTERN.test(xml)).toBe(false)
102+
expect(xml).toContain('beforeafter')
103+
})
104+
105+
it('keeps tabs, which are legal XML, while stripping forbidden characters', async () => {
106+
const buffer = await buildDocxFromContent(`a\tb${String.fromCharCode(0)}c`)
107+
108+
expect(await extractDocxText(buffer)).toContain('a\tbc')
109+
})
110+
92111
it('still produces an openable document for empty content', async () => {
93112
const buffer = await buildDocxFromContent('')
94113
const xml = await readDocumentXml(buffer)

apps/sim/lib/microsoft-word/document.server.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ const INLINE_EMPHASIS_PATTERN = /\*\*([^*]+)\*\*|\*([^*]+)\*/g
3434
/** Characters XML 1.0 forbids in a text node. */
3535
const INVALID_XML_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g
3636

37+
/** Removes the characters XML 1.0 forbids in a text node. */
38+
function stripInvalidXmlChars(value: string): string {
39+
return value.replace(INVALID_XML_CHARS, '')
40+
}
41+
3742
/**
3843
* Splits a line into bold / italic runs using the Markdown subset Sim supports:
3944
* `**bold**` and `*italic*`. Anything else is emitted verbatim, so unmatched
@@ -77,7 +82,12 @@ function parseInlineRuns(line: string): ContentRun[] {
7782
function parseContentBlocks(content: string): ContentBlock[] {
7883
const blocks: ContentBlock[] = []
7984

80-
for (const rawLine of content.replace(/\r\n?/g, '\n').split('\n')) {
85+
// Stripped once here rather than per run: the `docx` package writes run text
86+
// into the XML verbatim, so a stray control character from an upstream block
87+
// would produce a package Word refuses to open. Tab, newline, and carriage
88+
// return are legal XML and are outside the class, so the split below is
89+
// unaffected.
90+
for (const rawLine of stripInvalidXmlChars(content).replace(/\r\n?/g, '\n').split('\n')) {
8191
const line = rawLine.trimEnd()
8292
if (line.trim().length === 0) continue
8393

@@ -132,8 +142,7 @@ export async function buildDocxFromContent(content: string, title?: string): Pro
132142

133143
/** Escapes text for an XML text node and drops characters XML 1.0 forbids. */
134144
function escapeXmlText(value: string): string {
135-
return value
136-
.replace(INVALID_XML_CHARS, '')
145+
return stripInvalidXmlChars(value)
137146
.replace(/&/g, '&amp;')
138147
.replace(/</g, '&lt;')
139148
.replace(/>/g, '&gt;')

0 commit comments

Comments
 (0)