Files
ragflow/internal/deepdoc/parser/pdf/layout/layout.go
Jack 1d3c100acb Refactor: pdf parser (#16625)
### Summary

PDF parser refactor
2026-07-05 20:45:35 +08:00

515 lines
13 KiB
Go
Raw 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 layout
import (
"log/slog"
"math"
pdf "ragflow/internal/deepdoc/parser/pdf/type"
util "ragflow/internal/deepdoc/parser/pdf/util"
"regexp"
"slices"
"sort"
"strings"
"unicode"
"unicode/utf8"
)
// ---- Column assignment ----
// AssignColumn groups boxes into columns on each page by KMeans x0 clustering
// with silhouette score selection, matching Python's _assign_column().
//
// Python: pdf_parser.py:739 _assign_column()
func AssignColumn(boxes []pdf.TextBox, zoom float64) []pdf.TextBox {
if len(boxes) == 0 {
return boxes
}
pageGroups, sortedPages := groupBoxesByPage(boxes)
result := make([]pdf.TextBox, len(boxes))
copy(result, boxes)
// Step A: per-page best k using silhouette score.
pageCols := make(map[int]int)
for _, pg := range sortedPages {
indices := pageGroups[pg]
determineBestKForPage(boxes, result, indices, pg, pageCols)
}
// Step B: assign col_id per page using per-page best k.
// Labels are remapped by centroid x-order: leftmost column → 0.
for _, pg := range sortedPages {
indices := pageGroups[pg]
assignColIDsForPage(boxes, result, indices, pg, pageCols)
}
return result
}
// determineBestKForPage finds the best number of clusters (k) for a page using silhouette score
func determineBestKForPage(boxes, result []pdf.TextBox, indices []int, pg int, pageCols map[int]int) {
n := len(indices)
if n < 2 {
pageCols[pg] = 1
for _, idx := range indices {
result[idx].ColID = 0
}
return
}
x0s, minX0, maxX1 := extractX0Values(boxes, indices)
pageWidth := maxX1 - minX0
indentTol := pageWidth * 0.12
applyIndentTolerance(x0s, minX0, indentTol)
bestK, _ := findBestK(x0s, n)
pageCols[pg] = bestK
}
// extractX0Values extracts x0 coordinates from boxes on a page and finds minX0 and maxX1
func extractX0Values(boxes []pdf.TextBox, indices []int) (x0s []float64, minX0 float64, maxX1 float64) {
n := len(indices)
x0s = make([]float64, n)
minX0 = math.MaxFloat64
maxX1 = 0.0
for i, idx := range indices {
x0s[i] = boxes[idx].X0
if x0s[i] < minX0 {
minX0 = x0s[i]
}
if boxes[idx].X1 > maxX1 {
maxX1 = boxes[idx].X1
}
}
return x0s, minX0, maxX1
}
// applyIndentTolerance adjusts x0 values that are close to minX0 to improve clustering
func applyIndentTolerance(x0s []float64, minX0, indentTol float64) {
for i := range x0s {
if math.Abs(x0s[i]-minX0) < indentTol {
x0s[i] = minX0
}
}
}
// findBestK tries k from 1 to min(4, n) and returns the k with the best silhouette score
func findBestK(x0s []float64, n int) (bestK int, bestScore float64) {
maxTry := min(4, n)
if maxTry < 2 {
maxTry = 1
}
bestK, bestScore = 1, -1.0
for k := 1; k <= maxTry; k++ {
labels, _ := util.KMeans1D(x0s, k)
var score float64
if k > 1 {
score = util.Silhouette1D(x0s, labels)
}
// score = 0 for k=1; score = -1 if silhouette undefined.
if score > bestScore {
bestScore = score
bestK = k
}
}
return bestK, bestScore
}
// assignColIDsForPage assigns column IDs to boxes on a page using the best k
func assignColIDsForPage(boxes, result []pdf.TextBox, indices []int, pg int, pageCols map[int]int) {
if len(indices) == 0 {
return
}
k := pageCols[pg]
if len(indices) < k {
k = 1
}
x0s := make([]float64, len(indices))
for i, idx := range indices {
x0s[i] = boxes[idx].X0
}
labels, centroids := util.KMeans1D(x0s, k)
remap := remapLabelsByCentroidOrder(centroids)
for i, idx := range indices {
result[idx].ColID = remap[labels[i]]
}
}
// remapLabelsByCentroidOrder remaps cluster labels so leftmost column = 0
func remapLabelsByCentroidOrder(centroids []float64) map[int]int {
type clPair struct {
center float64
label int
}
var pairs []clPair
for lbl, c := range centroids {
pairs = append(pairs, clPair{c, lbl})
}
sort.Slice(pairs, func(i, j int) bool { return pairs[i].center < pairs[j].center })
remap := make(map[int]int, len(centroids))
for newL, p := range pairs {
remap[p.label] = newL
}
return remap
}
// ---- Text merge (horizontal) ----
// TextMerge horizontally merges adjacent boxes at similar vertical positions.
//
// Python: pdf_parser.py:888 _text_merge()
func TextMerge(boxes []pdf.TextBox, medianHeights map[int]float64, zoom float64) []pdf.TextBox {
if len(boxes) < 2 {
return boxes
}
// Build output via collect: O(n) instead of O(n²) slice-element removal.
out := make([]pdf.TextBox, 0, len(boxes))
i := 0
for i < len(boxes) {
cur := boxes[i]
i++
for i < len(boxes) {
nxt := boxes[i]
if cur.PageNumber != nxt.PageNumber || cur.ColID != nxt.ColID {
break
}
// Python: b.get("layoutno", "0") != b_.get("layoutno", "1") —
// asymmetric defaults mean empty/missing layoutno never merge horizontally.
if cur.LayoutNo != nxt.LayoutNo || cur.LayoutNo == "" || nxt.LayoutNo == "" ||
cur.LayoutType == pdf.LayoutTypeTable || cur.LayoutType == pdf.LayoutTypeFigure || cur.LayoutType == pdf.LayoutTypeEquation {
break
}
mh := medianHeights[cur.PageNumber]
if mh <= 0 {
mh = 10
}
if math.Abs(util.BoxYDis(cur, nxt)) < mh/3 {
cur.X1 = nxt.X1
cur.Top = (cur.Top + nxt.Top) / 2
cur.Bottom = (cur.Bottom + nxt.Bottom) / 2
cur.Text += nxt.Text
i++
} else {
break
}
}
out = append(out, cur)
}
return out
}
// ---- Naive vertical merge ----
// NaiveVerticalMerge vertically merges boxes on the same page/column.
//
// Python: pdf_parser.py:926 _naive_vertical_merge()
func NaiveVerticalMerge(boxes []pdf.TextBox, medianHeights map[int]float64, medianWidths map[int]float64, isEnglish bool) []pdf.TextBox {
if len(boxes) < 2 {
return boxes
}
// Group boxes by page
pageGroups, sortedPages := groupBoxesByPage(boxes)
var result []pdf.TextBox
for _, pg := range sortedPages {
// Collect all boxes for this page
indices := pageGroups[pg]
bxs := make([]pdf.TextBox, len(indices))
for i, idx := range indices {
bxs[i] = boxes[idx]
}
mh := medianHeights[pg]
if mh <= 0 {
mh = util.MedianHeight(bxs)
}
mw := medianWidths[pg]
if mw <= 0 {
mw = 8 // Python fallback: np.median([...]) if chars else 8 (pdf_parser.py:1465)
}
// Process boxes for this page
processed := processPageBoxes(bxs, mh, mw, isEnglish)
result = append(result, processed...)
}
slog.Debug("vm result", "in", len(boxes), "out", len(result))
return result
}
// ---- Reading order ----
// FinalReadingOrderMerge sorts boxes by page → column → top → x0.
//
// Python: pdf_parser.py:1007 _final_reading_order_merge()
func FinalReadingOrderMerge(boxes []pdf.TextBox) []pdf.TextBox {
if len(boxes) == 0 {
return boxes
}
sort.Slice(boxes, func(i, j int) bool {
bi, bj := boxes[i], boxes[j]
if bi.PageNumber != bj.PageNumber {
return bi.PageNumber < bj.PageNumber
}
if bi.ColID != bj.ColID {
return bi.ColID < bj.ColID
}
if bi.Top != bj.Top {
return bi.Top < bj.Top
}
return bi.X0 < bj.X0
})
return boxes
}
var pageNumSuffixPattern = regexp.MustCompile(`[0-9 •一—-]+$`)
// groupBoxesByPage groups text boxes by page, returning a map from page number to index list and sorted page number list
func groupBoxesByPage(boxes []pdf.TextBox) (map[int][]int, []int) {
if len(boxes) == 0 {
return map[int][]int{}, []int{}
}
pageGroups := make(map[int][]int)
for i, b := range boxes {
pageGroups[b.PageNumber] = append(pageGroups[b.PageNumber], i)
}
// Sort page numbers
pageKeys := make([]int, 0, len(pageGroups))
for pg := range pageGroups {
pageKeys = append(pageKeys, pg)
}
sort.Ints(pageKeys)
return pageGroups, pageKeys
}
// shouldMergeBoxes determines whether two boxes should be merged
func shouldMergeBoxes(prev, curr *pdf.TextBox, mh, mw float64, isEnglish bool) bool {
// Check layout number
if prev.LayoutNo != curr.LayoutNo {
slog.Debug("vm reject", "reason", "layoutNo", "prevLayout", prev.LayoutNo, "currLayout", curr.LayoutNo)
return false
}
// Check vertical gap
gap := curr.Top - prev.Bottom
if gap > mh*1.5 {
slog.Debug("vm reject", "reason", "gap", "gap", gap, "threshold", mh*1.5, "mh", mh)
return false
}
// Check horizontal overlap
ov := util.OverlapX(prev, curr)
if ov < 0.3 {
slog.Debug("vm reject", "reason", "ovX", "ov", ov, "threshold", 0.3)
return false
}
// Check merge/block conditions
prevText := strings.TrimSpace(prev.Text)
currText := strings.TrimSpace(curr.Text)
concatting := []bool{
endsWithOneOf(prevText, ",;:\",、‘“;:-"),
endsSecondLastOneOf(prevText, ",;:\",、‘“;:"),
startsWithOneOf(currText, "。;?!?\"),,、:"),
}
anti := []bool{
endsWithOneOf(prevText, "。?!?"),
isEnglish && endsWithOneOf(prevText, ".!?"),
prev.PageNumber < curr.PageNumber && math.Abs(prev.X0-curr.X0) > mw*4,
}
detach := []bool{prev.X1 < curr.X0, prev.X0 > curr.X1}
if (slices.Contains(anti, true) && !slices.Contains(concatting, true)) || slices.Contains(detach, true) {
return false
}
return true
}
// mergeTwoBoxes merges two text boxes
func mergeTwoBoxes(prev, curr pdf.TextBox) pdf.TextBox {
prevText := strings.TrimSpace(prev.Text)
currText := strings.TrimSpace(curr.Text)
prev.Text = strings.TrimSpace(strings.TrimRight(prevText, " \t") + " " + strings.TrimLeft(currText, " \t"))
prev.Bottom = math.Max(prev.Bottom, curr.Bottom)
prev.X0 = math.Min(prev.X0, curr.X0)
prev.X1 = math.Max(prev.X1, curr.X1)
prevTrunc, currTrunc := prevText, currText
if r := []rune(prevTrunc); len(r) > 40 {
prevTrunc = string(r[:40])
}
if r := []rune(currTrunc); len(r) > 40 {
currTrunc = string(r[:40])
}
slog.Debug("vm merge", "prev", prevTrunc, "curr", currTrunc)
return prev
}
// processPageBoxes processes all boxes for a single page
func processPageBoxes(boxes []pdf.TextBox, mh, mw float64, isEnglish bool) []pdf.TextBox {
if len(boxes) == 0 {
return boxes
}
// Sort by Top, X0
sortedBoxes := make([]pdf.TextBox, len(boxes))
copy(sortedBoxes, boxes)
sort.Slice(sortedBoxes, func(i, j int) bool {
if sortedBoxes[i].Top != sortedBoxes[j].Top {
return sortedBoxes[i].Top < sortedBoxes[j].Top
}
return sortedBoxes[i].X0 < sortedBoxes[j].X0
})
out := make([]pdf.TextBox, 0, len(sortedBoxes))
for i := 0; i < len(sortedBoxes); i++ {
curr := sortedBoxes[i]
// Skip cross-page suffixes (like previous page number)
if i > 0 && sortedBoxes[i-1].PageNumber < curr.PageNumber && pageNumSuffixPattern.MatchString(sortedBoxes[i-1].Text) {
continue
}
// Handle empty boxes
if strings.TrimSpace(curr.Text) == "" {
if len(out) > 0 {
prev := &out[len(out)-1]
if curr.Top-prev.Bottom <= mh*1.5 && util.OverlapX(prev, &curr) >= 0.3 {
// TODO: prev.Bottom = math.Max(prev.Bottom, curr.Bottom) — direct assignment might shrink tall merged boxes
// Matches Python behavior (also direct assignment). Defer fix until pipeline alignment release.
prev.Bottom = curr.Bottom
}
}
continue
}
if len(out) == 0 {
out = append(out, curr)
continue
}
prev := &out[len(out)-1]
if shouldMergeBoxes(prev, &curr, mh, mw, isEnglish) {
out[len(out)-1] = mergeTwoBoxes(*prev, curr)
} else {
out = append(out, curr)
}
}
return out
}
// ---- rune-based text helpers (CJK-safe) ----
func lastRune(s string) rune {
r, _ := utf8.DecodeLastRuneInString(s)
return r
}
func firstRune(s string) rune {
r, _ := utf8.DecodeRuneInString(s)
return r
}
func secondLastRune(s string) rune {
r, size := utf8.DecodeLastRuneInString(s)
if r == utf8.RuneError && size == 0 {
return 0
}
r2, _ := utf8.DecodeLastRuneInString(s[:len(s)-size])
return r2
}
func endsWithOneOf(s, set string) bool {
r := lastRune(s)
if r == 0 {
return false
}
return strings.ContainsRune(set, r)
}
func endsSecondLastOneOf(s, set string) bool {
r := secondLastRune(s)
if r == 0 {
return false
}
return strings.ContainsRune(set, r)
}
func startsWithOneOf(s, set string) bool {
r := firstRune(s)
if r == 0 {
return false
}
return strings.ContainsRune(set, r)
}
// MergeSameBullet merges adjacent boxes that start with the same bullet/number
// character, combining their text with a newline separator.
func MergeSameBullet(boxes []pdf.TextBox, tok pdf.Tokenizer) []pdf.TextBox {
if len(boxes) < 2 {
return boxes
}
out := make([]pdf.TextBox, 0, len(boxes))
i := 0
for i < len(boxes) {
if strings.TrimSpace(boxes[i].Text) == "" {
i++
continue
}
cur := boxes[i]
i++
for i < len(boxes) {
if strings.TrimSpace(boxes[i].Text) == "" {
i++
continue
}
nxt := boxes[i]
firstCur := firstRuneString(cur.Text)
firstNxt := firstRuneString(nxt.Text)
if firstCur != firstNxt ||
unicode.Is(unicode.Latin, firstCur) ||
isChinese(firstCur, tok) ||
cur.Top > nxt.Bottom {
break
}
cur.Text = cur.Text + "\n" + nxt.Text
cur.X0 = min(cur.X0, nxt.X0)
cur.X1 = max(cur.X1, nxt.X1)
cur.Bottom = nxt.Bottom
i++
}
out = append(out, cur)
}
return out
}
func firstRuneString(s string) rune {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
return []rune(s)[0]
}
// isChinese checks if a rune is a Chinese character (CJK Unified Ideograph).
func isChinese(r rune, tok pdf.Tokenizer) bool {
if tok != nil {
return strings.Contains(tok.Tag(string(r)), "n")
}
return (r >= 0x4E00 && r <= 0x9FFF) ||
(r >= 0x3400 && r <= 0x4DBF) ||
(r >= 0x20000 && r <= 0x2A6DF)
}