Skip to content

Commit 79a25d2

Browse files
authored
fix(file-parsers): read side-by-side calendar grids one block at a time (#8232)
* fix(file-parsers): read side-by-side calendar grids one block at a time * fix(file-parsers): tighten calendar block detection from review * fix(file-parsers): accept a weekday header drawn as one text item * fix(file-parsers): require a shared header to name every grid column * fix(file-parsers): match multi-word header cells and group rows by local height
1 parent 124f4ba commit 79a25d2

6 files changed

Lines changed: 943 additions & 27 deletions

File tree

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
type BufferedItem,
7+
findInterleavedBands,
8+
MAX_PDF_LAYOUT_ITEMS,
9+
PdfPageCollector,
10+
} from '@/lib/file-parsers/pdf-layout'
11+
import { joinLines, type PdfItemGeometry } from '@/lib/file-parsers/pdf-lines'
12+
13+
const HEIGHT = 8
14+
const CHAR_WIDTH = 4.5
15+
16+
function placed(str: string, x: number, y: number, height = HEIGHT): BufferedItem {
17+
const geometry: PdfItemGeometry = { x, y, width: str.length * CHAR_WIDTH, height }
18+
return { str, geometry, hasEOL: false }
19+
}
20+
21+
function readPage(items: readonly BufferedItem[]): string {
22+
const collector = new PdfPageCollector()
23+
for (const item of items) collector.add(item.str, item.geometry, item.hasEOL)
24+
return joinLines(collector.finish())
25+
}
26+
27+
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] as const
28+
const CELL = 16
29+
const ROW_PITCH = 12
30+
31+
interface Month {
32+
name: string
33+
firstWeekday: number
34+
days: number
35+
x: number
36+
}
37+
38+
/** A month's rows as text, the way a reader expects them. */
39+
function monthRows(month: Month): string[] {
40+
const rows = [month.name, WEEKDAYS.join(' ')]
41+
let week: string[] = []
42+
for (let day = 1; day <= month.days; day++) {
43+
week.push(String(day))
44+
if ((month.firstWeekday + day) % 7 === 0 || day === month.days) {
45+
rows.push(week.join(' '))
46+
week = []
47+
}
48+
}
49+
return rows
50+
}
51+
52+
/** Positioned items for one visual row of a month: its title, weekday header, or a week. */
53+
function monthRowItems(month: Month, row: number, y: number): BufferedItem[] {
54+
if (row === 0) return [placed(month.name, month.x, y)]
55+
if (row === 1) return WEEKDAYS.map((day, i) => placed(day, month.x + i * CELL, y))
56+
const items: BufferedItem[] = []
57+
for (let day = 1; day <= month.days; day++) {
58+
const slot = month.firstWeekday + day - 1
59+
if (Math.floor(slot / 7) + 2 === row) {
60+
items.push(placed(String(day), month.x + (slot % 7) * CELL, y))
61+
}
62+
}
63+
return items
64+
}
65+
66+
/**
67+
* Three months side by side, drawn the way calendar producers draw them: one
68+
* visual row across every month before the next row.
69+
*/
70+
function quarterCalendar(): { items: BufferedItem[]; months: Month[] } {
71+
const months: Month[] = [
72+
{ name: 'January 2026', firstWeekday: 4, days: 31, x: 40 },
73+
{ name: 'February 2026', firstWeekday: 0, days: 28, x: 188 },
74+
{ name: 'March 2026', firstWeekday: 0, days: 31, x: 336 },
75+
]
76+
const items: BufferedItem[] = [placed('2026 Calendar', 200, 740, 14)]
77+
for (let row = 0; row < 8; row++) {
78+
for (const month of months) items.push(...monthRowItems(month, row, 700 - row * ROW_PITCH))
79+
}
80+
return { items, months }
81+
}
82+
83+
describe('PdfPageCollector', () => {
84+
it('reads side-by-side calendar months one month at a time', () => {
85+
const { items, months } = quarterCalendar()
86+
87+
const text = readPage(items)
88+
89+
const expected = ['2026 Calendar', ...months.map((month) => monthRows(month).join('\n'))]
90+
expect(text).toBe(expected.join('\n\n'))
91+
})
92+
93+
it('leaves calendar months the stream already draws one at a time', () => {
94+
const { items } = quarterCalendar()
95+
const monthByMonth = [...items].sort((a, b) => (a.geometry?.x ?? 0) - (b.geometry?.x ?? 0))
96+
97+
expect(findInterleavedBands(monthByMonth)).toEqual([])
98+
})
99+
100+
it('keeps a two-column table of wrapped prose cells row by row', () => {
101+
const left = [
102+
'Every request is reviewed on a fixed weekly',
103+
'schedule set by the team that owns the queue.',
104+
'Each review closes the request or sends it back.',
105+
]
106+
const right = [
107+
'Office hours open two days after each release',
108+
'and close two weeks before the next one ships.',
109+
'Freeze periods apply to every team member.',
110+
]
111+
const items = left.flatMap((line, i) => [
112+
placed(line, 40, 700 - i * 12),
113+
placed(right[i], 300, 700 - i * 12),
114+
])
115+
116+
expect(findInterleavedBands(items)).toEqual([])
117+
})
118+
119+
it('keeps a table with evenly spaced columns row by row', () => {
120+
const rows = [
121+
['Name', 'Opened', 'Closed'],
122+
['Ana Park', '1200', '300'],
123+
['Ben Ortiz', '800', '200'],
124+
]
125+
const items = rows.flatMap((cells, i) =>
126+
cells.map((cell, column) => placed(cell, 40 + column * 120, 700 - i * 12))
127+
)
128+
129+
expect(findInterleavedBands(items)).toEqual([])
130+
expect(readPage(items)).toBe('Name Opened Closed\nAna Park 1200 300\nBen Ortiz 800 200')
131+
})
132+
133+
it('keeps a label and value list row by row across a wide gutter', () => {
134+
const rows = [
135+
['Employee', 'Ana Park'],
136+
['Start date', '2024-03-01'],
137+
['Location', 'Springfield'],
138+
]
139+
const items = rows.flatMap(([label, value], i) => [
140+
placed(label, 40, 700 - i * 12),
141+
placed(value, 400, 700 - i * 12),
142+
])
143+
144+
expect(findInterleavedBands(items)).toEqual([])
145+
})
146+
147+
it('keeps a table whose wrapped cells leave its label column sparse', () => {
148+
const items = [
149+
placed('Role', 40, 700),
150+
placed('Responsibility', 200, 700),
151+
placed('Author', 40, 688),
152+
placed('Writes the change and opens a request for review with a', 200, 688),
153+
placed('short summary of the risk involved.', 200, 676),
154+
placed('Owner', 40, 664),
155+
placed('Owns the document and reviews it once a year, then', 200, 664),
156+
placed('approves every exception in writing.', 200, 652),
157+
]
158+
159+
expect(findInterleavedBands(items)).toEqual([])
160+
})
161+
162+
it('leaves side-by-side blocks the stream already draws one at a time', () => {
163+
const address = ['Jane Doe', 'Example Co', '1 Main Street', 'Phone: 555 0100']
164+
const items = [
165+
placed('TO:', 40, 700),
166+
...address.map((line, i) => placed(line, 40, 688 - i * 12)),
167+
placed('SHIP TO:', 300, 700),
168+
...address.map((line, i) => placed(line, 300, 688 - i * 12)),
169+
]
170+
171+
expect(findInterleavedBands(items)).toEqual([])
172+
expect(readPage(items)).toBe(`TO:\n${address.join('\n')}\n\nSHIP TO:\n${address.join('\n')}`)
173+
})
174+
175+
it('reads a sparse legend column as its own block', () => {
176+
const { items, months } = quarterCalendar()
177+
const legend = ['Holiday', 'Office Closure', 'Deadline']
178+
items.push(...legend.map((label, i) => placed(label, 500, 688 - i * ROW_PITCH)))
179+
180+
const text = readPage(items)
181+
182+
const expected = [
183+
'2026 Calendar',
184+
...months.map((month) => monthRows(month).join('\n')),
185+
legend.join('\n'),
186+
]
187+
expect(text).toBe(expected.join('\n\n'))
188+
})
189+
190+
it('keeps a table whose column groups differ in shape row by row', () => {
191+
const rows = [
192+
['QUANTITY', 'DESCRIPTION', 'UNIT PRICE', 'TOTAL'],
193+
['10', 'Standard widget, blue', '12.45', '124.50'],
194+
['25', 'Standard widget, red', '8.10', '202.50'],
195+
]
196+
const columns = [40, 100, 360, 440]
197+
const items = rows.flatMap((cells, i) =>
198+
cells.map((cell, column) => placed(cell, columns[column], 700 - i * 12))
199+
)
200+
201+
expect(findInterleavedBands(items)).toEqual([])
202+
})
203+
204+
it('matches a repeated header whose cells hold several words', () => {
205+
const boxes = [
206+
{
207+
x: 40,
208+
rows: [
209+
['Cash', 'Payables'],
210+
['Receivables', 'Accrued wages'],
211+
],
212+
},
213+
{
214+
x: 400,
215+
rows: [
216+
['Deposits', 'Loans'],
217+
['Securities', 'Borrowings'],
218+
],
219+
},
220+
]
221+
const header = ['Current Assets', 'Current Liabilities']
222+
const items: BufferedItem[] = []
223+
for (let i = 0; i < 3; i++) {
224+
for (const box of boxes) {
225+
const cells = i === 0 ? header : box.rows[i - 1]
226+
cells.forEach((cell, c) => items.push(placed(cell, box.x + c * 110, 700 - i * 12)))
227+
}
228+
}
229+
230+
expect(findInterleavedBands(items)[0]?.blocks).toHaveLength(2)
231+
})
232+
233+
it('separates compact small-type grid rows on a page of larger body text', () => {
234+
const { items, months } = quarterCalendar()
235+
const compact = items.map((item) =>
236+
item.geometry ? placed(item.str, item.geometry.x, 400 + (item.geometry.y - 700) / 3, 3) : item
237+
)
238+
const body = Array.from({ length: 200 }, (_, i) =>
239+
placed(`body text line ${i}`, 40, 300 - i * 14, 12)
240+
)
241+
242+
const bands = findInterleavedBands([...compact, ...body])
243+
244+
expect(bands).toHaveLength(1)
245+
expect(bands[0].blocks.map((block) => block.length)).toEqual(
246+
months.map((month) => monthRows(month).length)
247+
)
248+
})
249+
250+
it('keeps a grid of values without repeated headers row by row', () => {
251+
const words = [
252+
['w0 = 603deb10', 'w1 = 15ca71be', 'w2 = 2b73aef0', 'w3 = 857d7781'],
253+
['w4 = 1f352c07', 'w5 = 3b6108d7', 'w6 = 2d9810a3', 'w7 = 0914dff4'],
254+
]
255+
const items = words.flatMap((row, i) =>
256+
row.flatMap((word, column) => {
257+
const [name, equals, value] = word.split(' ')
258+
const x = 40 + column * 130
259+
return [
260+
placed(name, x, 700 - i * 12),
261+
placed(equals, x + 16, 700 - i * 12),
262+
placed(value, x + 28, 700 - i * 12),
263+
]
264+
})
265+
)
266+
267+
expect(findInterleavedBands(items)).toEqual([])
268+
})
269+
270+
it('reads side-by-side account boxes that repeat a header one box at a time', () => {
271+
const boxes = [
272+
{ x: 40, rows: ['Business', 'Assets Liabilities', 'Buildings Loans'] },
273+
{ x: 260, rows: ['Bank', 'Assets Liabilities', 'Securities Deposits'] },
274+
{ x: 480, rows: ['Household', 'Assets Liabilities', 'Deposits Loans'] },
275+
]
276+
const items: BufferedItem[] = []
277+
for (let row = 0; row < 3; row++) {
278+
for (const box of boxes) {
279+
const words = box.rows[row].split(' ')
280+
words.forEach((word, i) => items.push(placed(word, box.x + i * 50, 700 - row * 12)))
281+
}
282+
}
283+
284+
expect(readPage(items)).toBe(boxes.map((box) => box.rows.join('\n')).join('\n\n'))
285+
})
286+
287+
it('still splits calendar months when dense text elsewhere lowers the page pitch', () => {
288+
const { items } = quarterCalendar()
289+
for (let i = 0; i < 40; i++) {
290+
items.push(
291+
placed(`Footnote line ${i} with enough words to read as running text`, 40, 500 - i * 5, 3)
292+
)
293+
}
294+
295+
const bands = findInterleavedBands(items)
296+
297+
expect(bands).toHaveLength(1)
298+
expect(bands[0].blocks).toHaveLength(3)
299+
})
300+
301+
it('matches a weekday header drawn as one text item', () => {
302+
const { items } = quarterCalendar()
303+
const merged: BufferedItem[] = []
304+
for (const item of items) {
305+
if (!['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].includes(item.str)) merged.push(item)
306+
else if (item.str === 'Su' && item.geometry) {
307+
merged.push(placed(WEEKDAYS.join(' '), item.geometry.x, item.geometry.y))
308+
}
309+
}
310+
311+
expect(findInterleavedBands(merged)[0]?.blocks).toHaveLength(3)
312+
})
313+
314+
it('keeps column groups under a repeated one-item label row by row', () => {
315+
const items: BufferedItem[] = []
316+
const rows = [
317+
['Total Amount Due', 'Total Amount Due'],
318+
['100 12', '300 34'],
319+
['200 56', '400 78'],
320+
]
321+
rows.forEach((groups, i) => {
322+
groups.forEach((group, g) => {
323+
const x = 40 + g * 260
324+
if (i === 0) items.push(placed(group, x, 700))
325+
else
326+
group.split(' ').forEach((cell, c) => items.push(placed(cell, x + c * 60, 700 - i * 12)))
327+
})
328+
})
329+
330+
expect(findInterleavedBands(items)).toEqual([])
331+
})
332+
333+
it('keeps column groups that share only a single-cell label row by row', () => {
334+
const items: BufferedItem[] = []
335+
const rows = [
336+
['Amount', 'Amount'],
337+
['100 12', '300 34'],
338+
['200 56', '400 78'],
339+
['500 90', '600 11'],
340+
]
341+
rows.forEach((groups, i) => {
342+
groups.forEach((group, g) => {
343+
group
344+
.split(' ')
345+
.forEach((cell, c) => items.push(placed(cell, 40 + g * 260 + c * 60, 700 - i * 12)))
346+
})
347+
})
348+
349+
expect(findInterleavedBands(items)).toEqual([])
350+
})
351+
352+
it('keeps stream order when any item has no usable geometry', () => {
353+
const { items } = quarterCalendar()
354+
items.push({ str: 'rotated caption', geometry: undefined, hasEOL: true })
355+
356+
expect(findInterleavedBands(items)).toEqual([])
357+
})
358+
359+
it('streams in pdf.js order once a page exceeds the layout item cap', () => {
360+
const collector = new PdfPageCollector()
361+
for (let i = 0; i <= MAX_PDF_LAYOUT_ITEMS; i++) {
362+
collector.add(
363+
'x',
364+
{ x: (i % 2) * 300, y: 700 - Math.floor(i / 2), width: 5, height: 1 },
365+
false
366+
)
367+
}
368+
369+
const lines = collector.finish()
370+
371+
expect(lines.some((line) => line.blockStart)).toBe(false)
372+
expect(lines.length).toBeGreaterThan(0)
373+
})
374+
})

0 commit comments

Comments
 (0)