package parser import ( "context" "image" "regexp" "sort" ) // SaaS model label taxonomies. // DLA: 10 classes with duplicates (matching SaaS Docker TSR endpoint). var saasDLALabels = []string{ LayoutTypeTitle, LayoutTypeText, LayoutTypeReference, LayoutTypeFigure, DLALabelFigureCaption, LayoutTypeTable, DLALabelTableCaption, DLALabelTableCaption, LayoutTypeEquation, DLALabelFigureCaption, } // TSR: 2-class separator lines (v=vertical, h=horizontal). var saasTSRLabels = []string{"v", "h"} // DeepDoc label regexes — compiled once at package init. // These match the TSR label taxonomy returned by the Python DeepDoc // table structure recognition service. var ( reHeader = regexp.MustCompile(`.*header$`) reRowHdr = regexp.MustCompile(`table$|.* (row|header)`) // "table$" catches the default TSR label "table" (class 0), matching // Python's behavior which uses all cells regardless of label. reSpan = regexp.MustCompile(`.*spanning`) reColumn = regexp.MustCompile(`table column$`) ) // gatherTSR filters cells by label regex pattern. func gatherTSR(cells []TSRCell, re *regexp.Regexp) []TSRCell { var result []TSRCell for _, c := range cells { if re.MatchString(c.Label) { result = append(result, c) } } return result } // SaasDeepDocService implements TableBuilder and DocAnalyzer using the // Python DeepDoc TSR service. type SaasDeepDocService struct { doc DocAnalyzer } // NewSaasDeepDocService creates a service backed by the SaaS DeepDoc service. // If doc is a *DeepDocClient, its DLALabels/TSRLabels are set to the SaaS // taxonomy. func NewSaasDeepDocService(doc DocAnalyzer) *SaasDeepDocService { if c, ok := doc.(*DeepDocClient); ok { c.DLALabels = saasDLALabels c.TSRLabels = saasTSRLabels } return &SaasDeepDocService{doc: doc} } func (b *SaasDeepDocService) Name() string { return "deepdoc" } func (b *SaasDeepDocService) DetectCells(ctx context.Context, cropped image.Image) ([]TSRCell, error) { return b.doc.TSR(ctx, cropped) } func (b *SaasDeepDocService) GroupCells(cells []TSRCell) [][]TSRCell { return groupTSRCellsToRowsLabeled(cells) } // groupTSRCellsToRowsLabeled groups TSR cells into rows using labels // (header, row, spanning) instead of just Y proximity. Matching Python's // gather-based approach. func groupTSRCellsToRowsLabeled(cells []TSRCell) [][]TSRCell { rows := gatherTSR(cells, reRowHdr) spans := gatherTSR(cells, reSpan) clmns := gatherTSR(cells, reColumn) if len(rows) == 0 && len(spans) == 0 { return groupTSRCellsToRows(cells) } sortYFirstly(rows, 10) sortXFirstly(clmns, 10) var grouped [][]TSRCell var curRow []TSRCell curY := 0.0 rowThreshold := 0.0 if len(rows) > 0 { heights := make([]float64, len(rows)) for i, r := range rows { heights[i] = r.Y1 - r.Y0 } sort.Float64s(heights) rowThreshold = heights[len(heights)/2] * 0.5 if rowThreshold <= 0 { rowThreshold = 10 } } for _, c := range rows { if len(curRow) == 0 { curRow = append(curRow, c) curY = c.Y0 continue } if c.Y0-curY > rowThreshold { grouped = append(grouped, curRow) curRow = []TSRCell{c} curY = c.Y0 } else { curRow = append(curRow, c) } } if len(curRow) > 0 { grouped = append(grouped, curRow) } for _, s := range spans { for ri, row := range grouped { if len(row) > 0 && s.Y0 <= row[0].Y1 && s.Y1 >= row[0].Y0 { grouped[ri] = append(grouped[ri], s) break } } } for _, row := range grouped { sortXFirstly(row, 10) } maxCols := 0 for _, row := range grouped { if len(row) > maxCols { maxCols = len(row) } } for i := range grouped { if len(grouped[i]) == 0 { continue // no real cells → cannot derive valid coordinates for padding } for len(grouped[i]) < maxCols { lastX := grouped[i][len(grouped[i])-1].X1 + 10 rowY0 := grouped[i][0].Y0 rowY1 := grouped[i][0].Y1 grouped[i] = append(grouped[i], TSRCell{X0: lastX, X1: lastX + 1, Y0: rowY0, Y1: rowY1}) } } return grouped }