Skip to content

Commit ade39c1

Browse files
committed
Merge branch 'fix/parsers-office-structure' into eval/kb-parser-quality
2 parents dc33570 + 8e0385c commit ade39c1

13 files changed

Lines changed: 1332 additions & 292 deletions

‎apps/sim/lib/file-parsers/docx-parser.ts‎

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,26 @@ import {
66
isEncryptedOfficeParserError,
77
toFileParserError,
88
} from '@/lib/file-parsers/errors'
9+
import {
10+
assertHtmlStringWithinLimits,
11+
htmlToStructuredText,
12+
isHtmlComplexityError,
13+
} from '@/lib/file-parsers/html-parser'
914
import { parseOfficeText } from '@/lib/file-parsers/officeparser-module'
1015
import type { FileParseOptions, FileParseResult, FileParser } from '@/lib/file-parsers/types'
1116
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
1217
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
1318

1419
const logger = createLogger('DocxParser')
1520

16-
interface MammothMessage {
17-
type: 'warning' | 'error'
18-
message: string
19-
}
20-
21-
interface MammothResult {
22-
value: string
23-
messages: MammothMessage[]
24-
}
25-
21+
/**
22+
* Extracts DOCX text by rendering the document to HTML with mammoth and walking
23+
* that HTML with the shared structured-text walker. mammoth's HTML keeps the
24+
* heading levels, list nesting, table rows, and footnotes that its raw-text mode
25+
* flattens to one paragraph per cell, so the output matches what the HTML parser
26+
* produces for the same document. (mammoth's Markdown mode is deprecated and
27+
* drops tables, so it is deliberately not used.)
28+
*/
2629
export class DocxParser implements FileParser {
2730
async parseFile(filePath: string, options: FileParseOptions = {}): Promise<FileParseResult> {
2831
if (!filePath) {
@@ -46,24 +49,29 @@ export class DocxParser implements FileParser {
4649
let parserReturnedEmpty = false
4750

4851
try {
49-
const result = await mammoth.extractRawText({ buffer })
52+
const htmlResult = await mammoth.convertToHtml({ buffer })
5053
options.signal?.throwIfAborted()
5154

52-
if (result.value && result.value.trim().length > 0) {
53-
let htmlResult: MammothResult = { value: '', messages: [] }
54-
try {
55-
htmlResult = await mammoth.convertToHtml({ buffer })
56-
} catch {
57-
// HTML conversion is optional
55+
const structured = this.structuredTextFromHtml(htmlResult.value)
56+
if (structured) {
57+
return {
58+
content: sanitizeTextForUTF8(structured),
59+
metadata: {
60+
extractionMethod: 'mammoth-html',
61+
messages: htmlResult.messages,
62+
},
5863
}
59-
options.signal?.throwIfAborted()
64+
}
6065

66+
const rawResult = await mammoth.extractRawText({ buffer })
67+
options.signal?.throwIfAborted()
68+
69+
if (rawResult.value && rawResult.value.trim().length > 0) {
6170
return {
62-
content: sanitizeTextForUTF8(result.value),
71+
content: sanitizeTextForUTF8(rawResult.value),
6372
metadata: {
6473
extractionMethod: 'mammoth',
65-
messages: [...result.messages, ...htmlResult.messages],
66-
html: htmlResult.value,
74+
messages: [...htmlResult.messages, ...rawResult.messages],
6775
},
6876
}
6977
}
@@ -140,4 +148,23 @@ export class DocxParser implements FileParser {
140148
throw toFileParserError(error, 'invalid_format', 'Failed to parse DOCX buffer')
141149
}
142150
}
151+
152+
/**
153+
* Walks mammoth's HTML rendering under the HTML parser's size caps. A rendering
154+
* too large to walk safely falls back to the raw-text path by returning empty,
155+
* since mammoth has already materialised the document once at that point.
156+
*/
157+
private structuredTextFromHtml(html: string): string {
158+
if (!html || html.trim().length === 0) return ''
159+
try {
160+
assertHtmlStringWithinLimits(html)
161+
} catch (error) {
162+
if (isHtmlComplexityError(error)) {
163+
logger.warn('mammoth HTML exceeds walker limits, using raw text:', error.message)
164+
return ''
165+
}
166+
throw error
167+
}
168+
return htmlToStructuredText(html).trim()
169+
}
143170
}

‎apps/sim/lib/file-parsers/html-parser.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,5 +118,31 @@ describe('HtmlParser', () => {
118118
expect(result.metadata?.listCount).toBe(1)
119119
expect(result.metadata?.tableCount).toBe(1)
120120
})
121+
122+
it('numbers ordered lists and keeps markers on nested items', async () => {
123+
const buffer = Buffer.from(
124+
`<body><ol start="3"><li>third</li><li>fourth<ul><li>nested</li></ul></li></ol></body>`
125+
)
126+
127+
const result = await parser.parseBuffer(buffer)
128+
129+
expect(result.content).toContain('3. third')
130+
expect(result.content).toContain('4. fourth')
131+
expect(result.content).toContain(' • nested')
132+
expect(result.content).not.toContain('fourth nested')
133+
})
134+
135+
it('drops footnote return links but keeps the footnote text', async () => {
136+
const buffer = Buffer.from(
137+
`<body><p>Body<sup><a href="#footnote-1" id="footnote-ref-1">[1]</a></sup></p>` +
138+
`<ol><li id="footnote-1"><p>Note text <a href="#footnote-ref-1">↑</a></p></li></ol></body>`
139+
)
140+
141+
const result = await parser.parseBuffer(buffer)
142+
143+
expect(result.content).toContain('Body[1]')
144+
expect(result.content).toContain('1. Note text')
145+
expect(result.content).not.toContain('↑')
146+
})
121147
})
122148
})

0 commit comments

Comments
 (0)