@oh-my-pi/pi-coding-agent 16.0.7 → 16.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli.js +4752 -12462
  3. package/dist/types/cli/update-cli.d.ts +11 -0
  4. package/dist/types/debug/remote-debugger.d.ts +45 -0
  5. package/dist/types/internal-urls/docs-index.d.ts +19 -0
  6. package/dist/types/markit/converters/docx.d.ts +6 -0
  7. package/dist/types/markit/converters/epub.d.ts +15 -0
  8. package/dist/types/markit/converters/pdf/columns.d.ts +35 -0
  9. package/dist/types/markit/converters/pdf/extract.d.ts +10 -0
  10. package/dist/types/markit/converters/pdf/grid.d.ts +25 -0
  11. package/dist/types/markit/converters/pdf/headers.d.ts +24 -0
  12. package/dist/types/markit/converters/pdf/index.d.ts +6 -0
  13. package/dist/types/markit/converters/pdf/render.d.ts +24 -0
  14. package/dist/types/markit/converters/pdf/types.d.ts +75 -0
  15. package/dist/types/markit/converters/pptx.d.ts +57 -0
  16. package/dist/types/markit/converters/xlsx.d.ts +25 -0
  17. package/dist/types/markit/index.d.ts +2 -0
  18. package/dist/types/markit/registry.d.ts +16 -0
  19. package/dist/types/markit/types.d.ts +30 -0
  20. package/dist/types/session/agent-session.d.ts +7 -8
  21. package/dist/types/session/auth-storage.d.ts +3 -2
  22. package/dist/types/session/yield-queue.d.ts +3 -1
  23. package/dist/types/tools/browser/attach.d.ts +1 -1
  24. package/dist/types/utils/markit.d.ts +0 -8
  25. package/dist/types/utils/mupdf-wasm-embed.d.ts +1 -0
  26. package/dist/types/utils/turndown.d.ts +15 -0
  27. package/dist/types/utils/zip.d.ts +119 -0
  28. package/package.json +20 -18
  29. package/scripts/build-binary.ts +7 -3
  30. package/scripts/bundle-dist.ts +28 -12
  31. package/scripts/embed-mupdf-wasm.ts +67 -0
  32. package/scripts/generate-docs-index.ts +48 -32
  33. package/scripts/omp +1 -1
  34. package/src/advisor/__tests__/advisor.test.ts +83 -0
  35. package/src/advisor/runtime.ts +16 -1
  36. package/src/cli/auth-broker-cli.ts +1 -3
  37. package/src/cli/auth-gateway-cli.ts +2 -5
  38. package/src/cli/update-cli.ts +63 -3
  39. package/src/config/model-discovery.ts +20 -8
  40. package/src/config/models-config-schema.ts +8 -1
  41. package/src/debug/index.ts +44 -0
  42. package/src/debug/remote-debugger.ts +151 -0
  43. package/src/debug/report-bundle.ts +2 -1
  44. package/src/internal-urls/docs-index.generated.txt +2 -0
  45. package/src/internal-urls/docs-index.ts +102 -0
  46. package/src/internal-urls/omp-protocol.ts +10 -9
  47. package/src/markit/NOTICE +32 -0
  48. package/src/markit/converters/docx.ts +56 -0
  49. package/src/markit/converters/epub.ts +136 -0
  50. package/src/markit/converters/mammoth.d.ts +24 -0
  51. package/src/markit/converters/pdf/columns.ts +103 -0
  52. package/src/markit/converters/pdf/extract.ts +574 -0
  53. package/src/markit/converters/pdf/grid.ts +780 -0
  54. package/src/markit/converters/pdf/headers.ts +106 -0
  55. package/src/markit/converters/pdf/index.ts +146 -0
  56. package/src/markit/converters/pdf/render.ts +501 -0
  57. package/src/markit/converters/pdf/types.ts +84 -0
  58. package/src/markit/converters/pptx.ts +325 -0
  59. package/src/markit/converters/xlsx.ts +173 -0
  60. package/src/markit/index.ts +2 -0
  61. package/src/markit/registry.ts +59 -0
  62. package/src/markit/types.ts +35 -0
  63. package/src/modes/components/snapcompact-shape-preview-doc.md +14 -7
  64. package/src/modes/components/snapcompact-shape-preview.ts +2 -2
  65. package/src/modes/controllers/input-controller.ts +29 -8
  66. package/src/modes/interactive-mode.ts +26 -9
  67. package/src/prompts/advisor/system.md +1 -0
  68. package/src/sdk.ts +5 -9
  69. package/src/session/agent-session.ts +62 -40
  70. package/src/session/auth-storage.ts +2 -11
  71. package/src/session/yield-queue.ts +7 -1
  72. package/src/tools/browser/attach.ts +2 -2
  73. package/src/tools/fetch.ts +25 -60
  74. package/src/tools/read.ts +1 -1
  75. package/src/tools/search.ts +1 -6
  76. package/src/tools/write.ts +25 -65
  77. package/src/utils/markit.ts +25 -9
  78. package/src/utils/mupdf-wasm-embed.ts +12 -0
  79. package/src/utils/tools-manager.ts +2 -11
  80. package/src/utils/turndown.ts +83 -0
  81. package/src/{tools/archive-reader.ts → utils/zip.ts} +453 -83
  82. package/src/web/scrapers/types.ts +3 -46
  83. package/dist/types/internal-urls/docs-index.generated.d.ts +0 -2
  84. package/dist/types/tools/archive-reader.d.ts +0 -49
  85. package/src/internal-urls/docs-index.generated.ts +0 -120
@@ -0,0 +1,780 @@
1
+ // Adapted from markit-ai (MIT). See ../../NOTICE.
2
+
3
+ /**
4
+ * Table grid detection from vector segments and text boxes.
5
+ *
6
+ * Ported from @oharato/pdf2md-ts with TypeScript types and without
7
+ * CJK-specific borderless table heuristics. The core algorithm:
8
+ *
9
+ * 1. Classify segments as horizontal or vertical lines
10
+ * 2. Group horizontal Y-lines into table groups (split by vertical gaps)
11
+ * 3. For each group:
12
+ * a. Full grid (H+V lines): build cells from grid intersections,
13
+ * place text via raycasting
14
+ * b. H-line only (no V lines): infer columns from text X positions
15
+ * 4. Prune empty rows/cols
16
+ *
17
+ * Coordinate system: PDF native (bottom-left origin, Y increases upward).
18
+ */
19
+ import type { Segment, TableCell, TableGrid, TextBox } from "./types";
20
+
21
+ export interface GridResult {
22
+ grids: TableGrid[];
23
+ consumedIds: string[];
24
+ }
25
+
26
+ type RayDirection = "up" | "down" | "left" | "right";
27
+
28
+ interface Ray {
29
+ direction: RayDirection;
30
+ segmentId: string | null;
31
+ distance: number;
32
+ }
33
+
34
+ interface Interval {
35
+ min: number;
36
+ max: number;
37
+ }
38
+
39
+ function castRaysForTextBox(textBox: TextBox, segments: Segment[]): Ray[] {
40
+ const cx = (textBox.bounds.left + textBox.bounds.right) / 2;
41
+ const cy = (textBox.bounds.top + textBox.bounds.bottom) / 2;
42
+ let up: Ray = { direction: "up", segmentId: null, distance: Infinity };
43
+ let down: Ray = { direction: "down", segmentId: null, distance: Infinity };
44
+ let left: Ray = { direction: "left", segmentId: null, distance: Infinity };
45
+ let right: Ray = {
46
+ direction: "right",
47
+ segmentId: null,
48
+ distance: Infinity,
49
+ };
50
+ for (const seg of segments) {
51
+ const isH = Math.abs(seg.y1 - seg.y2) < 0.5;
52
+ const isV = Math.abs(seg.x1 - seg.x2) < 0.5;
53
+ if (isH) {
54
+ const minX = Math.min(seg.x1, seg.x2);
55
+ const maxX = Math.max(seg.x1, seg.x2);
56
+ if (cx >= minX && cx <= maxX) {
57
+ const d = seg.y1 - cy;
58
+ if (d >= 0 && d < up.distance) up = { direction: "up", segmentId: seg.id, distance: d };
59
+ const dd = cy - seg.y1;
60
+ if (dd >= 0 && dd < down.distance) down = { direction: "down", segmentId: seg.id, distance: dd };
61
+ }
62
+ }
63
+ if (isV) {
64
+ const minY = Math.min(seg.y1, seg.y2);
65
+ const maxY = Math.max(seg.y1, seg.y2);
66
+ if (cy >= minY && cy <= maxY) {
67
+ const d = cx - seg.x1;
68
+ if (d >= 0 && d < left.distance) left = { direction: "left", segmentId: seg.id, distance: d };
69
+ const rd = seg.x1 - cx;
70
+ if (rd >= 0 && rd < right.distance) right = { direction: "right", segmentId: seg.id, distance: rd };
71
+ }
72
+ }
73
+ }
74
+ return [up, down, left, right];
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Utility
79
+ // ---------------------------------------------------------------------------
80
+ const AXIS_EPSILON = 0.8;
81
+ const PAGE_MARGIN = 20;
82
+
83
+ function uniqueSorted(values: number[]): number[] {
84
+ const sorted = [...values].sort((a, b) => a - b);
85
+ const result: number[] = [];
86
+ for (const v of sorted) {
87
+ if (result.length === 0 || Math.abs(result[result.length - 1] - v) > 1) result.push(v);
88
+ }
89
+ return result;
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Y-line group splitting
94
+ // ---------------------------------------------------------------------------
95
+ function chainCoversRange(intervals: Interval[], lowerY: number, upperY: number, eps: number): boolean {
96
+ const sorted = [...intervals].sort((a, b) => a.min - b.min);
97
+ let covered = lowerY;
98
+ for (const iv of sorted) {
99
+ if (iv.min > covered + eps) break;
100
+ if (iv.max > covered) covered = iv.max;
101
+ if (covered >= upperY - eps) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ function countBridgingVLineCols(upperY: number, lowerY: number, verticals: Segment[]): number {
107
+ const eps = 1.5;
108
+ const byX = new Map<number, Interval[]>();
109
+ for (const seg of verticals) {
110
+ const rx = Math.round(seg.x1);
111
+ if (!byX.has(rx)) byX.set(rx, []);
112
+ byX.get(rx)?.push({ min: Math.min(seg.y1, seg.y2), max: Math.max(seg.y1, seg.y2) });
113
+ }
114
+ let count = 0;
115
+ for (const intervals of byX.values()) {
116
+ if (chainCoversRange(intervals, lowerY, upperY, eps)) count++;
117
+ }
118
+ return count;
119
+ }
120
+
121
+ function bridgingXSet(upperY: number, lowerY: number, verticals: Segment[]): Set<number> {
122
+ const eps = 1.5;
123
+ const xs = new Set<number>();
124
+ const byX = new Map<number, Interval[]>();
125
+ for (const seg of verticals) {
126
+ const rx = Math.round(seg.x1);
127
+ if (!byX.has(rx)) byX.set(rx, []);
128
+ byX.get(rx)?.push({ min: Math.min(seg.y1, seg.y2), max: Math.max(seg.y1, seg.y2) });
129
+ }
130
+ for (const [rx, intervals] of byX) {
131
+ if (chainCoversRange(intervals, lowerY, upperY, eps)) xs.add(rx);
132
+ }
133
+ return xs;
134
+ }
135
+
136
+ const MIN_RICH_BRIDGING_COLS = 3;
137
+
138
+ function splitYLinesIntoGroups(yLines: number[], verticals: Segment[]): number[][] {
139
+ if (yLines.length === 0) return [];
140
+ const eps = 1.5;
141
+ const allX = verticals.map(s => Math.round(s.x1));
142
+ const globalXMin = allX.length > 0 ? Math.min(...allX) : 0;
143
+ const globalXMax = allX.length > 0 ? Math.max(...allX) : 0;
144
+ const groups: number[][] = [];
145
+ let currentGroup = [yLines[0]];
146
+ let prevBridgingCols = -1;
147
+ for (let i = 1; i < yLines.length; i++) {
148
+ const upperY = yLines[i - 1];
149
+ const lowerY = yLines[i];
150
+ const cols = countBridgingVLineCols(upperY, lowerY, verticals);
151
+ if (cols === 0) {
152
+ groups.push(currentGroup);
153
+ currentGroup = [yLines[i]];
154
+ prevBridgingCols = -1;
155
+ continue;
156
+ }
157
+ if (prevBridgingCols >= MIN_RICH_BRIDGING_COLS && cols < MIN_RICH_BRIDGING_COLS) {
158
+ const bxs = bridgingXSet(upperY, lowerY, verticals);
159
+ const isOuterFrameOnly = [...bxs].every(
160
+ x => Math.abs(x - globalXMin) <= eps || Math.abs(x - globalXMax) <= eps,
161
+ );
162
+ if (!isOuterFrameOnly) {
163
+ groups.push(currentGroup);
164
+ currentGroup = [yLines[i - 1], yLines[i]];
165
+ prevBridgingCols = cols;
166
+ continue;
167
+ }
168
+ }
169
+ currentGroup.push(yLines[i]);
170
+ prevBridgingCols = cols;
171
+ }
172
+ groups.push(currentGroup);
173
+ return groups;
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Sub-row Y-cluster expansion
178
+ // ---------------------------------------------------------------------------
179
+ const Y_CLUSTER_GAP = 10;
180
+ const MIN_COLS_IN_TOP_CLUSTER = 2;
181
+
182
+ function assignToYCluster(y: number, clusters: number[]): number {
183
+ let closest = 0;
184
+ let closestDist = Math.abs(y - clusters[0]);
185
+ for (let k = 1; k < clusters.length; k++) {
186
+ const d = Math.abs(y - clusters[k]);
187
+ if (d < closestDist) {
188
+ closestDist = d;
189
+ closest = k;
190
+ }
191
+ }
192
+ return closest;
193
+ }
194
+
195
+ function expandSubRowsByYClusters(
196
+ originalRows: number,
197
+ cols: number,
198
+ cells: TableCell[],
199
+ cellBoxes: Map<TableCell, TextBox[]>,
200
+ ): number {
201
+ let addedRows = 0;
202
+ for (let origRow = 0; origRow < originalRows; origRow++) {
203
+ const currentRow = origRow + addedRows;
204
+ const rowCellInfos: Array<{ cell: TableCell; col: number; boxes: TextBox[] }> = [];
205
+ for (let col = 0; col < cols; col++) {
206
+ const cell = cells.find(c => c.row === currentRow && c.col === col);
207
+ if (!cell) continue;
208
+ const boxes = cellBoxes.get(cell);
209
+ if (boxes && boxes.length > 0) rowCellInfos.push({ cell, col, boxes });
210
+ }
211
+ if (rowCellInfos.length === 0) continue;
212
+ const allMidYs = rowCellInfos.flatMap(({ boxes }) => boxes.map(b => (b.bounds.top + b.bounds.bottom) / 2));
213
+ const sortedY = [...new Set(allMidYs.map(y => Math.round(y * 10) / 10))].sort((a, b) => b - a);
214
+ const clusters = [sortedY[0]];
215
+ for (let i = 1; i < sortedY.length; i++) {
216
+ if (clusters[clusters.length - 1] - sortedY[i] > Y_CLUSTER_GAP) {
217
+ clusters.push(sortedY[i]);
218
+ }
219
+ }
220
+ if (clusters.length < 2) continue;
221
+ const colsInTopCluster = new Set<number>();
222
+ const totalNonEmptyCols = new Set<number>();
223
+ for (const { col, boxes } of rowCellInfos) {
224
+ totalNonEmptyCols.add(col);
225
+ if (boxes.some(b => assignToYCluster((b.bounds.top + b.bounds.bottom) / 2, clusters) === 0)) {
226
+ colsInTopCluster.add(col);
227
+ }
228
+ }
229
+ if (colsInTopCluster.size < MIN_COLS_IN_TOP_CLUSTER) continue;
230
+ if (colsInTopCluster.size >= totalNonEmptyCols.size) continue;
231
+ const sparseColsHaveMultipleBoxes = rowCellInfos.some(
232
+ ({ col, boxes }) => !colsInTopCluster.has(col) && boxes.length > 1,
233
+ );
234
+ if (!sparseColsHaveMultipleBoxes) continue;
235
+ const numSubRows = clusters.length;
236
+ const numNewRows = numSubRows - 1;
237
+ for (const cell of cells) {
238
+ if (cell.row > currentRow) cell.row += numNewRows;
239
+ }
240
+ for (let subRow = 1; subRow < numSubRows; subRow++) {
241
+ for (let col = 0; col < cols; col++) {
242
+ cells.push({
243
+ row: currentRow + subRow,
244
+ col,
245
+ text: "",
246
+ rowSpan: 1,
247
+ colSpan: 1,
248
+ });
249
+ }
250
+ }
251
+ for (const { cell: origCell, col, boxes } of rowCellInfos) {
252
+ const subRowBoxGroups: TextBox[][] = Array.from({ length: numSubRows }, () => []);
253
+ for (const box of boxes) {
254
+ const cy = (box.bounds.top + box.bounds.bottom) / 2;
255
+ subRowBoxGroups[assignToYCluster(cy, clusters)].push(box);
256
+ }
257
+ cellBoxes.set(origCell, subRowBoxGroups[0]);
258
+ if (subRowBoxGroups[0].length === 0) cellBoxes.delete(origCell);
259
+ for (let subRow = 1; subRow < numSubRows; subRow++) {
260
+ if (subRowBoxGroups[subRow].length > 0) {
261
+ const newCell = cells.find(c => c.row === currentRow + subRow && c.col === col);
262
+ if (newCell) cellBoxes.set(newCell, subRowBoxGroups[subRow]);
263
+ }
264
+ }
265
+ }
266
+ addedRows += numNewRows;
267
+ }
268
+ return originalRows + addedRows;
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Cross-column text box splitting
273
+ // ---------------------------------------------------------------------------
274
+ /**
275
+ * Find which column a horizontal position falls into.
276
+ * Returns -1 if outside the grid.
277
+ */
278
+ function findCol(x: number, xLines: number[]): number {
279
+ for (let i = 0; i < xLines.length - 1; i++) {
280
+ if (x >= xLines[i] && x <= xLines[i + 1]) return i;
281
+ }
282
+ return -1;
283
+ }
284
+
285
+ /**
286
+ * When a text box spans across one or more vertical column boundaries,
287
+ * split it into multiple virtual text boxes — one per column — with the
288
+ * text divided proportionally by width.
289
+ *
290
+ * We split at word boundaries closest to the proportional split point
291
+ * so we don't chop words in half.
292
+ */
293
+ function splitCrossColumnBoxes(textBoxes: TextBox[], xLines: number[]): TextBox[] {
294
+ const result: TextBox[] = [];
295
+ const MARGIN = 5; // allow small overlap before considering it cross-column
296
+ for (const tb of textBoxes) {
297
+ const leftCol = findCol(tb.bounds.left + MARGIN, xLines);
298
+ const rightCol = findCol(tb.bounds.right - MARGIN, xLines);
299
+ // Not spanning columns, or outside grid — keep as-is
300
+ if (leftCol < 0 || rightCol < 0 || leftCol === rightCol) {
301
+ result.push(tb);
302
+ continue;
303
+ }
304
+ // Text box spans from leftCol to rightCol — split it
305
+ const totalWidth = tb.bounds.right - tb.bounds.left;
306
+ if (totalWidth <= 0) {
307
+ result.push(tb);
308
+ continue;
309
+ }
310
+ const words = tb.text.split(/\s+/);
311
+ if (words.length <= 1) {
312
+ // Single word spanning columns — just assign to whichever col has more overlap
313
+ result.push(tb);
314
+ continue;
315
+ }
316
+ // For each column boundary crossing, find the best word-boundary split
317
+ let remainingWords = [...words];
318
+ let currentLeft = tb.bounds.left;
319
+ for (let col = leftCol; col <= rightCol && remainingWords.length > 0; col++) {
320
+ const colRight = col < xLines.length - 1 ? xLines[col + 1] : tb.bounds.right;
321
+ const segmentRight = Math.min(colRight, tb.bounds.right);
322
+ if (col === rightCol) {
323
+ // Last column — take all remaining words
324
+ result.push({
325
+ ...tb,
326
+ id: `${tb.id}-split${col}`,
327
+ text: remainingWords.join(" "),
328
+ bounds: {
329
+ ...tb.bounds,
330
+ left: currentLeft,
331
+ right: tb.bounds.right,
332
+ },
333
+ });
334
+ remainingWords = [];
335
+ } else {
336
+ // Find how many words fit in this column segment proportionally
337
+ const segmentWidth = segmentRight - currentLeft;
338
+ const fractionOfTotal = segmentWidth / totalWidth;
339
+ const approxChars = Math.round(fractionOfTotal * tb.text.length);
340
+ // Walk words to find the split closest to the proportional point
341
+ let charCount = 0;
342
+ let splitIdx = 0;
343
+ for (let w = 0; w < remainingWords.length; w++) {
344
+ const nextCount = charCount + remainingWords[w].length + (w > 0 ? 1 : 0);
345
+ if (nextCount > approxChars && splitIdx > 0) break;
346
+ charCount = nextCount;
347
+ splitIdx = w + 1;
348
+ }
349
+ if (splitIdx === 0) splitIdx = 1; // take at least one word
350
+ if (splitIdx >= remainingWords.length) {
351
+ // All remaining words fit here
352
+ result.push({
353
+ ...tb,
354
+ id: `${tb.id}-split${col}`,
355
+ text: remainingWords.join(" "),
356
+ bounds: {
357
+ ...tb.bounds,
358
+ left: currentLeft,
359
+ right: segmentRight,
360
+ },
361
+ });
362
+ remainingWords = [];
363
+ } else {
364
+ const partWords = remainingWords.slice(0, splitIdx);
365
+ result.push({
366
+ ...tb,
367
+ id: `${tb.id}-split${col}`,
368
+ text: partWords.join(" "),
369
+ bounds: {
370
+ ...tb.bounds,
371
+ left: currentLeft,
372
+ right: segmentRight,
373
+ },
374
+ });
375
+ remainingWords = remainingWords.slice(splitIdx);
376
+ currentLeft = segmentRight;
377
+ }
378
+ }
379
+ }
380
+ }
381
+ return result;
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Full grid table (H + V lines)
386
+ // ---------------------------------------------------------------------------
387
+ function buildCells(rows: number, cols: number): TableCell[] {
388
+ const cells: TableCell[] = [];
389
+ for (let row = 0; row < rows; row++) {
390
+ for (let col = 0; col < cols; col++) {
391
+ cells.push({ row, col, text: "", rowSpan: 1, colSpan: 1 });
392
+ }
393
+ }
394
+ return cells;
395
+ }
396
+
397
+ function buildTableGrid(
398
+ pageNumber: number,
399
+ yLines: number[],
400
+ xLines: number[],
401
+ filteredSegments: Segment[],
402
+ textBoxes: TextBox[],
403
+ ): { grid: TableGrid; consumedIds: string[] } {
404
+ let rows = yLines.length - 1;
405
+ const cols = xLines.length - 1;
406
+ const cells = buildCells(rows, cols);
407
+ const consumedIds: string[] = [];
408
+ const yMin = yLines[yLines.length - 1];
409
+ const yMax = yLines[0];
410
+ const xMin = xLines[0];
411
+ const xMax = xLines[xLines.length - 1];
412
+ // Split text boxes that span multiple columns before placement
413
+ const splitBoxes = splitCrossColumnBoxes(textBoxes, xLines);
414
+ // Track which split piece IDs get placed in cells, so we can consume
415
+ // the original (unsplit) text box IDs too.
416
+ const placedSplitIds = new Set<string>();
417
+ // Look for header text boxes just above the grid.
418
+ // Use the ORIGINAL (unsplit) text boxes for header detection so that
419
+ // wide paragraph text isn't falsely split into column-sized header chunks.
420
+ // Reject boxes wider than 1.5 columns — those are paragraph text, not headers.
421
+ const avgColWidth = (xMax - xMin) / cols;
422
+ const maxHeaderBoxWidth = avgColWidth * 1.5;
423
+ const headerBoxes = textBoxes.filter(tb => {
424
+ const cy = (tb.bounds.top + tb.bounds.bottom) / 2;
425
+ const cx = (tb.bounds.left + tb.bounds.right) / 2;
426
+ const boxWidth = tb.bounds.right - tb.bounds.left;
427
+ return cy > yMax && cy <= yMax + 20 && cx >= xMin && cx <= xMax && boxWidth <= maxHeaderBoxWidth;
428
+ });
429
+ if (headerBoxes.length > 0) {
430
+ rows += 1;
431
+ for (const cell of cells) cell.row += 1;
432
+ for (let col = 0; col < cols; col++) {
433
+ cells.push({ row: 0, col, text: "", rowSpan: 1, colSpan: 1 });
434
+ }
435
+ for (const tb of headerBoxes) {
436
+ const cx = (tb.bounds.left + tb.bounds.right) / 2;
437
+ const col = xLines.findIndex((lineX, idx) => {
438
+ const next = xLines[idx + 1];
439
+ return next !== undefined && cx >= lineX && cx <= next;
440
+ });
441
+ if (col >= 0 && col < cols) {
442
+ const cell = cells.find(c => c.row === 0 && c.col === col);
443
+ if (cell) {
444
+ cell.text = cell.text.length === 0 ? tb.text : `${cell.text} ${tb.text}`;
445
+ consumedIds.push(tb.id);
446
+ }
447
+ }
448
+ }
449
+ }
450
+ const cellBoxes = new Map<TableCell, TextBox[]>();
451
+ for (const tb of splitBoxes) {
452
+ const cx = (tb.bounds.left + tb.bounds.right) / 2;
453
+ const cy = (tb.bounds.top + tb.bounds.bottom) / 2;
454
+ if (cy < yMin || cy > yMax || cx < xMin || cx > xMax) continue;
455
+ const rays = castRaysForTextBox(tb, filteredSegments);
456
+ const rayConfidence = rays.filter(r => r.segmentId !== null).length;
457
+ let row = yLines.findIndex((lineY, idx) => {
458
+ const next = yLines[idx + 1];
459
+ return next !== undefined && cy <= lineY && cy >= next;
460
+ });
461
+ if (row < 0 || row >= (headerBoxes.length > 0 ? rows - 1 : rows)) continue;
462
+ if (headerBoxes.length > 0) row += 1;
463
+ const col = xLines.findIndex((lineX, idx) => {
464
+ const next = xLines[idx + 1];
465
+ return next !== undefined && cx >= lineX && cx <= next;
466
+ });
467
+ if (col < 0 || col >= cols) continue;
468
+ if (rayConfidence === 0) continue;
469
+ const cell = cells.find(c => c.row === row && c.col === col);
470
+ if (!cell) continue;
471
+ if (!cellBoxes.has(cell)) cellBoxes.set(cell, []);
472
+ cellBoxes.get(cell)?.push(tb);
473
+ consumedIds.push(tb.id);
474
+ if (tb.id.includes("-split")) placedSplitIds.add(tb.id);
475
+ }
476
+ rows = expandSubRowsByYClusters(rows, cols, cells, cellBoxes);
477
+ // Merge text boxes within each cell into cell text
478
+ for (const [cell, boxes] of cellBoxes.entries()) {
479
+ boxes.sort((a, b) => b.bounds.top - a.bounds.top);
480
+ const lines: string[] = [];
481
+ let currentLine: string[] = [];
482
+ let currentY = boxes[0].bounds.top;
483
+ for (const box of boxes) {
484
+ if (Math.abs(box.bounds.top - currentY) > 5) {
485
+ lines.push(currentLine.join(" "));
486
+ currentLine = [box.text];
487
+ currentY = box.bounds.top;
488
+ } else {
489
+ currentLine.push(box.text);
490
+ }
491
+ }
492
+ if (currentLine.length > 0) lines.push(currentLine.join(" "));
493
+ cell.text = lines.join("<br>");
494
+ }
495
+ const grid = pruneEmptyRowsAndCols({
496
+ pageNumber,
497
+ rows,
498
+ cols,
499
+ cells,
500
+ warnings: [],
501
+ topY: yLines[0],
502
+ isBorderless: false,
503
+ });
504
+ // Also consume the original (unsplit) text box IDs when any of their
505
+ // split pieces were placed in a cell.
506
+ for (const splitId of placedSplitIds) {
507
+ const origId = splitId.replace(/-split\d+$/, "");
508
+ if (!consumedIds.includes(origId)) {
509
+ consumedIds.push(origId);
510
+ }
511
+ }
512
+ return { grid, consumedIds };
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // H-line-only table (inferred columns)
517
+ // ---------------------------------------------------------------------------
518
+ const COL_GAP_THRESHOLD = 20;
519
+ const HONLY_ROW_GAP = 30;
520
+ const HONLY_ROW_TOLERANCE = 8;
521
+ const MIN_TABLE_HEIGHT = 24;
522
+ const MIN_LEFT_SPREAD = 50;
523
+
524
+ function inferXLinesFromBoxes(textBoxes: TextBox[], xMin: number, xMax: number): number[] {
525
+ const centers = textBoxes.map(tb => (tb.bounds.left + tb.bounds.right) / 2).sort((a, b) => a - b);
526
+ if (centers.length === 0) return [xMin, xMax];
527
+ const boundaries = [xMin];
528
+ for (let i = 1; i < centers.length; i++) {
529
+ if (centers[i] - centers[i - 1] >= COL_GAP_THRESHOLD) {
530
+ boundaries.push((centers[i - 1] + centers[i]) / 2);
531
+ }
532
+ }
533
+ boundaries.push(xMax);
534
+ return boundaries;
535
+ }
536
+
537
+ function buildHLineOnlyTable(
538
+ pageNumber: number,
539
+ yLines: number[],
540
+ xMin: number,
541
+ xMax: number,
542
+ textBoxes: TextBox[],
543
+ alreadyConsumed: Set<string>,
544
+ ): { grid: TableGrid; consumedIds: string[] } | null {
545
+ const yMax = yLines[0];
546
+ const yMin = yLines[yLines.length - 1];
547
+ const candidates = textBoxes.filter(tb => !alreadyConsumed.has(tb.id));
548
+ const BOX_LEFT_TOLERANCE = 30;
549
+ const inRange = candidates.filter(tb => {
550
+ const cy = (tb.bounds.top + tb.bounds.bottom) / 2;
551
+ return (
552
+ tb.bounds.left >= xMin - BOX_LEFT_TOLERANCE &&
553
+ tb.bounds.right <= xMax + BOX_LEFT_TOLERANCE &&
554
+ cy >= yMin &&
555
+ cy <= yMax
556
+ );
557
+ });
558
+ // Extend downward below yMin
559
+ const belowYMin = candidates
560
+ .filter(tb => {
561
+ const cx = (tb.bounds.left + tb.bounds.right) / 2;
562
+ const cy = (tb.bounds.top + tb.bounds.bottom) / 2;
563
+ return cx >= xMin && cx <= xMax && cy < yMin;
564
+ })
565
+ .sort((a, b) => (b.bounds.top + b.bounds.bottom) / 2 - (a.bounds.top + a.bounds.bottom) / 2);
566
+ const extensionBoxes: TextBox[] = [];
567
+ let lastY = yMin;
568
+ for (const tb of belowYMin) {
569
+ const cy = (tb.bounds.top + tb.bounds.bottom) / 2;
570
+ if (lastY - cy > HONLY_ROW_GAP) break;
571
+ extensionBoxes.push(tb);
572
+ lastY = cy;
573
+ }
574
+ const allBoxes = [...inRange, ...extensionBoxes];
575
+ if (allBoxes.length === 0) return null;
576
+ const leftEdges = allBoxes.map(tb => tb.bounds.left);
577
+ if (Math.max(...leftEdges) - Math.min(...leftEdges) < MIN_LEFT_SPREAD) return null;
578
+ const xLines = inferXLinesFromBoxes(allBoxes, xMin, xMax);
579
+ if (xLines.length < 2) return null;
580
+ const cols = xLines.length - 1;
581
+ // Build visual rows
582
+ const visualRows: Array<{ midY: number; boxes: TextBox[] }> = [];
583
+ const sortedBoxes = [...allBoxes].sort((a, b) => {
584
+ const ya = (a.bounds.top + a.bounds.bottom) / 2;
585
+ const yb = (b.bounds.top + b.bounds.bottom) / 2;
586
+ if (Math.abs(ya - yb) > 0.5) return yb - ya;
587
+ return a.bounds.left - b.bounds.left;
588
+ });
589
+ for (const box of sortedBoxes) {
590
+ const cy = (box.bounds.top + box.bounds.bottom) / 2;
591
+ const last = visualRows[visualRows.length - 1];
592
+ if (last && Math.abs(last.midY - cy) <= HONLY_ROW_TOLERANCE) {
593
+ last.boxes.push(box);
594
+ } else {
595
+ visualRows.push({ midY: cy, boxes: [box] });
596
+ }
597
+ }
598
+ if (visualRows.length === 0) return null;
599
+ const cells: TableCell[] = [];
600
+ const consumedIds: string[] = [];
601
+ for (let rowIdx = 0; rowIdx < visualRows.length; rowIdx++) {
602
+ const vrow = visualRows[rowIdx];
603
+ const colBoxes = new Map<number, TextBox[]>();
604
+ for (const box of vrow.boxes) {
605
+ const cx = (box.bounds.left + box.bounds.right) / 2;
606
+ const col = xLines.findIndex((lineX, idx) => {
607
+ const next = xLines[idx + 1];
608
+ return next !== undefined && cx >= lineX && cx <= next;
609
+ });
610
+ if (col >= 0 && col < cols) {
611
+ if (!colBoxes.has(col)) colBoxes.set(col, []);
612
+ colBoxes.get(col)?.push(box);
613
+ }
614
+ }
615
+ for (let c = 0; c < cols; c++) {
616
+ const cbs = (colBoxes.get(c) ?? []).sort((a, b) => a.bounds.left - b.bounds.left);
617
+ cells.push({
618
+ row: rowIdx,
619
+ col: c,
620
+ text: cbs.map(b => b.text).join(" "),
621
+ rowSpan: 1,
622
+ colSpan: 1,
623
+ });
624
+ consumedIds.push(...cbs.map(b => b.id));
625
+ }
626
+ }
627
+ const contentTopY = visualRows.length > 0 ? visualRows[0].midY : yMax;
628
+ const grid = pruneEmptyRowsAndCols({
629
+ pageNumber,
630
+ rows: visualRows.length,
631
+ cols,
632
+ cells,
633
+ warnings: [],
634
+ topY: contentTopY,
635
+ isBorderless: false,
636
+ });
637
+ return { grid, consumedIds };
638
+ }
639
+
640
+ // ---------------------------------------------------------------------------
641
+ // Pruning
642
+ // ---------------------------------------------------------------------------
643
+ function pruneEmptyRowsAndCols(table: TableGrid): TableGrid {
644
+ const occupiedRows = new Set(table.cells.filter(c => c.text.trim().length > 0).map(c => c.row));
645
+ const occupiedCols = new Set(table.cells.filter(c => c.text.trim().length > 0).map(c => c.col));
646
+ if (occupiedRows.size === 0) return table;
647
+ const rowMap = new Map<number, number>();
648
+ let newRow = 0;
649
+ for (let r = 0; r < table.rows; r++) {
650
+ if (occupiedRows.has(r)) rowMap.set(r, newRow++);
651
+ }
652
+ const colMap = new Map<number, number>();
653
+ let newCol = 0;
654
+ for (let c = 0; c < table.cols; c++) {
655
+ if (occupiedCols.has(c)) colMap.set(c, newCol++);
656
+ }
657
+ const prunedCells = table.cells
658
+ .filter(c => occupiedRows.has(c.row) && occupiedCols.has(c.col))
659
+ .map(c => ({
660
+ ...c,
661
+ row: rowMap.get(c.row) ?? c.row,
662
+ col: colMap.get(c.col) ?? c.col,
663
+ }));
664
+ return { ...table, rows: newRow, cols: newCol, cells: prunedCells };
665
+ }
666
+
667
+ // ---------------------------------------------------------------------------
668
+ // Diagram vs table discrimination
669
+ // ---------------------------------------------------------------------------
670
+ /** Maximum column count for a plausible data table. */
671
+ const MAX_TABLE_COLS = 25;
672
+
673
+ /**
674
+ * Returns true if a grid looks like a vector diagram rather than a data table.
675
+ *
676
+ * Heuristics (any match → diagram):
677
+ * 1. Column count > 25 (diagrams create many X-lines from box edges)
678
+ * 2. Fill ratio < 25% (most cells empty — scattered boxes)
679
+ * 3. Fill < 50% AND duplicate text ratio > 30% (repeating labels in a
680
+ * diagram layout, e.g. "Hash", "Transaction" appearing in each column)
681
+ * 4. Fill < 50% AND cols >= 6 (moderate sparseness with wide grid)
682
+ */
683
+ function isDiagram(grid: TableGrid): boolean {
684
+ const totalCells = grid.rows * grid.cols;
685
+ if (totalCells === 0) return true;
686
+ const filled = grid.cells.filter(c => c.text.trim().length > 0);
687
+ const fillRatio = filled.length / totalCells;
688
+ // Very high column count
689
+ if (grid.cols > MAX_TABLE_COLS) return true;
690
+ // Very sparse
691
+ if (fillRatio < 0.25) return true;
692
+ // Compute duplicate text ratio among non-trivial cells.
693
+ // Exclude short values (≤3 chars) like "—", "V", "YES", "NO" which
694
+ // naturally repeat in real data tables.
695
+ const substantive = filled.filter(c => c.text.trim().length > 3);
696
+ const uniqueTexts = new Set(substantive.map(c => c.text.trim())).size;
697
+ const dupRatio = substantive.length > 2 ? 1 - uniqueTexts / substantive.length : 0;
698
+ // Sparse + highly duplicated substantive text → repeating diagram
699
+ if (fillRatio < 0.5 && dupRatio > 0.3) return true;
700
+ // High duplication + wide grid → repeating diagram even at moderate fill
701
+ if (dupRatio > 0.4 && grid.cols >= 6) return true;
702
+ // Sparse + wide grid with no substantive text to judge
703
+ if (fillRatio < 0.4 && grid.cols >= 6) return true;
704
+ return false;
705
+ }
706
+
707
+ /**
708
+ * Detect all table grids on a single page from its text boxes and segments.
709
+ */
710
+ export function resolveTableGrids(pageNumber: number, textBoxes: TextBox[], segments: Segment[]): GridResult {
711
+ const vertical = segments.filter(s => Math.abs(s.x1 - s.x2) <= AXIS_EPSILON);
712
+ const horizontal = segments.filter(s => Math.abs(s.y1 - s.y2) <= AXIS_EPSILON);
713
+ // Filter segments to the text's visible area
714
+ const textYValues = textBoxes.flatMap(t => [t.bounds.bottom, t.bounds.top]);
715
+ const textYMin = textYValues.length > 0 ? Math.min(...textYValues) - PAGE_MARGIN : -Infinity;
716
+ const textYMax = textYValues.length > 0 ? Math.max(...textYValues) + PAGE_MARGIN : Infinity;
717
+ const textXValues = textBoxes.flatMap(t => [t.bounds.left, t.bounds.right]);
718
+ const textXMin = textXValues.length > 0 ? Math.min(...textXValues) - 100 : -Infinity;
719
+ const textXMax = textXValues.length > 0 ? Math.max(...textXValues) + 100 : Infinity;
720
+ const filteredH = horizontal.filter(
721
+ s => s.y1 >= textYMin && s.y1 <= textYMax && s.x1 <= textXMax && s.x2 >= textXMin,
722
+ );
723
+ const hMaxX2 = filteredH.length > 0 ? Math.max(...filteredH.map(s => s.x2)) : textXMax;
724
+ const vSegXMax = Math.max(textXMax, hMaxX2 + PAGE_MARGIN);
725
+ const filteredV = vertical.filter(s => {
726
+ const segMin = Math.min(s.y1, s.y2);
727
+ const segMax = Math.max(s.y1, s.y2);
728
+ return segMax >= textYMin && segMin <= textYMax && s.x1 >= textXMin && s.x1 <= vSegXMax;
729
+ });
730
+ const allYLines = uniqueSorted(filteredH.flatMap(s => [s.y1, s.y2])).sort((a, b) => b - a);
731
+ if (allYLines.length < 2) {
732
+ return { grids: [], consumedIds: [] };
733
+ }
734
+ const filteredSegments = [...filteredH, ...filteredV];
735
+ const yGroups = splitYLinesIntoGroups(allYLines, filteredV);
736
+ const grids: TableGrid[] = [];
737
+ const gridConsumedIds: string[][] = [];
738
+ // Flat set for the alreadyConsumed check in H-line-only tables
739
+ const allConsumedIds: string[] = [];
740
+ for (const yLines of yGroups) {
741
+ if (yLines.length < 2) continue;
742
+ const yMin = yLines[yLines.length - 1];
743
+ const yMax = yLines[0];
744
+ const groupVerticals = filteredV.filter(s => {
745
+ const segMin = Math.min(s.y1, s.y2);
746
+ const segMax = Math.max(s.y1, s.y2);
747
+ return segMin < yMax - 1.5 && segMax > yMin + 1.5;
748
+ });
749
+ const groupXLines = uniqueSorted(groupVerticals.flatMap(s => [s.x1, s.x2]));
750
+ if (groupXLines.length < 2) {
751
+ if (yMax - yMin < MIN_TABLE_HEIGHT) continue;
752
+ const groupHoriz = filteredH.filter(s => s.y1 >= yMin - 1.5 && s.y1 <= yMax + 1.5);
753
+ if (groupHoriz.length === 0) continue;
754
+ const hxMin = Math.min(...groupHoriz.map(s => s.x1));
755
+ const hxMax = Math.max(...groupHoriz.map(s => s.x2));
756
+ const result = buildHLineOnlyTable(pageNumber, yLines, hxMin, hxMax, textBoxes, new Set(allConsumedIds));
757
+ if (result) {
758
+ grids.push(result.grid);
759
+ gridConsumedIds.push(result.consumedIds);
760
+ allConsumedIds.push(...result.consumedIds);
761
+ }
762
+ continue;
763
+ }
764
+ if (yMax - yMin < MIN_TABLE_HEIGHT) continue;
765
+ const result = buildTableGrid(pageNumber, yLines, groupXLines, filteredSegments, textBoxes);
766
+ grids.push(result.grid);
767
+ gridConsumedIds.push(result.consumedIds);
768
+ allConsumedIds.push(...result.consumedIds);
769
+ }
770
+ // Filter out grids that look like vector diagrams, not data tables.
771
+ // Their consumed text box IDs are released so the text becomes free text.
772
+ const filteredGrids: TableGrid[] = [];
773
+ const filteredConsumedIds: string[] = [];
774
+ for (let i = 0; i < grids.length; i++) {
775
+ if (isDiagram(grids[i])) continue;
776
+ filteredGrids.push(grids[i]);
777
+ filteredConsumedIds.push(...gridConsumedIds[i]);
778
+ }
779
+ return { grids: filteredGrids, consumedIds: filteredConsumedIds };
780
+ }