2026-07-05 20:45:35 +08:00
|
|
|
|
package table
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-18 16:16:02 +08:00
|
|
|
|
"fmt"
|
2026-07-05 20:45:35 +08:00
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
|
|
pdf "ragflow/internal/deepdoc/parser/pdf/type"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
func TestCrossPageTableMerge(t *testing.T) {
|
|
|
|
|
|
// Page 0 table: 2 cells, positioned at page 0.
|
|
|
|
|
|
pg0 := pdf.TableItem{
|
|
|
|
|
|
Positions: []pdf.Position{
|
|
|
|
|
|
{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 800},
|
|
|
|
|
|
},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{
|
|
|
|
|
|
{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "pg0_r0c0"},
|
|
|
|
|
|
{X0: 100, Y0: 0, X1: 200, Y1: 50, Text: "pg0_r0c1"},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
// Page 1 table: 2 cells, same X range, positioned at page 1.
|
|
|
|
|
|
pg1 := pdf.TableItem{
|
|
|
|
|
|
Positions: []pdf.Position{
|
|
|
|
|
|
{PageNumbers: []int{1}, Left: 50, Right: 500, Top: 100, Bottom: 300},
|
|
|
|
|
|
},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{
|
|
|
|
|
|
{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "pg1_r0c0"},
|
|
|
|
|
|
{X0: 100, Y0: 0, X1: 200, Y1: 50, Text: "pg1_r0c1"},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
tables := []pdf.TableItem{pg0, pg1}
|
|
|
|
|
|
|
|
|
|
|
|
// mergeTablesAcrossPages merges tables on consecutive pages with X overlap.
|
|
|
|
|
|
merged := MergeTablesAcrossPages(tables, nil)
|
|
|
|
|
|
if len(merged) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 merged table, got %d", len(merged))
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(merged[0].Cells) != 4 {
|
|
|
|
|
|
t.Errorf("expected 4 merged cells, got %d", len(merged[0].Cells))
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(merged[0].Positions) != 2 {
|
|
|
|
|
|
t.Errorf("expected 2 merged positions, got %d", len(merged[0].Positions))
|
|
|
|
|
|
}
|
|
|
|
|
|
t.Logf("Merged %d cells across %d pages", len(merged[0].Cells), len(merged[0].Positions))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestMergeTablesAcrossPages_NoOverlap verifies that non-adjacent or
|
|
|
|
|
|
// non-overlapping tables are NOT merged.
|
|
|
|
|
|
func TestMergeTablesAcrossPages_NoOverlap(t *testing.T) {
|
|
|
|
|
|
// Tables with no X overlap should NOT be merged.
|
|
|
|
|
|
tables := []pdf.TableItem{
|
|
|
|
|
|
{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 100, Top: 100, Bottom: 500}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{{Text: "left"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 500, Right: 600, Top: 100, Bottom: 500}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{{Text: "right"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
merged := MergeTablesAcrossPages(tables, nil)
|
|
|
|
|
|
if len(merged) != 2 {
|
|
|
|
|
|
t.Fatalf("non-overlapping tables: expected 2 tables, got %d", len(merged))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestMergeTablesAcrossPages_NonConsecutive verifies that tables on
|
|
|
|
|
|
// non-consecutive pages are NOT merged.
|
|
|
|
|
|
func TestMergeTablesAcrossPages_NonConsecutive(t *testing.T) {
|
|
|
|
|
|
tables := []pdf.TableItem{
|
|
|
|
|
|
{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 500}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{{Text: "page0"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{3}, Left: 50, Right: 500, Top: 100, Bottom: 500}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{{Text: "page3"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
merged := MergeTablesAcrossPages(tables, nil)
|
|
|
|
|
|
if len(merged) != 2 {
|
|
|
|
|
|
t.Fatalf("non-consecutive pages: expected 2 tables, got %d", len(merged))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestMergeTablesAcrossPages_SingleTable verifies that a single table
|
|
|
|
|
|
// passes through unchanged.
|
|
|
|
|
|
func TestMergeTablesAcrossPages_SingleTable(t *testing.T) {
|
|
|
|
|
|
tables := []pdf.TableItem{
|
|
|
|
|
|
{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 500}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{{Text: "only"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
merged := MergeTablesAcrossPages(tables, nil)
|
|
|
|
|
|
if len(merged) != 1 {
|
|
|
|
|
|
t.Fatalf("single table: expected 1 table, got %d", len(merged))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestMergeTablesAcrossPages_EmptyPositions(t *testing.T) {
|
|
|
|
|
|
// Tables with empty Positions should be preserved (not dropped).
|
|
|
|
|
|
tables := []pdf.TableItem{
|
|
|
|
|
|
{
|
|
|
|
|
|
Positions: []pdf.Position{},
|
|
|
|
|
|
Cells: []pdf.TSRCell{{Text: "posless"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 500}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{{Text: "normal"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
merged := MergeTablesAcrossPages(tables, nil)
|
|
|
|
|
|
if len(merged) != 2 {
|
|
|
|
|
|
t.Fatalf("empty Positions: expected 2 tables (preserved), got %d", len(merged))
|
|
|
|
|
|
}
|
|
|
|
|
|
// Tables with Positions come first (from items list), positionless tables are appended.
|
|
|
|
|
|
if len(merged[0].Positions) == 0 {
|
|
|
|
|
|
t.Error("expected table with Positions first in result")
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(merged[1].Positions) != 0 {
|
|
|
|
|
|
t.Error("expected positionless table second in result")
|
|
|
|
|
|
}
|
|
|
|
|
|
if merged[1].Cells[0].Text != "posless" {
|
|
|
|
|
|
t.Errorf("positionless table content lost: got %q", merged[1].Cells[0].Text)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestMergeTablesAcrossPages_LargeYGap(t *testing.T) {
|
|
|
|
|
|
// Tables with large Y gap should NOT be merged.
|
|
|
|
|
|
medianHeights := map[int]float64{0: 10}
|
|
|
|
|
|
tables := []pdf.TableItem{
|
|
|
|
|
|
{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 150}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{{Text: "page0"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 50, Right: 500, Top: 5000, Bottom: 5100}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{{Text: "page1_far"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
merged := MergeTablesAcrossPages(tables, medianHeights)
|
|
|
|
|
|
if len(merged) != 2 {
|
|
|
|
|
|
t.Fatalf("large Y gap: expected 2 tables (not merged), got %d", len(merged))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestMergeTablesAcrossPages_NoMedianHeights(t *testing.T) {
|
|
|
|
|
|
// Without medianHeights, mh defaults to 10 and threshold is 230.
|
|
|
|
|
|
// yDis = (10 + 120 - 150 - 150) / 2 = -85, which is <= 230, so they merge.
|
|
|
|
|
|
tables := []pdf.TableItem{
|
|
|
|
|
|
{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 150}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{{Text: "page0"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 50, Right: 500, Top: 10, Bottom: 120}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: []pdf.TSRCell{{Text: "page1_near"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
merged := MergeTablesAcrossPages(tables, nil)
|
|
|
|
|
|
if len(merged) != 1 {
|
|
|
|
|
|
t.Fatalf("no medianHeights: expected 1 merged table, got %d", len(merged))
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(merged[0].Cells) != 2 {
|
|
|
|
|
|
t.Errorf("expected 2 cells after merge, got %d", len(merged[0].Cells))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-17 17:58:19 +08:00
|
|
|
|
|
|
|
|
|
|
// TestMergeTablesAcrossPages_RebuildsGridAcrossPages verifies that after a
|
|
|
|
|
|
// cross-page merge the merged table's Grid contains rows from BOTH pages,
|
|
|
|
|
|
// not just the anchor (page-0) grid. This catches the regression where
|
|
|
|
|
|
// ConstructTable reads the stale anchor Grid and drops all continuation rows.
|
|
|
|
|
|
func TestMergeTablesAcrossPages_RebuildsGridAcrossPages(t *testing.T) {
|
|
|
|
|
|
pageGrid := func(rows [][]string) [][]pdf.TSRCell {
|
|
|
|
|
|
g := make([][]pdf.TSRCell, len(rows))
|
|
|
|
|
|
for r, row := range rows {
|
|
|
|
|
|
g[r] = make([]pdf.TSRCell, len(row))
|
|
|
|
|
|
for c := range row {
|
|
|
|
|
|
g[r][c] = pdf.TSRCell{
|
|
|
|
|
|
X0: float64(c) * 100, Y0: float64(r) * 30,
|
|
|
|
|
|
X1: float64(c)*100 + 100, Y1: float64(r)*30 + 30,
|
|
|
|
|
|
Text: row[c],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return g
|
|
|
|
|
|
}
|
|
|
|
|
|
pg0 := pdf.TableItem{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 0, Right: 200, Top: 0, Bottom: 60}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Grid: pageGrid([][]string{{"a", "b"}, {"c", "d"}}),
|
|
|
|
|
|
}
|
|
|
|
|
|
pg1 := pdf.TableItem{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 0, Right: 200, Top: 0, Bottom: 60}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Grid: pageGrid([][]string{{"e", "f"}, {"g", "h"}}),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
merged := MergeTablesAcrossPages([]pdf.TableItem{pg0, pg1}, nil)
|
|
|
|
|
|
if len(merged) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 merged table, got %d", len(merged))
|
|
|
|
|
|
}
|
|
|
|
|
|
// Anchor has 2 rows, continuation has 2 rows → merged Grid must be 4.
|
|
|
|
|
|
if len(merged[0].Grid) != 4 {
|
|
|
|
|
|
t.Fatalf("merged Grid must contain rows from both pages (want 4), got %d", len(merged[0].Grid))
|
|
|
|
|
|
}
|
|
|
|
|
|
// Continuation rows must appear after anchor rows, in page order.
|
|
|
|
|
|
if merged[0].Grid[0][0].Text != "a" || merged[0].Grid[2][0].Text != "e" {
|
|
|
|
|
|
t.Errorf("row order wrong after stacking: %s / %s", merged[0].Grid[0][0].Text, merged[0].Grid[2][0].Text)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Continuation rows must be Y-shifted strictly below the anchor rows so
|
|
|
|
|
|
// Y-monotonic downstream logic (span detection, ordering) stays correct.
|
|
|
|
|
|
// Catches a regression where the shift is dropped but stacking is kept:
|
|
|
|
|
|
// row order would still be correct by page order, so only this assertion
|
|
|
|
|
|
// would fail.
|
|
|
|
|
|
if merged[0].Grid[2][0].Y0 <= merged[0].Grid[1][0].Y1 {
|
|
|
|
|
|
t.Errorf("continuation row was not shifted below the anchor rows (Grid[2][0].Y0=%v <= Grid[1][0].Y1=%v)",
|
|
|
|
|
|
merged[0].Grid[2][0].Y0, merged[0].Grid[1][0].Y1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 16:16:02 +08:00
|
|
|
|
// TestMergeTablesAcrossPages_JaggedContinuationPreservesRows verifies that
|
|
|
|
|
|
// when a continuation page's grid is not column-uniform with the anchor (a
|
|
|
|
|
|
// jagged cross-page stack), MergeTablesAcrossPages still preserves every
|
|
|
|
|
|
// continuation row. It aligns both per-page grids to a shared column model
|
|
|
|
|
|
// (the max column count, padding shorter rows by index) and stacks all rows,
|
|
|
|
|
|
// instead of dropping the continuation page's Grid. Regression test for the
|
|
|
|
|
|
// bug where a non-uniform cross-page grid silently deleted an entire
|
|
|
|
|
|
// continuation page from the output.
|
|
|
|
|
|
func TestMergeTablesAcrossPages_JaggedContinuationPreservesRows(t *testing.T) {
|
2026-08-17 17:58:19 +08:00
|
|
|
|
pageGrid := func(rows [][]string) [][]pdf.TSRCell {
|
|
|
|
|
|
g := make([][]pdf.TSRCell, len(rows))
|
|
|
|
|
|
for r, row := range rows {
|
|
|
|
|
|
g[r] = make([]pdf.TSRCell, len(row))
|
|
|
|
|
|
for c := range row {
|
|
|
|
|
|
g[r][c] = pdf.TSRCell{
|
|
|
|
|
|
X0: float64(c) * 100, Y0: float64(r) * 30,
|
|
|
|
|
|
X1: float64(c)*100 + 100, Y1: float64(r)*30 + 30,
|
|
|
|
|
|
Text: row[c],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return g
|
|
|
|
|
|
}
|
|
|
|
|
|
cells := func(rows [][]string) []pdf.TSRCell {
|
|
|
|
|
|
var cs []pdf.TSRCell
|
|
|
|
|
|
for r, row := range rows {
|
|
|
|
|
|
for c := range row {
|
|
|
|
|
|
cs = append(cs, pdf.TSRCell{
|
|
|
|
|
|
X0: float64(c) * 100, Y0: float64(r) * 30,
|
|
|
|
|
|
X1: float64(c)*100 + 100, Y1: float64(r)*30 + 30,
|
|
|
|
|
|
Text: row[c],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return cs
|
|
|
|
|
|
}
|
2026-08-18 09:45:46 +08:00
|
|
|
|
cases := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
anchorRows [][]string
|
|
|
|
|
|
contRows [][]string
|
|
|
|
|
|
contCellText string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "first row column count differs",
|
|
|
|
|
|
anchorRows: [][]string{{"a", "b", "c"}, {"d", "e", "f"}},
|
|
|
|
|
|
contRows: [][]string{{"g", "h"}, {"i", "j"}},
|
|
|
|
|
|
contCellText: "g",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "interior row column count differs",
|
|
|
|
|
|
anchorRows: [][]string{{"a", "b", "c"}, {"d", "e", "f"}},
|
|
|
|
|
|
contRows: [][]string{{"g", "h", "i"}, {"j", "k"}},
|
|
|
|
|
|
contCellText: "g",
|
|
|
|
|
|
},
|
2026-08-17 17:58:19 +08:00
|
|
|
|
}
|
2026-08-18 09:45:46 +08:00
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
|
pg0 := pdf.TableItem{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 0, Right: 300, Top: 0, Bottom: 60}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Grid: pageGrid(tc.anchorRows),
|
|
|
|
|
|
Cells: cells(tc.anchorRows),
|
|
|
|
|
|
}
|
|
|
|
|
|
pg1 := pdf.TableItem{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 0, Right: 300, Top: 0, Bottom: 60}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Grid: pageGrid(tc.contRows),
|
|
|
|
|
|
Cells: cells(tc.contRows),
|
|
|
|
|
|
}
|
2026-08-17 17:58:19 +08:00
|
|
|
|
|
2026-08-18 09:45:46 +08:00
|
|
|
|
merged := MergeTablesAcrossPages([]pdf.TableItem{pg0, pg1}, nil)
|
|
|
|
|
|
if len(merged) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 merged table, got %d", len(merged))
|
|
|
|
|
|
}
|
2026-08-18 16:16:02 +08:00
|
|
|
|
// Columns differ (anchor 3 vs continuation jagged) → aligned to
|
|
|
|
|
|
// uniCols=3, rows still stacked: anchor + continuation (no drop).
|
|
|
|
|
|
wantRows := len(tc.anchorRows) + len(tc.contRows)
|
|
|
|
|
|
if len(merged[0].Grid) != wantRows {
|
|
|
|
|
|
t.Fatalf("jagged continuation must preserve all rows (want %d), got %d", wantRows, len(merged[0].Grid))
|
2026-08-18 09:45:46 +08:00
|
|
|
|
}
|
2026-08-18 16:16:02 +08:00
|
|
|
|
// Aligned width is the max column count (3) for every row.
|
|
|
|
|
|
for r, row := range merged[0].Grid {
|
|
|
|
|
|
if len(row) != 3 {
|
|
|
|
|
|
t.Errorf("row %d: aligned grid width must be max cols (3), got %d", r, len(row))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Anchor rows preserved first.
|
2026-08-18 09:45:46 +08:00
|
|
|
|
if merged[0].Grid[0][0].Text != tc.anchorRows[0][0] || merged[0].Grid[1][0].Text != tc.anchorRows[1][0] {
|
2026-08-18 16:16:02 +08:00
|
|
|
|
t.Errorf("anchor rows corrupted after alignment: %s / %s", merged[0].Grid[0][0].Text, merged[0].Grid[1][0].Text)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Continuation rows appended in page order, padded by index.
|
|
|
|
|
|
base := len(tc.anchorRows)
|
|
|
|
|
|
for r, crow := range tc.contRows {
|
|
|
|
|
|
for c, txt := range crow {
|
|
|
|
|
|
if merged[0].Grid[base+r][c].Text != txt {
|
|
|
|
|
|
t.Errorf("continuation cell lost after alignment: Grid[%d][%d]=%q want %q", base+r, c, merged[0].Grid[base+r][c].Text, txt)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-18 09:45:46 +08:00
|
|
|
|
}
|
2026-08-18 16:16:02 +08:00
|
|
|
|
// Continuation Cells are still appended (merge decision unchanged).
|
2026-08-18 09:45:46 +08:00
|
|
|
|
hasCont := false
|
|
|
|
|
|
for _, c := range merged[0].Cells {
|
|
|
|
|
|
if c.Text == tc.contCellText {
|
|
|
|
|
|
hasCont = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !hasCont {
|
2026-08-18 16:16:02 +08:00
|
|
|
|
t.Errorf("continuation Cells should still be appended after alignment")
|
2026-08-18 09:45:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-08-17 17:58:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 16:16:02 +08:00
|
|
|
|
// TestMergeTablesAcrossPages_MixedColumnCountsPreservesAllRows reproduces the
|
|
|
|
|
|
// 中加纯债 cross-page table: page 0 has 27 rows × 6 cols and page 1 has 35
|
|
|
|
|
|
// rows × 7 cols (TSR detects one extra spurious column on page 1). The merged
|
|
|
|
|
|
// table must contain ALL 62 rows (27 + 35) at the shared width of 7 columns.
|
|
|
|
|
|
func TestMergeTablesAcrossPages_MixedColumnCountsPreservesAllRows(t *testing.T) {
|
|
|
|
|
|
gridWithTag := func(rows, cols int, tag string) [][]pdf.TSRCell {
|
|
|
|
|
|
g := make([][]pdf.TSRCell, rows)
|
|
|
|
|
|
for r := 0; r < rows; r++ {
|
|
|
|
|
|
g[r] = make([]pdf.TSRCell, cols)
|
|
|
|
|
|
for c := 0; c < cols; c++ {
|
|
|
|
|
|
g[r][c] = pdf.TSRCell{
|
|
|
|
|
|
X0: float64(c) * 100, Y0: float64(r) * 30,
|
|
|
|
|
|
X1: float64(c)*100 + 100, Y1: float64(r)*30 + 30,
|
|
|
|
|
|
Text: fmt.Sprintf("%s_r%d_c%d", tag, r, c),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return g
|
|
|
|
|
|
}
|
|
|
|
|
|
pg0 := pdf.TableItem{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 0, Right: 600, Top: 0, Bottom: 810}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Grid: gridWithTag(27, 6, "p0"),
|
|
|
|
|
|
}
|
|
|
|
|
|
pg1 := pdf.TableItem{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 0, Right: 700, Top: 0, Bottom: 1050}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Grid: gridWithTag(35, 7, "p1"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
merged := MergeTablesAcrossPages([]pdf.TableItem{pg0, pg1}, nil)
|
|
|
|
|
|
if len(merged) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 merged table, got %d", len(merged))
|
|
|
|
|
|
}
|
|
|
|
|
|
// All 62 rows (27 + 35) must survive the cross-page merge.
|
|
|
|
|
|
if len(merged[0].Grid) != 62 {
|
|
|
|
|
|
t.Fatalf("merged Grid must contain all rows from both pages (want 62), got %d", len(merged[0].Grid))
|
|
|
|
|
|
}
|
|
|
|
|
|
// Shared width is the max column count (7) for every row.
|
|
|
|
|
|
for r, row := range merged[0].Grid {
|
|
|
|
|
|
if len(row) != 7 {
|
|
|
|
|
|
t.Errorf("row %d: aligned grid width must be max cols (7), got %d", r, len(row))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Anchor rows first, continuation rows appended, both complete.
|
|
|
|
|
|
if merged[0].Grid[0][0].Text != "p0_r0_c0" {
|
|
|
|
|
|
t.Errorf("first anchor row lost: %s", merged[0].Grid[0][0].Text)
|
|
|
|
|
|
}
|
|
|
|
|
|
if merged[0].Grid[26][5].Text != "p0_r26_c5" {
|
|
|
|
|
|
t.Errorf("last anchor row lost: %s", merged[0].Grid[26][5].Text)
|
|
|
|
|
|
}
|
|
|
|
|
|
if merged[0].Grid[27][0].Text != "p1_r0_c0" {
|
|
|
|
|
|
t.Errorf("first continuation row lost: %s", merged[0].Grid[27][0].Text)
|
|
|
|
|
|
}
|
|
|
|
|
|
if merged[0].Grid[61][6].Text != "p1_r34_c6" {
|
|
|
|
|
|
t.Errorf("last continuation row lost: %s", merged[0].Grid[61][6].Text)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 17:58:19 +08:00
|
|
|
|
// TestMergeTablesAcrossPages_ThreePageCumulativeShift verifies that with three
|
|
|
|
|
|
// consecutive pages the per-page grids stack cumulatively: each continuation
|
|
|
|
|
|
// page sits strictly below the previous page's last row, and the Y shift
|
|
|
|
|
|
// accumulates (page2 below page1 below page0). Catches a regression where
|
|
|
|
|
|
// stackGrids resets prevMaxY to the anchor instead of carrying the prior
|
|
|
|
|
|
// page's shifted bottom forward.
|
|
|
|
|
|
func TestMergeTablesAcrossPages_ThreePageCumulativeShift(t *testing.T) {
|
|
|
|
|
|
pageGrid := func(rows [][]string) [][]pdf.TSRCell {
|
|
|
|
|
|
g := make([][]pdf.TSRCell, len(rows))
|
|
|
|
|
|
for r, row := range rows {
|
|
|
|
|
|
g[r] = make([]pdf.TSRCell, len(row))
|
|
|
|
|
|
for c := range row {
|
|
|
|
|
|
g[r][c] = pdf.TSRCell{
|
|
|
|
|
|
X0: float64(c) * 100, Y0: float64(r) * 30,
|
|
|
|
|
|
X1: float64(c)*100 + 100, Y1: float64(r)*30 + 30,
|
|
|
|
|
|
Text: row[c],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return g
|
|
|
|
|
|
}
|
|
|
|
|
|
// Three pages, 2 rows × 2 cols each, identical layout.
|
|
|
|
|
|
pages := []pdf.TableItem{
|
|
|
|
|
|
{Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 0, Right: 200, Top: 0, Bottom: 60}}, Scale: 1.0, Grid: pageGrid([][]string{{"a", "b"}, {"c", "d"}})},
|
|
|
|
|
|
{Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 0, Right: 200, Top: 0, Bottom: 60}}, Scale: 1.0, Grid: pageGrid([][]string{{"e", "f"}, {"g", "h"}})},
|
|
|
|
|
|
{Positions: []pdf.Position{{PageNumbers: []int{2}, Left: 0, Right: 200, Top: 0, Bottom: 60}}, Scale: 1.0, Grid: pageGrid([][]string{{"i", "j"}, {"k", "l"}})},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
merged := MergeTablesAcrossPages(pages, nil)
|
|
|
|
|
|
if len(merged) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 merged table, got %d", len(merged))
|
|
|
|
|
|
}
|
|
|
|
|
|
// 3 pages × 2 rows → 6 rows, in page order.
|
|
|
|
|
|
if len(merged[0].Grid) != 6 {
|
|
|
|
|
|
t.Fatalf("3-page merge must stack all rows (want 6), got %d", len(merged[0].Grid))
|
|
|
|
|
|
}
|
|
|
|
|
|
if merged[0].Grid[0][0].Text != "a" || merged[0].Grid[2][0].Text != "e" || merged[0].Grid[4][0].Text != "i" {
|
|
|
|
|
|
t.Errorf("row order wrong after 3-page stacking: %s / %s / %s",
|
|
|
|
|
|
merged[0].Grid[0][0].Text, merged[0].Grid[2][0].Text, merged[0].Grid[4][0].Text)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Cumulative Y shift: page1 strictly below page0, page2 strictly below
|
|
|
|
|
|
// page1, and page2 below page1 (monotonic accumulation).
|
|
|
|
|
|
if merged[0].Grid[2][0].Y0 <= merged[0].Grid[1][0].Y1 {
|
|
|
|
|
|
t.Errorf("page1 not shifted below page0 (Grid[2][0].Y0=%v <= Grid[1][0].Y1=%v)",
|
|
|
|
|
|
merged[0].Grid[2][0].Y0, merged[0].Grid[1][0].Y1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if merged[0].Grid[4][0].Y0 <= merged[0].Grid[3][0].Y1 {
|
|
|
|
|
|
t.Errorf("page2 not shifted below page1 (Grid[4][0].Y0=%v <= Grid[3][0].Y1=%v)",
|
|
|
|
|
|
merged[0].Grid[4][0].Y0, merged[0].Grid[3][0].Y1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if merged[0].Grid[4][0].Y0 <= merged[0].Grid[2][0].Y0 {
|
|
|
|
|
|
t.Errorf("Y shift not cumulative: page2 (Y0=%v) must be below page1 (Y0=%v)",
|
|
|
|
|
|
merged[0].Grid[4][0].Y0, merged[0].Grid[2][0].Y0)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestMergeTablesAcrossPages_GridlessAnchorUnchanged verifies that when the
|
|
|
|
|
|
// anchor has no Grid, MergeTablesAcrossPages skips the rebuild entirely and
|
|
|
|
|
|
// leaves anchor.Grid empty (nil) so ConstructTable falls back to the Cells
|
|
|
|
|
|
// path — the same pre-fix behaviour. This locks the no-regression promise the
|
|
|
|
|
|
// fix relies on for Grid-less tables (and the 7 pre-existing tests that set no
|
|
|
|
|
|
// Grid). The cross-page merge decision itself is unchanged: continuation
|
|
|
|
|
|
// Cells and Positions are still appended.
|
|
|
|
|
|
func TestMergeTablesAcrossPages_GridlessAnchorUnchanged(t *testing.T) {
|
|
|
|
|
|
cells := func(texts []string) []pdf.TSRCell {
|
|
|
|
|
|
cs := make([]pdf.TSRCell, len(texts))
|
|
|
|
|
|
for i, txt := range texts {
|
|
|
|
|
|
cs[i] = pdf.TSRCell{
|
|
|
|
|
|
X0: 0, Y0: float64(i) * 30, X1: 100, Y1: float64(i)*30 + 30,
|
|
|
|
|
|
Text: txt,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return cs
|
|
|
|
|
|
}
|
|
|
|
|
|
// Anchor has Cells but NO Grid; continuation also Grid-less.
|
|
|
|
|
|
pg0 := pdf.TableItem{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 0, Right: 200, Top: 0, Bottom: 60}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: cells([]string{"a", "b", "c", "d"}),
|
|
|
|
|
|
}
|
|
|
|
|
|
pg1 := pdf.TableItem{
|
|
|
|
|
|
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 0, Right: 200, Top: 0, Bottom: 60}},
|
|
|
|
|
|
Scale: 1.0,
|
|
|
|
|
|
Cells: cells([]string{"e", "f", "g", "h"}),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
merged := MergeTablesAcrossPages([]pdf.TableItem{pg0, pg1}, nil)
|
|
|
|
|
|
if len(merged) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 merged table, got %d", len(merged))
|
|
|
|
|
|
}
|
|
|
|
|
|
// Guard: anchor has no Grid → rebuild skipped entirely → Grid stays empty.
|
|
|
|
|
|
if len(merged[0].Grid) != 0 {
|
|
|
|
|
|
t.Fatalf("Grid-less anchor must keep Grid empty (rebuild skipped), got %d rows", len(merged[0].Grid))
|
|
|
|
|
|
}
|
|
|
|
|
|
// Merge decision unchanged: all continuation Cells still appended.
|
|
|
|
|
|
have := map[string]bool{}
|
|
|
|
|
|
for _, c := range merged[0].Cells {
|
|
|
|
|
|
have[c.Text] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, want := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
|
|
|
|
|
|
if !have[want] {
|
|
|
|
|
|
t.Errorf("merged Cells missing %q (merge decision changed for Grid-less anchor)", want)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Positions from both pages present → the cross-page merge did happen.
|
|
|
|
|
|
pages := map[int]bool{}
|
|
|
|
|
|
for _, p := range merged[0].Positions {
|
|
|
|
|
|
for _, pn := range p.PageNumbers {
|
|
|
|
|
|
pages[pn] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !pages[0] || !pages[1] {
|
|
|
|
|
|
t.Errorf("cross-page merge did not combine both pages' positions: %v", pages)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|