package tool import ( "encoding/csv" "encoding/json" "fmt" "math" "os" "path/filepath" "sort" "strconv" "strings" "time" "github.com/xuri/excelize/v2" "golang.org/x/text/unicode/norm" ) // Diff stores per-PDF comparison metrics between Go and Python output. type Diff struct { File string PagesOk bool BoxesInitDiffPct float64 BoxesTMDiffPct float64 BoxesVMDiffPct float64 SectionsDiffPct float64 TextLenDiffPct float64 CharsDiffPct float64 TablesDiff int CharSim float64 LcsSim float64 RawCharSim float64 // CharSim without NFKC normalization RawLcsSim float64 // LcsSim without space stripping } // CompareWithPython compares Go results against Python reference. func CompareWithPython(log TLogger, goResults []BatchResult, pyResults []PyResult, goTextDir, pyTextDir string) { pyMap := make(map[string]PyResult, len(pyResults)) for _, pr := range pyResults { pyMap[pr.File] = pr } goMap := make(map[string]BatchResult, len(goResults)) for _, r := range goResults { goMap[r.File] = r } var diffs []Diff matched, mismatched := 0, 0 for _, r := range goResults { py, ok := pyMap[r.File] if !ok { continue } d := Diff{File: r.File, TablesDiff: r.TSTables - py.Tables} if py.Pages > 0 { d.PagesOk = r.Pages == py.Pages if r.Pages == py.Pages { matched++ } else { mismatched++ } } if py.BoxesInitial > 0 { d.BoxesInitDiffPct = math.Abs(float64(r.BoxesInitial-py.BoxesInitial)) / float64(py.BoxesInitial) * 100 } if py.BoxesTextMerge > 0 { d.BoxesTMDiffPct = math.Abs(float64(r.BoxesTextMerg-py.BoxesTextMerge)) / float64(py.BoxesTextMerge) * 100 } if py.BoxesVertMerge > 0 { d.BoxesVMDiffPct = math.Abs(float64(r.BoxesVertMerg-py.BoxesVertMerge)) / float64(py.BoxesVertMerge) * 100 } if py.Sections > 0 { d.SectionsDiffPct = math.Abs(float64(r.Sections-py.Sections)) / float64(py.Sections) * 100 } if py.TextLen > 0 { d.TextLenDiffPct = math.Abs(float64(r.TextLen-py.TextLen)) / float64(py.TextLen) * 100 } if py.Chars > 0 { d.CharsDiffPct = math.Abs(float64(r.Chars-py.Chars)) / float64(py.Chars) * 100 } goTextPath := filepath.Join(goTextDir, r.File+".txt") pyTextPath := filepath.Join(pyTextDir, r.File+".txt") if goTxt, err := os.ReadFile(goTextPath); err == nil { if pyTxt, err := os.ReadFile(pyTextPath); err == nil { goStr, pyStr := string(goTxt), string(pyTxt) // NFKC normalisation: fullwidth→halfwidth (e.g. ",(" → ",(") goStr = norm.NFKC.String(goStr) pyStr = norm.NFKC.String(pyStr) d.CharSim = CharSimilarity(goStr, pyStr) // Section-level LCS: align sections by position window, // compute per-section LCS, bidirectional F1. d.LcsSim = SectionAlignedScore(goStr, pyStr) // Raw metrics without NFKC / space stripping. d.RawCharSim = RawCharSimilarity(string(goTxt), string(pyTxt)) d.RawLcsSim = SectionAlignedScore(string(goTxt), string(pyTxt)) } } diffs = append(diffs, d) log.Logf(" [%d/%d] %s CharDiff=D%.1f%% LcsDiff=D%.1f%% RawCharDiff=D%.1f%% RawLcsDiff=D%.1f%%", len(diffs), len(goResults), r.File, 100-d.CharSim, 100-d.LcsSim, 100-d.RawCharSim, 100-d.RawLcsSim) } sort.Slice(diffs, func(i, j int) bool { return diffs[i].SectionsDiffPct < diffs[j].SectionsDiffPct }) log.Logf("\n=== Go vs Python (%d PDFs) ===", len(diffs)) log.Logf("Pages match: %d/%d", matched, matched+mismatched) log.Logf("%-40s %-18s %-18s %s %s %s %s %s %s %s %s %s %s", "file", "Go:init->tm->vm->sec", "Py:init->tm->vm->sec", "Init%", "TM%", "VM%", "Sec%", "Txt%", "TabD", "CharDiff%", "LcsDiff%", "RawCharDiff%", "RawLcsDiff%") log.Logf("%s", strings.Repeat("-", 168)) for _, d := range diffs { py := pyMap[d.File] gr := goMap[d.File] goStages := fmt.Sprintf("%3d->%3d->%3d->%3d", gr.BoxesInitial, gr.BoxesTextMerg, gr.BoxesVertMerg, gr.Sections) pyStages := fmt.Sprintf("%3d->%3d->%3d->%3d", py.BoxesInitial, py.BoxesTextMerge, py.BoxesVertMerge, py.Sections) log.Logf("%-40s %-18s %-18s %4.0f%% %4.0f%% %4.0f%% %4.0f%% %4.0f%% %+4d %.0f%% %.0f%% %.0f%% %.0f%%", d.File, goStages, pyStages, d.BoxesInitDiffPct, d.BoxesTMDiffPct, d.BoxesVMDiffPct, d.SectionsDiffPct, d.TextLenDiffPct, d.TablesDiff, 100-d.CharSim, 100-d.LcsSim, 100-d.RawCharSim, 100-d.RawLcsSim) } n := len(diffs) if n == 0 { return } type stats struct { median, mean, max, min float64 over5, over10 int } computeStats := func(get func(Diff) float64) stats { sort.Slice(diffs, func(i, j int) bool { return get(diffs[i]) < get(diffs[j]) }) s := stats{min: 1e9} if n%2 == 0 { s.median = (get(diffs[n/2-1]) + get(diffs[n/2])) / 2 } else { s.median = get(diffs[n/2]) } var sum float64 for _, d := range diffs { v := get(d) sum += v if v > s.max { s.max = v } if v < s.min { s.min = v } if v > 5 { s.over5++ } if v > 10 { s.over10++ } } s.mean = sum / float64(n) return s } label := func(name string, s stats) string { return fmt.Sprintf("%s Med=%.1f%% Mean=%.1f%% Min=%.0f%% Max=%.0f%% >5%%:%d >10%%:%d", name, s.median, s.mean, s.min, s.max, s.over5, s.over10) } log.Logf("\nSummary (n=%d):", n) log.Logf(" %s", label("BoxesInit ", computeStats(func(d Diff) float64 { return d.BoxesInitDiffPct }))) log.Logf(" %s", label("TextMerge", computeStats(func(d Diff) float64 { return d.BoxesTMDiffPct }))) log.Logf(" %s", label("VertMerge", computeStats(func(d Diff) float64 { return d.BoxesVMDiffPct }))) log.Logf(" %s", label("Sections ", computeStats(func(d Diff) float64 { return d.SectionsDiffPct }))) log.Logf(" %s", label("TextLen ", computeStats(func(d Diff) float64 { return d.TextLenDiffPct }))) log.Logf(" %s", label("CharDiff ", computeStats(func(d Diff) float64 { return 100 - d.CharSim }))) log.Logf(" %s", label("LcsDiff ", computeStats(func(d Diff) float64 { return 100 - d.LcsSim }))) log.Logf(" %s", label("RawCharDiff", computeStats(func(d Diff) float64 { return 100 - d.RawCharSim }))) log.Logf(" %s", label("RawLcsDiff ", computeStats(func(d Diff) float64 { return 100 - d.RawLcsSim }))) // Auto-generate xlsx report with timestamp. mode := filepath.Base(filepath.Dir(goTextDir)) // "ocr" ts := time.Now().Format("20060102_1504") xlsxDir := filepath.Join("testdata", "output") os.MkdirAll(xlsxDir, 0755) xlsxPath := filepath.Join(xlsxDir, fmt.Sprintf("compare_%s_%s.xlsx", mode, ts)) if err := WriteExcel(xlsxPath, diffs); err != nil { log.Logf("Excel write error: %v", err) } else { log.Logf("Excel report: %s", xlsxPath) } // Also write CSV if BATCH_CSV env is set (backward compat). if csvPath := os.Getenv("BATCH_CSV"); csvPath != "" { if err := WriteCSV(csvPath, diffs); err != nil { log.Logf("CSV write error: %v", err) } else { log.Logf("CSV written to %s", csvPath) } } } // WriteCSV writes comparison results to a CSV file using encoding/csv // for proper field escaping (filenames may contain commas/quotes). func WriteCSV(path string, diffs []Diff) error { f, err := os.Create(path) if err != nil { return err } defer f.Close() w := csv.NewWriter(f) defer w.Flush() if err := w.Write([]string{"file", "init%", "tm%", "vm%", "sec%", "txt%", "tabsD", "chrdiff%", "lcsdiff%", "rawChr%", "rawLcs%"}); err != nil { return err } for _, d := range diffs { row := []string{ d.File, strconv.FormatFloat(d.BoxesInitDiffPct, 'f', 1, 64), strconv.FormatFloat(d.BoxesTMDiffPct, 'f', 1, 64), strconv.FormatFloat(d.BoxesVMDiffPct, 'f', 1, 64), strconv.FormatFloat(d.SectionsDiffPct, 'f', 1, 64), strconv.FormatFloat(d.TextLenDiffPct, 'f', 1, 64), strconv.Itoa(d.TablesDiff), strconv.FormatFloat(100-d.CharSim, 'f', 1, 64), strconv.FormatFloat(100-d.LcsSim, 'f', 1, 64), strconv.FormatFloat(100-d.RawCharSim, 'f', 1, 64), strconv.FormatFloat(100-d.RawLcsSim, 'f', 1, 64), } if err := w.Write(row); err != nil { return err } } w.Flush() return w.Error() } // WriteExcel writes comparison results to an xlsx file with formatting. func WriteExcel(path string, diffs []Diff) error { f := excelize.NewFile() defer f.Close() sheet := "Comparison" f.SetSheetName("Sheet1", sheet) // Styles. headerStyle, _ := f.NewStyle(&excelize.Style{ Font: &excelize.Font{Bold: true}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"D9E1F2"}}, Alignment: &excelize.Alignment{Horizontal: "center"}, }) greenStyle, _ := f.NewStyle(&excelize.Style{ Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"C6EFCE"}}, NumFmt: 2, }) yellowStyle, _ := f.NewStyle(&excelize.Style{ Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FFEB9C"}}, NumFmt: 2, }) redStyle, _ := f.NewStyle(&excelize.Style{ Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FFC7CE"}}, NumFmt: 2, }) // Header row. headers := []string{"File", "Init%", "TM%", "VM%", "Sec%", "Txt%", "TabsD", "ChrDiff%", "LcsDiff%"} for i, h := range headers { cell, _ := excelize.CoordinatesToCellName(i+1, 1) f.SetCellValue(sheet, cell, h) f.SetCellStyle(sheet, cell, cell, headerStyle) } // Data rows. for row, d := range diffs { r := row + 2 // 1-indexed, skip header vals := []float64{ 0, // placeholder for file d.BoxesInitDiffPct, d.BoxesTMDiffPct, d.BoxesVMDiffPct, d.SectionsDiffPct, d.TextLenDiffPct, float64(d.TablesDiff), 100 - d.CharSim, 100 - d.LcsSim, } // File name (column A). f.SetCellValue(sheet, cellName(1, r), d.File) // Numeric columns (B-I). for col := 2; col <= 9; col++ { cell := cellName(col, r) v := vals[col-1] f.SetCellValue(sheet, cell, v) // Color: green <5, yellow 5-20, red >=20. if col == 7 { // TabsD is a count, not percentage continue } abs := math.Abs(v) switch { case abs < 5: f.SetCellStyle(sheet, cell, cell, greenStyle) case abs < 20: f.SetCellStyle(sheet, cell, cell, yellowStyle) default: f.SetCellStyle(sheet, cell, cell, redStyle) } } } // Column widths. f.SetColWidth(sheet, "A", "A", 45) f.SetColWidth(sheet, "B", "I", 12) // Freeze header row. f.SetPanes(sheet, &excelize.Panes{ Freeze: true, Split: false, XSplit: 0, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft", }) return f.SaveAs(path) } func cellName(col, row int) string { s, _ := excelize.CoordinatesToCellName(col, row) return s } // including per-cell text comparison. func CompareTablesWithPython(log TLogger, goTablesDir, pyTablesDir string) { goEntries, err := os.ReadDir(goTablesDir) if err != nil { log.Logf("Tables compare: no Go tables dir %s", goTablesDir) return } type goTable struct { Rows [][]string `json:"rows"` } type pyCell struct { X0 float64 `json:"x0"` X1 float64 `json:"x1"` Top float64 `json:"top"` Bottom float64 `json:"bottom"` Text string `json:"text"` Page int `json:"page"` } type pyResult struct { Cells []pyCell `json:"cells"` Page int `json:"page"` Rows [][]string `json:"rows"` } type pyFile struct { Tables int `json:"tables"` Results []pyResult `json:"results"` } matched, tableDiffs, cellDiffs, textMismatches := 0, 0, 0, 0 totalCellsCompared, totalCellsMatched := 0, 0 log.Logf("\n=== Table Comparison (Go vs Python) ===") log.Logf("%-40s %6s %6s %6s %6s %8s %s", "file", "GoTbl", "PyTbl", "GoCel", "PyCel", "TxtMatch", "Result") log.Logf("%s", strings.Repeat("-", 100)) for _, e := range goEntries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue } goPath := filepath.Join(goTablesDir, e.Name()) pyPath := filepath.Join(pyTablesDir, e.Name()) if !FileExists(pyPath) { continue } // Read Go tables. goData, _ := os.ReadFile(goPath) var goTables []goTable if err := json.Unmarshal(goData, &goTables); err != nil { log.Logf(" %s: Go JSON parse error: %v", e.Name(), err) continue } // Read Python tables. pyData, _ := os.ReadFile(pyPath) var pyF pyFile if err := json.Unmarshal(pyData, &pyF); err != nil { log.Logf(" %s: Py JSON parse error: %v", e.Name(), err) continue } matched++ // Count cells. goTotalCells := 0 for _, t := range goTables { for _, row := range t.Rows { goTotalCells += len(row) } } pyTotalCells := 0 for _, r := range pyF.Results { if len(r.Cells) > 0 { pyTotalCells += len(r.Cells) } else { for _, row := range r.Rows { pyTotalCells += len(row) } } } // Cell-level text comparison (table by table, row by row, cell by cell). cellsCompared, cellsMatched := 0, 0 nTables := min(len(goTables), len(pyF.Results)) for ti := 0; ti < nTables; ti++ { goRows := goTables[ti].Rows pyRows := pyF.Results[ti].Rows nRows := min(len(goRows), len(pyRows)) for ri := 0; ri < nRows; ri++ { nCols := min(len(goRows[ri]), len(pyRows[ri])) for ci := 0; ci < nCols; ci++ { cellsCompared++ if strings.TrimSpace(goRows[ri][ci]) == strings.TrimSpace(pyRows[ri][ci]) { cellsMatched++ } } } } totalCellsCompared += cellsCompared totalCellsMatched += cellsMatched // Status. status := "✅" txtMatch := "" if len(goTables) != len(pyF.Results) { tableDiffs++ status = "❌ tables" } if goTotalCells != pyTotalCells { cellDiffs++ if status == "✅" { status = "⚠️ cells" } } if cellsCompared > 0 { pct := float64(cellsMatched) / float64(cellsCompared) * 100 txtMatch = fmt.Sprintf("%.0f%%", pct) if pct < 100 && status == "✅" { status = "⚠️ text" textMismatches++ } if pct < 100 && status != "✅" { textMismatches++ } } else { txtMatch = "-" } name := strings.TrimSuffix(e.Name(), ".json") log.Logf("%-40s %6d %6d %6d %6d %8s %s", name, len(goTables), len(pyF.Results), goTotalCells, pyTotalCells, txtMatch, status) } if matched == 0 { log.Logf("No matching table files found") return } txtPct := 0.0 if totalCellsCompared > 0 { txtPct = float64(totalCellsMatched) / float64(totalCellsCompared) * 100 } log.Logf("\nTable Summary: %d PDFs, %d table diffs, %d cell diffs, %d text mismatches", matched, tableDiffs, cellDiffs, textMismatches) log.Logf("Cell text match: %d/%d (%.1f%%)", totalCellsMatched, totalCellsCompared, txtPct) } // ── DLA intermediate comparison ────────────────────────────────────────── type jsonDlaPage struct { Page int `json:"page"` Regions []jsonDlaRegion `json:"regions"` } type jsonDlaRegion struct { Label string `json:"label"` // Go uses "label" Type string `json:"type"` // Python uses "type" X0 float64 `json:"x0"` Y0 float64 `json:"y0"` X1 float64 `json:"x1"` Y1 float64 `json:"y1"` } // CompareDLAWithPython compares per-page DLA layout regions. // Both dirs contain {pdf}.json files with []dlaPageRegion. func CompareDLAWithPython(log TLogger, goDLADir, pyDLADir string) { goEntries, _ := os.ReadDir(goDLADir) pyEntries, _ := os.ReadDir(pyDLADir) pySet := map[string]bool{} for _, e := range pyEntries { pySet[e.Name()] = true } matched := 0 log.Logf("\n=== DLA Comparison (Go vs Python) ===") log.Logf("%-40s %6s %6s %6s %6s %6s", "file", "GoPg", "PyPg", "GoReg", "PyReg", "TblReg") log.Logf("%s", strings.Repeat("-", 80)) for _, e := range goEntries { if !strings.HasSuffix(e.Name(), ".json") || !pySet[e.Name()] { continue } goData, _ := os.ReadFile(filepath.Join(goDLADir, e.Name())) pyData, _ := os.ReadFile(filepath.Join(pyDLADir, e.Name())) var goPages []jsonDlaPage json.Unmarshal(goData, &goPages) var pyPages []jsonDlaPage json.Unmarshal(pyData, &pyPages) matched++ goRegions, pyRegions := 0, 0 goTables, pyTables := 0, 0 for _, p := range goPages { goRegions += len(p.Regions) for _, r := range p.Regions { if dlaRegionIsTable(r) { goTables++ } } } for _, p := range pyPages { pyRegions += len(p.Regions) for _, r := range p.Regions { if dlaRegionIsTable(r) { pyTables++ } } } name := strings.TrimSuffix(e.Name(), ".json") log.Logf("%-40s %6d %6d %6d %6d %6d", name, len(goPages), len(pyPages), goRegions, pyRegions, goTables-pyTables) } if matched == 0 { log.Logf("No matching DLA files found (go=%s py=%s)", goDLADir, pyDLADir) } } // ── TSR raw intermediate comparison ────────────────────────────────────── type tsrRawCell struct { TableIndex int `json:"table_index"` Page int `json:"page"` Label string `json:"label"` X0 float64 `json:"x0"` Y0 float64 `json:"y0"` X1 float64 `json:"x1"` Y1 float64 `json:"y1"` Text string `json:"text"` } // CompareTSRRawWithPython compares raw TSR cells per table. // Both dirs contain {pdf}.json files with []tsrRawCell (Go) or []tsrRawCell (Py). func CompareTSRRawWithPython(log TLogger, goTSRDir, pyTSRDir string) { goEntries, _ := os.ReadDir(goTSRDir) pyEntries, _ := os.ReadDir(pyTSRDir) pySet := map[string]bool{} for _, e := range pyEntries { pySet[e.Name()] = true } matched := 0 totalDiffs := 0 log.Logf("\n=== TSR Raw Comparison (Go vs Python) ===") log.Logf("%-40s %6s %6s %8s %8s %6s", "file", "GoTbl", "PyTbl", "GoCell", "PyCell", "LabelD") log.Logf("%s", strings.Repeat("-", 85)) for _, e := range goEntries { if !strings.HasSuffix(e.Name(), ".json") || !pySet[e.Name()] { continue } goData, _ := os.ReadFile(filepath.Join(goTSRDir, e.Name())) pyData, _ := os.ReadFile(filepath.Join(pyTSRDir, e.Name())) var goCells []tsrRawCell json.Unmarshal(goData, &goCells) var pyCells []tsrRawCell json.Unmarshal(pyData, &pyCells) // Group by table. goByTable := map[int][]tsrRawCell{} pyByTable := map[int][]tsrRawCell{} for _, c := range goCells { goByTable[c.TableIndex] = append(goByTable[c.TableIndex], c) } for _, c := range pyCells { pyByTable[c.TableIndex] = append(pyByTable[c.TableIndex], c) } matched++ labelDiffs := 0 goTotal, pyTotal := len(goCells), len(pyCells) for ti := range goByTable { goTab := goByTable[ti] pyTab := pyByTable[ti] n := min(len(goTab), len(pyTab)) for i := 0; i < n; i++ { if goTab[i].Label != pyTab[i].Label { labelDiffs++ } } labelDiffs += abs(len(goTab) - len(pyTab)) } if labelDiffs > 0 { totalDiffs++ } name := strings.TrimSuffix(e.Name(), ".json") log.Logf("%-40s %6d %6d %8d %8d %6d", name, len(goByTable), len(pyByTable), goTotal, pyTotal, labelDiffs) } if matched == 0 { log.Logf("No matching TSR raw files found (go=%s py=%s)", goTSRDir, pyTSRDir) } else { log.Logf("TSR Raw Summary: %d PDFs, %d with label diffs", matched, totalDiffs) } } func dlaRegionIsTable(r jsonDlaRegion) bool { label := r.Label if label == "" { label = r.Type } return label == "table" } func abs(x int) int { if x < 0 { return -x } return x }