Skip to content

Commit 43a6668

Browse files
waleedlatif1claude
andcommitted
fix(parsers): tighten benchmark-found edge cases
Ambiguous archives and binary layouts stay on the SheetJS and legacy Word routes instead of being refused; line-end hyphens are removed only when the document shows the joined word; page numbers printed inside a wide margin are dropped from a page's edge lines; time-of-day cells no longer carry the 1899 epoch; table cells with several paragraphs keep a space between them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 0784418 commit 43a6668

14 files changed

Lines changed: 235 additions & 13 deletions

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ import { HtmlComplexityError, HtmlParser } from '@/lib/file-parsers/html-parser'
99

1010
const parser = new HtmlParser()
1111

12+
describe('table cells with several paragraphs', () => {
13+
it('separates block children inside a cell with a space', async () => {
14+
const html = '<table><tr><td><p>Заказчик</p><p>Исполняющий</p></td><td>ok</td></tr></table>'
15+
const result = await new HtmlParser().parseBuffer(Buffer.from(html))
16+
17+
expect(result.content).toContain('| Заказчик Исполняющий | ok |')
18+
})
19+
})
20+
1221
describe('HtmlParser', () => {
1322
it('reports empty input with the typed parser taxonomy', async () => {
1423
await expect(parser.parseBuffer(Buffer.alloc(0))).rejects.toMatchObject({

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ export function assertHtmlStringWithinLimits(html: string): void {
105105

106106
const NON_CONTENT_SELECTOR = 'script, style, noscript, meta, link, iframe, object, embed, svg'
107107

108+
/** Block elements inside a table cell; `.text()` would otherwise glue their words together. */
109+
const CELL_BLOCK_SELECTOR = 'p, div, li, br, h1, h2, h3, h4, h5, h6, tr'
110+
108111
/** mammoth renders a footnote's return link as `<a href="#footnote-ref-N">↑</a>`. */
109112
const FOOTNOTE_BACKLINK_SELECTOR = 'a[href^="#footnote-ref"]'
110113

@@ -347,7 +350,9 @@ function processTable(
347350
const cells: string[] = []
348351

349352
$row.find('td, th').each((_, cell) => {
350-
const cellText = $(cell).text().replace(/\s+/g, ' ').trim()
353+
const $cell = $(cell)
354+
$cell.find(CELL_BLOCK_SELECTOR).after(' ')
355+
const cellText = $cell.text().replace(/\s+/g, ' ').trim()
351356
cells.push(cellText || '')
352357
})
353358

‎apps/sim/lib/file-parsers/pdf-furniture.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ function texts(pages: PdfLine[][]): string[][] {
3333
return pages.map((lines) => lines.map((entry) => entry.text))
3434
}
3535

36+
describe('page numbers outside the band', () => {
37+
it('drops a folio that is the last line of a page even inside a wide margin', () => {
38+
const page = (n: number) => ({
39+
pageHeight: 842,
40+
lines: [
41+
{ text: 'Body text of the page.', y: 700, height: 10 },
42+
{ text: String(n), y: 189, height: 10 },
43+
],
44+
})
45+
const result = suppressFurniture([page(1), page(2), page(3)])
46+
47+
expect(result.map((lines) => lines.map((line) => line.text))).toEqual([
48+
['Body text of the page.'],
49+
['Body text of the page.'],
50+
['Body text of the page.'],
51+
])
52+
})
53+
})
54+
3655
describe('suppressFurniture', () => {
3756
it('drops a header repeated on enough pages but keeps its first occurrence', () => {
3857
const pages = [1, 2, 3, 4].map((i) => page(i, { header: 'ACME Corp — Internal Use Only' }))

‎apps/sim/lib/file-parsers/pdf-furniture.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ export function suppressFurniture(pages: readonly PdfPageLines[]): PdfLine[][] {
6464

6565
pages.forEach((page, pageIndex) => {
6666
const seen = new Set<string>()
67+
for (const index of edgeLineIndices(page)) {
68+
if (isPageNumber(page.lines[index].text, pages.length)) drops[pageIndex].add(index)
69+
}
6770
for (const group of bandGroups(page)) {
6871
if (isPageNumber(group.text, pages.length)) {
6972
for (const index of group.indices) drops[pageIndex].add(index)
@@ -137,6 +140,24 @@ export function furnitureThreshold(pageCount: number): number | undefined {
137140
return Math.max(MIN_FURNITURE_REPEATS, Math.ceil(FURNITURE_PAGE_FRACTION * pageCount))
138141
}
139142

143+
/**
144+
* The first and last non-blank lines of a page. A folio printed inside a wide
145+
* margin sits outside the band, but it is still the edge of the page's text.
146+
*/
147+
function edgeLineIndices(page: PdfPageLines): number[] {
148+
const indices: number[] = []
149+
let first = -1
150+
let last = -1
151+
page.lines.forEach((line, index) => {
152+
if (line.text.trim().length === 0) return
153+
if (first === -1) first = index
154+
last = index
155+
})
156+
if (first !== -1) indices.push(first)
157+
if (last !== -1 && last !== first) indices.push(last)
158+
return indices
159+
}
160+
140161
/** Groups consecutive band lines that share a baseline into one furniture row. */
141162
function bandGroups(page: PdfPageLines): BandGroup[] {
142163
const { lines, pageHeight } = page

‎apps/sim/lib/file-parsers/pdf-lines.test.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { describe, expect, it } from 'vitest'
55
import {
66
collectCompounds,
7+
collectWords,
78
dominantLineHeight,
89
joinLines,
910
normalizePdfWhitespace,
@@ -113,14 +114,25 @@ describe('joinLines', () => {
113114
})
114115

115116
describe('dehyphenation', () => {
116-
it('removes a line-end hyphen when the next line continues the word', () => {
117+
it('removes a line-end hyphen when the document shows the joined word', () => {
117118
const lines = paragraph(['archived by the Infra-', 'structure team.'], 627.4)
119+
const words = collectWords([{ text: 'The Infrastructure team owns it.', height: BODY }])
118120

119-
expect(joinLines(lines, { headingMarkers: false })).toBe(
121+
expect(joinLines(lines, { words, headingMarkers: false })).toBe(
120122
'archived by the Infrastructure team.'
121123
)
122124
})
123125

126+
it('keeps an unknown line-end hyphen rather than inventing a word', () => {
127+
const lines = paragraph(['we ship high-', 'quality builds'], 627.4)
128+
const words = collectWords(lines)
129+
130+
expect(joinLines(lines, { words, headingMarkers: false })).toBe(
131+
'we ship high-quality builds'
132+
)
133+
expect(joinLines(lines, { headingMarkers: false })).toBe('we ship high-quality builds')
134+
})
135+
124136
it('keeps the hyphen when the compound appears intact elsewhere in the document', () => {
125137
const lines = paragraph(['we compare attention-', 'based models with others'], 700)
126138
const compounds = collectCompounds([{ text: 'Attention-based models win.', height: BODY }])

‎apps/sim/lib/file-parsers/pdf-lines.ts‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export type PdfLineSeparator = '' | ' ' | '\n'
4141
export interface JoinLinesOptions {
4242
/** Lowercase `a-b` compounds seen intact in the document; a line break on their hyphen keeps it. */
4343
compounds?: ReadonlySet<string>
44+
/**
45+
* Lowercase words seen in the document. A line-end hyphen is dropped only when
46+
* the joined word occurs elsewhere, so `high-` / `quality` keeps its hyphen
47+
* while `Infra-` / `structure` rejoins when `Infrastructure` appears intact.
48+
*/
49+
words?: ReadonlySet<string>
4450
/** Dominant body-text height for the document; enables heading markers and heading pitch scaling. */
4551
bodyHeight?: number
4652
/** Prefixes short, oversized lines with `## ` so Markdown-aware chunkers split on them. */
@@ -81,6 +87,7 @@ const SOFT_HYPHEN = '\u00AD'
8187
const TRAILING_HYPHEN = /(\p{L}+)-$/u
8288
const LEADING_LOWERCASE_WORD = /^(\p{Ll}\p{L}*)/u
8389
const INTACT_COMPOUND = /\p{L}+-\p{L}+/gu
90+
const WORD_TOKEN = /\p{L}{3,}/gu
8491

8592
/**
8693
* Reads an item's placement, or undefined when the item is rotated, vertical,
@@ -194,6 +201,15 @@ export function collectCompounds(lines: Iterable<PdfLine>): Set<string> {
194201
return compounds
195202
}
196203

204+
/** Lowercase words of three or more letters seen anywhere in the document. */
205+
export function collectWords(lines: Iterable<PdfLine>): Set<string> {
206+
const words = new Set<string>()
207+
for (const line of lines) {
208+
for (const match of line.text.matchAll(WORD_TOKEN)) words.add(match[0].toLowerCase())
209+
}
210+
return words
211+
}
212+
197213
/** Character-weighted modal line height across the document; 0 when unknown. */
198214
export function dominantLineHeight(lines: Iterable<PdfLine>): number {
199215
const weights = new Map<number, number>()
@@ -224,6 +240,7 @@ export function joinLines(lines: readonly PdfLine[], options: JoinLinesOptions =
224240
const bodyHeight = options.bodyHeight ?? 0
225241
const headingMarkers = options.headingMarkers ?? PDF_HEADING_MARKERS_ENABLED
226242
const compounds = options.compounds
243+
const words = options.words
227244

228245
let out = decorate(lines[0], bodyHeight, headingMarkers)
229246
for (let i = 1; i < lines.length; i++) {
@@ -234,7 +251,7 @@ export function joinLines(lines: readonly PdfLine[], options: JoinLinesOptions =
234251
continue
235252
}
236253
if (separator === '\n') {
237-
const joined = dehyphenate(out, line.text, compounds)
254+
const joined = dehyphenate(out, line.text, compounds, words)
238255
if (joined !== undefined) {
239256
out = joined
240257
continue
@@ -260,20 +277,25 @@ function decorate(line: PdfLine, bodyHeight: number, headingMarkers: boolean): s
260277

261278
/**
262279
* Joins `next` onto `out` across a hyphen that ended the line, or undefined
263-
* when the break is not a hyphenation. A compound seen intact elsewhere in the
264-
* document keeps its hyphen.
280+
* when the break is not a hyphenation. The hyphen is removed only when the
281+
* document itself shows the joined word; a compound seen intact keeps it, and
282+
* an unknown pair keeps it too, because `high-quality` split at a line end is
283+
* far more common in real documents than a word the document never repeats.
265284
*/
266285
function dehyphenate(
267286
out: string,
268287
next: string,
269-
compounds: ReadonlySet<string> | undefined
288+
compounds: ReadonlySet<string> | undefined,
289+
words: ReadonlySet<string> | undefined
270290
): string | undefined {
271291
const head = TRAILING_HYPHEN.exec(out)
272292
const tail = LEADING_LOWERCASE_WORD.exec(next)
273293
if (!head || !tail) return undefined
274294
const compound = `${head[1]}-${tail[1]}`.toLowerCase()
275295
if (compounds?.has(compound)) return out + next
276-
return out.slice(0, -1) + next
296+
const joined = `${head[1]}${tail[1]}`.toLowerCase()
297+
if (words?.has(joined)) return out.slice(0, -1) + next
298+
return out + next
277299
}
278300

279301
function separatorBetween(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ describe('PdfParser structure reconstruction', () => {
103103
'review the attached appendix before the next checkpoint, and the owning',
104104
'team retains sign-off authority for scope changes above five percent.',
105105
'Exceptions require written approval from a director or above. This',
106-
'supersedes the guidance circulated on 14 March and applies immediately.',
106+
'supersedes the guidance the Infrastructure team circulated on 14 March.',
107107
]
108108
const p1Top = 676.6
109109
const p2Top = p1Top - 5 * PITCH - PARAGRAPH_GAP

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { FileParserError } from '@/lib/file-parsers/errors'
55
import { type PdfPageLines, suppressFurniture } from '@/lib/file-parsers/pdf-furniture'
66
import {
77
collectCompounds,
8+
collectWords,
89
dominantLineHeight,
910
joinLines,
1011
normalizePdfWhitespace,
@@ -278,6 +279,7 @@ function assemblePages(pages: readonly PdfPageLines[], complete: boolean): strin
278279
const allLines = filteredPages.flat()
279280
const options = {
280281
compounds: collectCompounds(allLines),
282+
words: collectWords(allLines),
281283
bodyHeight: dominantLineHeight(allLines),
282284
}
283285
const pageTexts: string[] = []

‎apps/sim/lib/file-parsers/sheet-display-text.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ describe('isoDateText', () => {
115115
it('renders an invalid date as empty text', () => {
116116
expect(isoDateText(new Date(Number.NaN))).toBe('')
117117
})
118+
119+
it('renders a duration or time-of-day cell without the 1899 epoch date', () => {
120+
expect(isoDateText(new Date(Date.UTC(1899, 11, 30, 0, 30, 0)))).toBe('00:30:00')
121+
expect(isoDateText(new Date(Date.UTC(1899, 11, 31, 13, 5, 9)))).toBe('13:05:09')
122+
})
118123
})
119124

120125
describe('normalizeSheetDisplayText', () => {

‎apps/sim/lib/file-parsers/sheet-display-text.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ interface CellLookup {
2525
export function isoDateText(date: Date): string {
2626
if (Number.isNaN(date.getTime())) return ''
2727
const iso = date.toISOString()
28+
/** A serial below 1 is a duration or time of day; Excel shows it without the 1899 epoch date. */
29+
if (date.getUTCFullYear() < 1900) return iso.slice(11, 19)
2830
return iso.endsWith('T00:00:00.000Z') ? iso.slice(0, 10) : iso.slice(0, 19)
2931
}
3032

0 commit comments

Comments
 (0)