Files
ragflow/internal/deepdoc/parser/pdf/table/table_cells.go

620 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package table
import (
"log/slog"
"math"
pdf "ragflow/internal/deepdoc/parser/pdf/type"
"ragflow/internal/deepdoc/parser/pdf/util"
"regexp"
"sort"
"strings"
)
// ── TSR cell grouping ──────────────────────────────────────────────────
// GroupTSRCellsToRows groups TSR cells into rows by Y proximity.
// This is the basic fallback grouping used when model-specific grouping
// (e.g. EE label-aware grouping) is not applicable.
func GroupTSRCellsToRows(cells []pdf.TSRCell) [][]pdf.TSRCell {
if len(cells) == 0 {
return nil
}
if len(cells) == 1 {
return [][]pdf.TSRCell{{cells[0]}}
}
heights := make([]float64, len(cells))
for i, c := range cells {
heights[i] = c.Y1 - c.Y0
}
sort.Float64s(heights)
medianH := heights[len(heights)/2]
if medianH <= 0 {
medianH = 10
}
rowThreshold := medianH * 0.5
sort.Slice(cells, func(i, j int) bool {
if math.Abs(cells[i].Y0-cells[j].Y0) < rowThreshold {
return cells[i].X0 < cells[j].X0
}
return cells[i].Y0 < cells[j].Y0
})
var rows [][]pdf.TSRCell
var curRow []pdf.TSRCell
curY := 0.0
for _, c := range cells {
if len(curRow) == 0 {
curRow = append(curRow, c)
curY = c.Y0
continue
}
if c.Y0-curY > rowThreshold {
rows = append(rows, curRow)
curRow = []pdf.TSRCell{c}
curY = c.Y0
} else {
curRow = append(curRow, c)
}
}
if len(curRow) > 0 {
rows = append(rows, curRow)
}
for _, row := range rows {
sort.Slice(row, func(i, j int) bool { return row[i].X0 < row[j].X0 })
}
return rows
}
// ── cell text filling ──────────────────────────────────────────────────
// FillCellTextFromBoxes assigns PDF text boxes to TSR grid cells, mirroring
// Python's construct_table box→cell assignment (pdf_parser.py +
// table_structure_recognizer.py):
//
// 1. For each box, pick the single BEST row by vertical-overlap ratio
// inter(box,rowStrip)/area(box) >= 0.3, tie-broken by inter/rowArea
// (Python find_overlapped_with_threshold on the full-width row strip).
// 2. Within that row, pick the TIGHTEST column by horizontal edge/center
// distance, requiring vertical overlap (Python find_horizontally_tightest_fit,
// NO threshold). The box lands in exactly ONE cell (R,C).
// 3. Multiple boxes mapped to the same (R,C) are concatenated (Python joins
// them in construct_table).
//
// This replaces the old many-to-many 2-D cell-overlap filter
// (inter(box,cell)/area(box) >= 0.3 on every cross-product cell), which
// duplicated a straddling box into two cells (#1), dropped boxes whose 2-D
// cell overlap was < 0.3 even though their row-vertical overlap was >= 0.3
// (#2), and never applied the inter/cellArea tie-break (#3). All three are
// go_bug in testdata/parity/known_diffs.json.
//
// The Go-only 0.85 guard (BoxMatchesCell) is retained for PRE-FILLED cells
// only: if a cell already carries text (e.g. per-cell OCR in the rotated
// path), a detected box overrides it only when it sits almost entirely inside
// the cell (>= 0.85). Empty cells accept any box the row/column selection
// picked, matching Python. See go_intentional rule
// table-cell-fill-filled-threshold-0.85.
func FillCellTextFromBoxes(cells []pdf.TSRCell, boxes []pdf.TextBox) {
FillCellTextFromBoxesWithRows(cells, boxes, nil)
}
// FillCellTextFromBoxesWithRows is FillCellTextFromBoxes with the per-row
// strip X range taken from the original TSR "table row" component bboxes
// instead of the grid column union.
//
// Python matches a box to a row via find_overlapped_with_threshold(box,
// rows) where rows are the raw TSR row components (gather(".* (row|header)")
// in pdf_parser.py:602) and overlapped_area divides by the row's own bbox.
// Go's grid is a TSR-row × column cross-product; its cells always span the
// full column union, so the old strip X range was wider than the row
// component's true bbox. When TSR emits a row whose line does not cover a
// table edge (e.g. 13_crosspage_table.pdf page 2 row 44: x0=106.9 vs the
// grid's x0=90.8), a col-0 box straddling that row and the one above it
// can pass the 0.3 threshold against the full-width strip while Python
// rejects it against the true bbox — so Go assigned the box one row lower
// than Python.
//
// rowStrips are the TSR "table row" components (data rows only — header /
// projrowheader components are excluded by the caller). Each is matched to a
// grid row band by Y coordinate via matchRowStrip, so the override applies to
// every data row regardless of whether the table has a header. Rows with no
// matching TSR row component (e.g. header rows) keep the grid-union strip X.
func FillCellTextFromBoxesWithRows(cells []pdf.TSRCell, boxes []pdf.TextBox, rowStrips []pdf.TSRCell) {
slog.Debug("fillCellTextFromBoxes", "cells", len(cells), "boxes", len(boxes))
if len(cells) == 0 || len(boxes) == 0 {
return
}
// Group cells into row bands by their top coordinate. The grid is a TSR
// row×column cross-product, so every cell in a row shares the same Y band.
// A row band spans the full table width (union of its cells), matching
// Python's full-width "table row" components.
type rowBand struct {
y0, y1 float64
stripX0 float64
stripX1 float64
cells []int // indices into `cells`
}
var rows []rowBand
// Group cells into the same row band by exact top coordinate. A TSR
// cross-product grid (GroupCells) assigns every cell in a row the SAME
// Y0 value, and distinct rows differ by at least a row height, so a tiny
// epsilon is enough and never merges two real rows.
const yTol = 1e-6
for i := range cells {
c := &cells[i]
if c.X1 <= c.X0 || c.Y1 <= c.Y0 {
continue // degenerate / span-covered cell: not a fill target
}
rb := &rowBand{}
found := false
for ri := range rows {
if math.Abs(rows[ri].y0-c.Y0) <= yTol {
rb = &rows[ri]
found = true
break
}
}
if !found {
rows = append(rows, rowBand{
y0: c.Y0, y1: c.Y1, stripX0: c.X0, stripX1: c.X1,
})
rb = &rows[len(rows)-1]
}
if c.X0 < rb.stripX0 {
rb.stripX0 = c.X0
}
if c.X1 > rb.stripX1 {
rb.stripX1 = c.X1
}
if c.Y1 > rb.y1 {
rb.y1 = c.Y1
}
rb.cells = append(rb.cells, i)
}
// Stable ordering: rows top-to-bottom, cells left-to-right (matches
// Python's first-wins tie-breaking in find_overlapped_with_threshold /
// find_horizontally_tightest_fit).
sort.Slice(rows, func(i, j int) bool { return rows[i].y0 < rows[j].y0 })
for ri := range rows {
sort.Slice(rows[ri].cells, func(a, b int) bool {
return cells[rows[ri].cells[a]].X0 < cells[rows[ri].cells[b]].X0
})
}
// Replace each grid row's strip X with the matching TSR "table row"
// component's own bbox X (see FillCellTextFromBoxesWithRows doc). Matching
// is by Y band, not positional index, so the override applies to every
// data row independently of whether the table has a header: header /
// projrowheader components are simply absent from rowStrips and fall back
// to the grid-union strip X, which is correct for header rows. This
// replaces the former all-or-nothing `len(rowStrips) == len(rows)` guard,
// which silently disabled the override for any table that had a header.
for ri := range rows {
if sx0, sx1, ok := matchRowStrip(rowStrips, rows[ri].y0); ok {
rows[ri].stripX0 = sx0
rows[ri].stripX1 = sx1
}
}
// Accumulate box text per target cell so multiple boxes in one cell join.
cellText := make([]string, len(cells))
cellFilled := make([]bool, len(cells))
matched := 0
for bi := range boxes {
b := boxes[bi]
if IsCaptionBox(b.Text, b.LayoutType) {
continue
}
boxArea := util.Area(&b)
if boxArea <= 0 {
continue
}
// 1. Best row by vertical-overlap ratio (>= 0.3), tie-broken by _ov.
bestR := -1
bestOv, bestOv2 := 0.3, 0.0
for ri := range rows {
rb := &rows[ri]
if math.Min(b.Bottom, rb.y1)-math.Max(b.Top, rb.y0) <= 0 {
continue // no vertical overlap
}
strip := pdf.TSRCell{X0: rb.stripX0, Y0: rb.y0, X1: rb.stripX1, Y1: rb.y1}
inter := util.OverlapInter(&strip, &b)
ov := inter / boxArea
ov2 := 0.0
if a := util.Area(&strip); a > 0 {
ov2 = inter / a
}
// Skip unless strictly better than the current best, mirroring
// Python's (ov, _ov) tuple ordering in find_overlapped_with_threshold.
if !(ov > bestOv || (ov == bestOv && ov2 > bestOv2)) {
continue
}
bestR, bestOv, bestOv2 = ri, ov, ov2
}
if bestR < 0 {
continue
}
// 2. Tightest column within the matched row (no threshold).
rb := &rows[bestR]
bestC := -1
bestDis := 1e9
for _, ci := range rb.cells {
c := &cells[ci]
if math.Min(b.Bottom, c.Y1)-math.Max(b.Top, c.Y0) <= 0 {
continue
}
if dis := tightestColumnDistance(&b, c); dis < bestDis {
bestDis, bestC = dis, ci
}
}
if bestC < 0 {
continue
}
// 3. Assign, preserving the 0.85 guard for pre-filled cells only.
target := &cells[bestC]
if target.Text != "" && !BoxMatchesCell(*target, b, false) {
continue
}
t := strings.TrimSpace(b.Text)
if t == "" {
continue
}
if cellFilled[bestC] {
cellText[bestC] += " " + t
} else {
cellText[bestC] = t
cellFilled[bestC] = true
}
matched++
}
for i := range cells {
if cellFilled[i] {
cells[i].Text = cellText[i]
}
}
slog.Debug("fillCellTextFromBoxes done", "box_cell_matches", matched, "cells_filled", matched)
}
// tightestColumnDistance mirrors Python's find_horizontally_tightest_fit
// distance metric: the minimum of the left-edge gap, right-edge gap, and
// half the center gap. Smaller means the box sits tighter against the cell.
func tightestColumnDistance(b *pdf.TextBox, c *pdf.TSRCell) float64 {
dis := math.Min(math.Abs(b.X0-c.X0), math.Abs(b.X1-c.X1))
if center := math.Abs((b.X0+b.X1)-(c.X0+c.X1)) / 2; center < dis {
dis = center
}
return dis
}
// BoxMatchesCell reports whether a text box's text may be assigned to a
// TSR cell. The threshold is two-stage:
// - empty cell: inter/boxArea >= 0.3 — matches Python's
// find_overlapped_with_threshold default (thr=0.3), which fills cells from
// overlapping PDF boxes uniformly.
// - cell already has text: inter/boxArea >= 0.85 — Go-only guard, NOT in
// Python. In the rotated-table path (table_extract.go) ocrTableCells
// pre-fills cells with per-cell OCR text; the 0.85 bar stops a
// weakly-overlapping detected box from corrupting/overriding that OCR
// result. Python has no per-cell OCR at this stage, so it never raises the
// threshold. This is a deliberate go_intentional divergence: it can drop a
// legitimate secondary text fragment (a box overlapping 30-85%) that
// Python would keep.
//
// FillCellTextFromBoxes uses only the 0.85 branch (cellIsEmpty=false) as the
// guard for PRE-FILLED cells; for empty cells it relies on the row/column
// selection (which already enforces >= 0.3 vertical overlap, matching Python).
// BoxMatchesCell remains the canonical "does this box match this exact cell"
// primitive and is directly unit-tested.
func BoxMatchesCell(cell pdf.TSRCell, box pdf.TextBox, cellIsEmpty bool) bool {
inter := util.OverlapInter(&cell, &box)
boxArea := util.Area(&box)
if boxArea <= 0 {
return false
}
if cellIsEmpty {
return inter/boxArea >= 0.3 // Python's find_overlapped_with_threshold default
}
return inter/boxArea >= 0.85
}
// matchRowStrip finds the TSR "table row" component whose Y0 matches the grid
// row band y0 within yTol, returning its X range. A header / projrowheader
// component (not collected into rowStrips by the caller) returns ok=false, so
// that row keeps the grid-union strip X — correct for header rows. Matching by
// Y (not positional index) lets the strip-X override apply to every data row
// even when the table also has a header, instead of the former
// all-or-nothing `len(rowStrips) == len(rows)` guard that silently disabled
// the override for any header-bearing table.
func matchRowStrip(rowStrips []pdf.TSRCell, y0 float64) (float64, float64, bool) {
const yTol = 1e-6
for _, rs := range rowStrips {
if math.Abs(rs.Y0-y0) <= yTol {
return rs.X0, rs.X1, true
}
}
return 0, 0, false
}
// isCaptionBox checks if a text box is a table/figure caption,
// matching Python is_caption(). Captions should not enter table cells.
var reCaption = regexp.MustCompile(`^[图表]+[ 0-9:]{2,}|(?i)Fig\.?\s*\d+|(?i)Figure\s+\d+|(?i)Table\s+\d+`)
func IsCaptionBox(text string, layoutType string) bool {
if strings.Contains(layoutType, "caption") {
return true
}
return reCaption.MatchString(strings.TrimSpace(text))
}
// reTableCaptionText matches text patterns that indicate a table caption
// (as opposed to a figure caption). Python is_caption uses the same set.
var reTableCaptionText = regexp.MustCompile(`^表|(?i)Table\s+\d+`)
// reFigureCaptionText matches text patterns that indicate a figure caption.
var reFigureCaptionText = regexp.MustCompile(`^图|(?i)Fig\.?\s*\d+|(?i)Figure\s+\d+`)
// captionKind returns "table" if the section is a table caption,
// "figure" if a figure caption, or "" if not a caption.
// Matches Python's is_caption check: text patterns OR layout_type containing "caption".
func CaptionKind(s pdf.Section) string {
lt := s.LayoutType
if lt == pdf.DLALabelTableCaption || (strings.Contains(lt, "caption") && reTableCaptionText.MatchString(strings.TrimSpace(s.Text))) {
return pdf.LayoutTypeTable
}
if lt == pdf.DLALabelFigureCaption || strings.Contains(lt, "caption") {
return pdf.LayoutTypeFigure
}
// DLA may label captions as "text" or other types — check text patterns.
t := strings.TrimSpace(s.Text)
if reTableCaptionText.MatchString(t) {
return pdf.LayoutTypeTable
}
if reFigureCaptionText.MatchString(t) {
return pdf.LayoutTypeFigure
}
// The chart/figure pattern is ambiguous (matches both) — fall back to isCaptionBox.
if IsCaptionBox(t, "") {
return pdf.LayoutTypeTable
}
return ""
}
// ── blockType: cell content classification (Python: TableStructureRecognizer.blockType) ──
// Compiled once at package init.
var blockTypePatterns = []struct {
re *regexp.Regexp
kind string
}{
// Dt (date) patterns — Python blockType lines 161-168.
{regexp.MustCompile(`^(20|19)[0-9]{2}[年/-][0-9]{1,2}[月/-][0-9]{1,2}日*$`), "Dt"},
{regexp.MustCompile(`^(20|19)[0-9]{2}年$`), "Dt"},
{regexp.MustCompile(`^(20|19)[0-9]{2}[年-][0-9]{1,2}月*$`), "Dt"},
{regexp.MustCompile(`^[0-9]{1,2}[月-][0-9]{1,2}日*$`), "Dt"},
{regexp.MustCompile(`^第*[一二三四1-4]季度$`), "Dt"},
{regexp.MustCompile(`^(20|19)[0-9]{2}年*[一二三四1-4]季度$`), "Dt"},
{regexp.MustCompile(`^(20|19)[0-9]{2}[ABCDE]$`), "Dt"},
// Nu (numeric) — Python blockType line 169.
{regexp.MustCompile(`^[0-9.,+%/ -]+$`), "Nu"},
// Ca (categorical) — Python blockType line 170.
{regexp.MustCompile(`^[0-9A-Z/\._~-]+$`), "Ca"},
// En (English) — Python blockType line 171.
{regexp.MustCompile(`^[A-Z]*[a-z' -]+$`), "En"},
// NE (named entity — mixed alphanumeric) — Python blockType line 172.
{regexp.MustCompile(`^[0-9.,+-]+[0-9A-Za-z/$¥%<>()' -]+$`), "NE"},
// Sg (single character) — Python blockType line 173.
{regexp.MustCompile(`^.{1}$`), "Sg"},
}
// blockType classifies cell text into one of 9+1 types, matching Python's
// TableStructureRecognizer.blockType. Types: Dt (date), Nu (numeric),
// Ca (categorical), En (English), NE (named entity), Sg (single char),
// Tx (short text), Lx (long text), Nr (person name), Ot (other).
func BlockType(text string) string {
t := strings.TrimSpace(text)
for _, p := range blockTypePatterns {
if p.re.MatchString(t) {
return p.kind
}
}
// Token-based classification: >3 tokens, <12 → Tx, >=12 → Lx.
// Uses simple token counting (whitespace split + individual CJK chars).
tkn := simpleTokenCount(t)
if tkn > 3 {
if tkn < 12 {
return "Tx"
}
return "Lx"
}
// Single token with POS tag "nr" → "Nr" (requires tokenizer — not available).
// Default: "Ot" (other).
return "Ot"
}
// simpleTokenCount estimates token count: splits on whitespace and counts
// CJK characters individually (each CJK char ≈ one token in Chinese).
func simpleTokenCount(text string) int {
count := 0
for _, r := range text {
if pdf.IsCJK(r) {
count++
} else if r == ' ' || r == '\t' {
// whitespace tokenizes boundaries already counted via words
}
}
// Also count space-separated words.
words := strings.FieldsSeq(text)
for w := range words {
if !containsCJK(w) {
count++
}
}
return count
}
func containsCJK(s string) bool {
for _, r := range s {
if pdf.IsCJK(r) {
return true
}
}
return false
}
// HeaderSetWithBlockType returns the set of rows that are header rows, matching
// Python's construct_table header detection (table_structure_recognizer.py:336-348).
// Python scores every row independently (no early stop) and, per column, counts
// the cell toward the header when it has a geometric H OR (for numeric-dominant
// tables) the cell is non-numeric; a numeric cell in a numeric-dominant table is
// skipped entirely (it neither helps nor hurts).
//
// Go folds Python's geometric (box.H>0) and blockType signals into ONE per-cell
// pass with that exact predicate, so a row is a header when more than half of its
// columns (including skipped numeric ones, matching Python's h/cnt) satisfy it.
// box.H is set by AnnotateTableBoxes against the header-labeled cells (Python
// t_recognizer.py: gather(r".*header$")), not grid[0].
//
// A THIRD, additive signal is Go-only: a TSR cell whose Label contains "header"
// (Python has no exact equivalent) also promotes a row via the same >0.5
// row-majority. A row is a header if ANY signal flags it.
//
// boxes may be nil (e.g. the test-only cell-grouping path); the geometric signal
// is then skipped and only blockType + label are consulted.
func HeaderSetWithBlockType(rows [][]pdf.TSRCell, boxes []pdf.TextBox) map[int]bool {
// Compute dominant block type across all cells (Python: max_type, derived
// from box btype with first-seen tie-breaking). Track first-seen order so
// the tie rule matches Python's Counter+max (first max wins on ties).
typeCounts := make(map[string]int)
order := []string{}
seen := make(map[string]bool)
for _, row := range rows {
for _, cell := range row {
if t := strings.TrimSpace(cell.Text); t != "" {
bt := BlockType(t)
if !seen[bt] {
seen[bt] = true
order = append(order, bt)
}
typeCounts[bt]++
}
}
}
maxType := ""
maxCount := -1
for _, t := range order {
if typeCounts[t] > maxCount {
maxType, maxCount = t, typeCounts[t]
}
}
// Geometric H per cell, from boxes overlapping the header region (box.H > 0).
// colHit[row][col] = true when that column's cell has such a box.
//
// The boxes' R/C may have been assigned against the PRE-cleanup grid while
// `rows` here is POST-cleanup (CleanupOrphanColumns/Rows in ConstructTable
// removes empty rows/columns). So we re-derive (row, column) from geometry
// instead of trusting the stale R/C — matching Python, which keys the
// geometric H off the box itself and never re-indexes by a stale coordinate.
colHit := make(map[int]map[int]bool)
for i := range boxes {
b := boxes[i]
if b.H <= 0 {
continue
}
bestRi, bestCi, bestOv := -1, -1, 0.0
for ri, row := range rows {
for ci, cell := range row {
if tsrBoxOverlap(b, cell) {
continue // no overlap with this cell
}
if ov := util.OverlapInter(&b, &cell); ov > bestOv {
bestOv, bestRi, bestCi = ov, ri, ci
}
}
}
if bestRi >= 0 {
// Geometry resolved the cell against the (post-cleanup) rows.
if colHit[bestRi] == nil {
colHit[bestRi] = make(map[int]bool)
}
colHit[bestRi][bestCi] = true
} else if b.R >= 0 && b.R < len(rows) && b.C >= 0 && b.C < len(rows[b.R]) {
// No geometric overlap (e.g. boxes supplied without coordinates):
// fall back to the box's own R/C when it is in range, so callers
// that pre-assigned a correct R/C still work.
if colHit[b.R] == nil {
colHit[b.R] = make(map[int]bool)
}
colHit[b.R][b.C] = true
}
}
hdrs := make(map[int]bool)
// Signals 1+2 folded (Python: construct_table, 336-348). Numeric-dominant
// table: a numeric cell is skipped (continue); otherwise the cell counts when
// it has H OR is non-numeric. Non-numeric table: the predicate reduces to
// any(H) — the geometric signal alone.
if maxType == "Nu" {
for ri, row := range rows {
cnt, h := 0, 0
for ci, cell := range row {
t := strings.TrimSpace(cell.Text)
if t == "" {
continue
}
cnt++
bt := BlockType(t)
if bt == "Nu" {
continue // numeric cell in a numeric table: ignored
}
if colHit[ri][ci] || bt != "Nu" {
h++
}
}
if cnt > 0 && float64(h)/float64(cnt) > 0.5 {
hdrs[ri] = true
}
}
} else {
for ri, row := range rows {
cnt, h := 0, 0
for ci, cell := range row {
if strings.TrimSpace(cell.Text) == "" {
continue
}
cnt++
if colHit[ri][ci] {
h++
}
}
if cnt > 0 && float64(h)/float64(cnt) > 0.5 {
hdrs[ri] = true
}
}
}
// Signal 3: TSR label "header" (additive Go-only fallback; Python has no
// exact equivalent), with the same >0.5 row-majority.
for ri, row := range rows {
cnt, h := 0, 0
for _, cell := range row {
t := strings.TrimSpace(cell.Text)
if t == "" {
continue
}
cnt++
if isHeaderLabel(cell.Label) {
h++
}
}
if cnt > 0 && float64(h)/float64(cnt) > 0.5 {
hdrs[ri] = true
}
}
return hdrs
}