diff --git a/internal/ingestion/component/chunker/general_table_policy_test.go b/internal/ingestion/component/chunker/general_table_policy_test.go
new file mode 100644
index 0000000000..42e0ded5f3
--- /dev/null
+++ b/internal/ingestion/component/chunker/general_table_policy_test.go
@@ -0,0 +1,67 @@
+//
+// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+package chunker
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+// TestGeneralChunkerKeepsLargeTablesWhole pins the table policy shared by the
+// PDF and DOCX paths: a table is an atomic media unit, so chunk_token_size
+// never splits it. Text is still merged and split by the configured budget.
+func TestGeneralChunkerKeepsLargeTablesWhole(t *testing.T) {
+ component, err := NewGeneralChunker(map[string]any{"chunk_token_size": 512})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ table := "
| header |
" +
+ strings.Repeat("| cell value |
", 400) +
+ "
"
+ if tokens := tokenizeStr(table); tokens <= 512 {
+ t.Fatalf("test table has %d tokens, want more than the 512 budget", tokens)
+ }
+
+ for _, fileType := range []string{"docx", "pdf"} {
+ t.Run(fileType, func(t *testing.T) {
+ out, err := component.Invoke(context.Background(), nil, map[string]any{
+ "name": "large." + fileType,
+ "file_type": fileType,
+ "output_format": "json",
+ "json": []map[string]any{{
+ "text": table,
+ "doc_type_kwd": "table",
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ chunks := outputChunks(t, out)
+ if len(chunks) != 1 {
+ t.Fatalf("chunks = %d, want 1: a table is not split by the token budget", len(chunks))
+ }
+ if got := chunks[0]["doc_type_kwd"]; got != "table" {
+ t.Fatalf("doc_type_kwd = %v, want table", got)
+ }
+ if got, _ := chunks[0]["text"].(string); got != table {
+ t.Fatalf("table text changed: got %d bytes, want %d", len(got), len(table))
+ }
+ })
+ }
+}
diff --git a/internal/parser/parser/docx_ir.go b/internal/parser/parser/docx_ir.go
index f7bf8113fd..f6781e9c28 100644
--- a/internal/parser/parser/docx_ir.go
+++ b/internal/parser/parser/docx_ir.go
@@ -99,11 +99,14 @@ type docxIRRun struct {
}
type docxIRRow struct {
- Cells []docxIRCell `json:"cells"`
+ IsHeader bool `json:"is_header"` // repeated table header row
+ Cells []docxIRCell `json:"cells"`
}
type docxIRCell struct {
Content []docxIRElement `json:"content"` // block-level content (typically paragraphs; may also hold list/table)
+ ColSpan int `json:"col_span"`
+ RowSpan int `json:"row_span"`
}
// joinDOCXIRRuns concatenates inline runs into plain text. Hard line
@@ -180,16 +183,26 @@ func joinCellText(cell docxIRCell) string {
}
// officeIRTableData projects native Office rows directly onto the table contract.
+// The row/column spans office_oxide recovers for merged cells are preserved so
+// BuildTableData can rebuild the same grid; header flags mark repeated header
+// rows, falling back to BuildTableData's default when the format does not carry
+// them.
func officeIRTableData(el docxIRElement, caption string) *entity.TableData {
rows := make([][]tableutil.CellInput, 0, len(el.Rows))
+ headerFlags := make([]bool, 0, len(el.Rows))
for _, row := range el.Rows {
cells := make([]tableutil.CellInput, 0, len(row.Cells))
for _, cell := range row.Cells {
- cells = append(cells, tableutil.CellInput{Text: joinCellText(cell)})
+ cells = append(cells, tableutil.CellInput{
+ Text: joinCellText(cell),
+ ColSpan: cell.ColSpan,
+ RowSpan: cell.RowSpan,
+ })
}
rows = append(rows, cells)
+ headerFlags = append(headerFlags, row.IsHeader)
}
- return tableutil.BuildTableData(rows, nil, strings.TrimSpace(caption))
+ return tableutil.BuildTableData(rows, headerFlags, strings.TrimSpace(caption))
}
type docxTableImage struct {
@@ -302,16 +315,6 @@ func docxElementText(el docxIRElement, cellSep string) string {
}
}
-// docxIRTableCols returns the number of cells in a table's first row, used
-// to decide whether two adjacent table elements are fragments of one logical
-// table. It returns 0 for an empty table.
-func docxIRTableCols(el docxIRElement) int {
- if len(el.Rows) == 0 {
- return 0
- }
- return len(el.Rows[0].Cells)
-}
-
// docxHeading is one entry of the heading outline stack.
type docxHeading struct {
level int
diff --git a/internal/parser/parser/docx_ir_test.go b/internal/parser/parser/docx_ir_test.go
index 1d1576b0a6..692edd47cd 100644
--- a/internal/parser/parser/docx_ir_test.go
+++ b/internal/parser/parser/docx_ir_test.go
@@ -54,12 +54,126 @@ func TestBuildDOCXJSONSections_EmptyTableSkipped(t *testing.T) {
}
}
-// TestBuildDOCXJSONSections_DifferentColumnTablesStaySeparate pins that
-// two adjacent tables of different column counts are NOT merged: they are
-// genuinely separate tables, not fragments of one.
+// TestBuildDOCXJSONSections_PreservesMergedHeaderSpans pins that office_oxide's
+// row/column spans and repeated-header flags survive the IR -> TableData
+// projection. The header is two rows: row 0 carries the colspan/rowspan
+// geometry, row 1 fills the seven columns the vertical merges leave open, and
+// the body has ten columns.
+func TestBuildDOCXJSONSections_PreservesMergedHeaderSpans(t *testing.T) {
+ cell := func(text string, colSpan, rowSpan int) docxIRCell {
+ return docxIRCell{
+ Content: []docxIRElement{para(text)},
+ ColSpan: colSpan,
+ RowSpan: rowSpan,
+ }
+ }
+ row := func(header bool, cells ...docxIRCell) docxIRRow {
+ return docxIRRow{IsHeader: header, Cells: cells}
+ }
+ ir := docxIRDocument{Sections: []docxIRSection{{
+ Elements: []docxIRElement{
+ {Type: "heading", Level: 1, Content: rawJSON([]docxIRRun{{Type: "text", Text: "H"}})},
+ {Type: "table", Rows: []docxIRRow{
+ row(true,
+ cell("单位名称", 1, 2),
+ cell("集团公司采用", 3, 1),
+ cell("中国中铁采用", 2, 1),
+ cell("国资委采用", 2, 1),
+ cell("得分", 1, 2),
+ cell("排名", 1, 2),
+ ),
+ row(true,
+ cell("政务", 1, 1),
+ cell("政工", 1, 1),
+ cell("得分", 1, 1),
+ cell("数量", 1, 1),
+ cell("得分", 1, 1),
+ cell("数量", 1, 1),
+ cell("得分", 1, 1),
+ ),
+ row(false,
+ cell("西安公司", 1, 1),
+ cell("15(1)", 1, 1),
+ cell("12", 1, 1),
+ cell("265", 1, 1),
+ cell("4(3)", 1, 1),
+ cell("50", 1, 1),
+ cell("2(2)", 1, 1),
+ cell("40", 1, 1),
+ cell("355", 1, 1),
+ cell("1", 1, 1),
+ ),
+ }},
+ },
+ }}}
+ encoded, err := json.Marshal(ir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ got := buildDOCXJSONSections(string(encoded), nil)
+ var tables []map[string]any
+ for _, it := range got {
+ if dt, _ := it["doc_type_kwd"].(string); dt == "table" {
+ tables = append(tables, it)
+ }
+ }
+ if len(tables) != 1 {
+ t.Fatalf("expected 1 table item, got %d: %#v", len(tables), got)
+ }
+ td, ok := tables[0]["table"].(*entity.TableData)
+ if !ok || td == nil {
+ t.Fatalf("table has no TableData: %#v", tables[0])
+ }
+ if td.HeaderRows != 2 {
+ t.Fatalf("HeaderRows = %d, want 2", td.HeaderRows)
+ }
+ if td.Caption != "Table Location: H" {
+ t.Errorf("caption = %q, want %q", td.Caption, "Table Location: H")
+ }
+ if len(td.Grid) != 3 {
+ t.Fatalf("grid rows = %d, want 3", len(td.Grid))
+ }
+ for i, row := range td.Grid {
+ if len(row) != 10 {
+ t.Fatalf("grid row %d width = %d, want 10: %#v", i, len(row), row)
+ }
+ }
+ if got := td.Grid[0][0]; got.Text != "单位名称" || got.RowSpan != 2 {
+ t.Errorf("header cell 0 = %#v, want 单位名称 rowspan 2", got)
+ }
+ if got := td.Grid[0][1]; got.Text != "集团公司采用" || got.ColSpan != 3 {
+ t.Errorf("header cell 1 = %#v, want 集团公司采用 colspan 3", got)
+ }
+ if got := td.Grid[0][4]; got.Text != "中国中铁采用" || got.ColSpan != 2 {
+ t.Errorf("header cell at col 4 = %#v, want 中国中铁采用 colspan 2", got)
+ }
+ if got := td.Grid[0][6]; got.Text != "国资委采用" || got.ColSpan != 2 {
+ t.Errorf("header cell at col 6 = %#v, want 国资委采用 colspan 2", got)
+ }
+ if got := td.Grid[0][8]; got.Text != "得分" || got.RowSpan != 2 {
+ t.Errorf("header cell at col 8 = %#v, want 得分 rowspan 2", got)
+ }
+ if got := td.Grid[0][9]; got.Text != "排名" || got.RowSpan != 2 {
+ t.Errorf("header cell at col 9 = %#v, want 排名 rowspan 2", got)
+ }
+ for _, col := range []int{2, 3, 5, 7} {
+ if got := td.Grid[0][col]; !got.Covered {
+ t.Errorf("row 0 col %d = %#v, want covered by a colspan", col, got)
+ }
+ }
+ if got := td.Grid[1][0]; !got.Covered {
+ t.Errorf("row 1 col 0 = %#v, want covered by the rowspan", got)
+ }
+ if got := td.Grid[1][1]; got.Text != "政务" {
+ t.Errorf("row 1 col 1 = %#v, want 政务", got)
+ }
+}
+
+// TestBuildDOCXJSONSections_DifferentColumnTablesStaySeparate pins that two
+// adjacent tables of different column counts remain separate.
func TestBuildDOCXJSONSections_DifferentColumnTablesStaySeparate(t *testing.T) {
const cell = `{"content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}`
- // a 1-row 3-col table followed by a 2-row 2-col table: different widths.
irJSON := fmt.Sprintf(`{"sections":[{"title":"","elements":[
{"type":"table","rows":[{"cells":[%s,%s,%s]}]},
{"type":"table","rows":[{"cells":[%s,%s]},{"cells":[%s,%s]}]}
@@ -76,15 +190,11 @@ func TestBuildDOCXJSONSections_DifferentColumnTablesStaySeparate(t *testing.T) {
}
}
-// TestBuildDOCXJSONSections_AdjacentMultiRowTablesStaySeparate is the key
-// regression guard for the 1-row merge gate: two genuinely separate multi-row
-// tables of the SAME width, stacked with only a blank paragraph between them
-// (so they are adjacent in the IR), must NOT be merged. Only a 1-row header
-// fragment is folded into the following table.
-func TestBuildDOCXJSONSections_AdjacentMultiRowTablesStaySeparate(t *testing.T) {
+// TestBuildDOCXJSONSections_AdjacentTablesStaySeparate pins that adjacent
+// tables remain separate items. The parser no longer merges table elements;
+// office_oxide now emits one element per logical table.
+func TestBuildDOCXJSONSections_AdjacentTablesStaySeparate(t *testing.T) {
const cell = `{"content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}`
- // table A: 2 rows x 3 cols; table B: 2 rows x 3 cols — same width, both
- // multi-row, no element between. These are two distinct tables.
irJSON := fmt.Sprintf(`{"sections":[{"title":"","elements":[
{"type":"table","rows":[{"cells":[%s,%s,%s]},{"cells":[%s,%s,%s]}]},
{"type":"table","rows":[{"cells":[%s,%s,%s]},{"cells":[%s,%s,%s]}]}
@@ -97,7 +207,7 @@ func TestBuildDOCXJSONSections_AdjacentMultiRowTablesStaySeparate(t *testing.T)
}
}
if len(tables) != 2 {
- t.Fatalf("expected 2 separate multi-row tables, got %d: %#v", len(tables), got)
+ t.Fatalf("expected 2 separate tables, got %d: %#v", len(tables), got)
}
for i, tb := range tables {
td, ok := tb["table"].(*entity.TableData)
@@ -110,29 +220,6 @@ func TestBuildDOCXJSONSections_AdjacentMultiRowTablesStaySeparate(t *testing.T)
}
}
-// TestBuildDOCXJSONSections_HeaderFragmentWithParagraphBetweenStaysSplit pins
-// that a 1-row header fragment is only merged when the body is the VERY NEXT
-// element. A paragraph (even blank) between them keeps them as distinct
-// tables, so the gate never reaches across intervening content.
-func TestBuildDOCXJSONSections_HeaderFragmentWithParagraphBetweenStaysSplit(t *testing.T) {
- const cell = `{"content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}`
- irJSON := fmt.Sprintf(`{"sections":[{"title":"","elements":[
- {"type":"table","rows":[{"cells":[%s,%s,%s]}]},
- {"type":"paragraph","content":[{"type":"text","text":"note"}]},
- {"type":"table","rows":[{"cells":[%s,%s,%s]},{"cells":[%s,%s,%s]}]}
- ]}]}`, cell, cell, cell, cell, cell, cell, cell, cell, cell)
- got := buildDOCXJSONSections(irJSON, nil)
- var n int
- for _, it := range got {
- if dt, _ := it["doc_type_kwd"].(string); dt == "table" {
- n++
- }
- }
- if n != 2 {
- t.Fatalf("expected 2 separate tables (paragraph between), got %d: %#v", n, got)
- }
-}
-
func TestBuildDOCXJSONSections_PreservesInlineImageOrder(t *testing.T) {
imageData := officeTestPNG(t, 1)
irJSON := `{"sections":[{"elements":[
diff --git a/internal/parser/parser/table_structure_test.go b/internal/parser/parser/table_structure_test.go
index bc95630196..77dd4e4150 100644
--- a/internal/parser/parser/table_structure_test.go
+++ b/internal/parser/parser/table_structure_test.go
@@ -252,13 +252,25 @@ func TestPPTXTableOnlySlideIsNotEmpty(t *testing.T) {
}
}
-func TestOfficeTableRetainsDefaultHeaderCount(t *testing.T) {
+func TestOfficeTableUsesRepeatedHeaderFlags(t *testing.T) {
ir := `{"sections":[{"elements":[{"type":"table","rows":[{"is_header":true,"cells":[{"content":[{"type":"paragraph","content":[{"type":"text","text":"H1"}]}]}]},{"is_header":true,"cells":[{"content":[{"type":"paragraph","content":[{"type":"text","text":"H2"}]}]}]}]}]}]}`
items := buildDOCXJSONSections(ir, nil)
if len(items) != 1 {
t.Fatalf("items=%#v", items)
}
td := items[0]["table"].(*entity.TableData)
+ if td.HeaderRows != 2 || len(td.Grid) != 2 {
+ t.Fatalf("table=%+v, want two rows and two repeated header rows", td)
+ }
+}
+
+func TestOfficeTableDefaultsToOneHeaderWithoutFlags(t *testing.T) {
+ ir := `{"sections":[{"elements":[{"type":"table","rows":[{"cells":[{"content":[{"type":"paragraph","content":[{"type":"text","text":"H1"}]}]}]},{"cells":[{"content":[{"type":"paragraph","content":[{"type":"text","text":"H2"}]}]}]}]}]}]}`
+ items := buildDOCXJSONSections(ir, nil)
+ if len(items) != 1 {
+ t.Fatalf("items=%#v", items)
+ }
+ td := items[0]["table"].(*entity.TableData)
if td.HeaderRows != 1 || len(td.Grid) != 2 {
t.Fatalf("table=%+v, want two rows and one default header", td)
}