mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-15 05:04:27 +08:00
Incremental follow-up to #18023 (gap + balance-gate hybrid). Adds two complementary column detectors to `AssignColumn` that run **only after** the gap detector and the balance gate both fail, so already-correct pages are never touched.
949 lines
30 KiB
Go
949 lines
30 KiB
Go
package layout
|
|
|
|
import (
|
|
"math"
|
|
"math/rand"
|
|
"sort"
|
|
"unicode/utf8"
|
|
|
|
pdf "ragflow/internal/deepdoc/parser/pdf/type"
|
|
util "ragflow/internal/deepdoc/parser/pdf/util"
|
|
)
|
|
|
|
// AssignColumn groups boxes into columns using the hybrid gap + KMeans
|
|
// strategy that beats gap-only column detection on real documents.
|
|
//
|
|
// Decision per page (mirrors tool-py/diagnose_combined.py):
|
|
// 1. Geometric gap (whitespace gutter voting) finds candidate column
|
|
// separators. But "gap >= 2" is NOT blindly trusted:
|
|
// - If the resulting columns are NARROW (max column width <
|
|
// tableMaxColFrac of the page), they are table cells, not text columns:
|
|
// the page is a single reading block -> return 1 directly (and do NOT
|
|
// fall through to the balance gate, which would re-split the table's
|
|
// bimodal x0 into 2).
|
|
// - If gap == 2, the separator is unreliable (it is often a fake gutter
|
|
// from indentation/line-width variation, not a real column). Defer to
|
|
// the balance gate below.
|
|
// - If gap >= 3 with WIDE columns, it is a real multi-column layout:
|
|
// trust it and partition by KMeans(g).
|
|
// 2. When gap reports 1 (single column OR a double column whose gutter is
|
|
// bridged by full-width front matter), or gap == 2 was deferred, a forced
|
|
// k=2 KMeans on the BODY x0 decides whether the lines form TWO clusters
|
|
// each holding >= minModeFrac of body lines, separated by >=
|
|
// minSepFrac*width. A balanced split is a real second column; an
|
|
// unbalanced split (the usual KMeans false-split on a single page) is
|
|
// dropped -> stays 1.
|
|
//
|
|
// Net effect: tables and fake gutters no longer over-split, while the
|
|
// double-column pages that gap alone misses are recovered by the balance gate.
|
|
func AssignColumn(boxes []pdf.TextBox) []pdf.TextBox {
|
|
if len(boxes) == 0 {
|
|
return boxes
|
|
}
|
|
pageGroups, sortedPages := groupBoxesByPage(boxes)
|
|
result := make([]pdf.TextBox, len(boxes))
|
|
copy(result, boxes)
|
|
for _, pg := range sortedPages {
|
|
indices := pageGroups[pg]
|
|
k, cents := detectColumnCount(boxes, indices)
|
|
assignColIDs(boxes, result, indices, k, cents)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// tableMaxColFrac: a column narrower than this fraction of the page width is
|
|
// treated as a table cell, not a text column. Above this, the columns are
|
|
// wide enough to be real reading columns.
|
|
const tableMaxColFrac = 0.22
|
|
|
|
// maxColumnCount caps how many columns the gap detector may report. Gap
|
|
// voting can over-split a single page into many spurious gutters (e.g.
|
|
// first-line indentation), so we bound the count to the old detector's best-k
|
|
// cap of min(4, n). This prevents catastrophic splits (a single page reported
|
|
// as 7+ columns) that the old code could never produce.
|
|
const maxColumnCount = 4
|
|
|
|
// minColLineFrac: a column holding fewer than this fraction of the page's
|
|
// lines (or zero lines) is not a real reading column — it is a spurious
|
|
// gutter sliver (an indented block, a stray caption, an empty kmeans
|
|
// centroid). Drop it so the detector does not over-split.
|
|
//
|
|
// The threshold is set with margin below the smallest genuine column ratio
|
|
// observed on the 70-page labeled corpus: the sparsest real double's minority
|
|
// column is ~17.7% of lines, and the only real triple's columns are each
|
|
// >=22%. 12% prunes genuine outliers (e.g. a 4-line footnote, 7.3%) without
|
|
// touching those.
|
|
const minColLineFrac = 0.12
|
|
|
|
// maxPageExtent caps the X span a single line may plausibly occupy. A line
|
|
// wider than this is treated as a malformed coordinate (mirrors pdf-inspector's
|
|
// MAX_PAGE_EXTENT=14400 guard) and excluded from the column projection so it
|
|
// cannot balloon the page extent and collapse multi-column detection.
|
|
const maxPageExtent = 14400.0
|
|
|
|
// maxTrimFraction is the largest fraction of lines robustPageExtent may discard
|
|
// as outliers. If more than this fraction is anomalous, the page is trusted
|
|
// as-is: the "anomalies" are the norm, not noise.
|
|
const maxTrimFraction = 0.10
|
|
|
|
// maxBins caps the histogram allocation in gapColumnCount/detectColumnCount2D so
|
|
// a malformed (ballooned) page extent cannot trigger an OOM-scale allocation
|
|
// (mirrors pdf-inspector's bin cap). When the extent is huge, the bin is
|
|
// widened so the projection still resolves real gutters.
|
|
const maxBins = 65536
|
|
|
|
// gapMinFrac: a horizontal run of low coverage counts as a column gap only if
|
|
// it is at least this fraction of the page width. Reused by both gapColumnCount
|
|
// and the 2D both-sides gutter rescue so the two detectors agree on what a
|
|
// "real" gutter width is.
|
|
const gapMinFrac = 0.04
|
|
|
|
// binPt: x-binning resolution (points) for the 1D gap histogram and the 2D
|
|
// gutter scan. Sharing it keeps the gap and gutter detectors aligned.
|
|
const binPt = 2.0
|
|
|
|
// detectColumnCount returns (columnCount, centroids) for one page.
|
|
// columnCount is 1, 2, or up to maxColumnCount; centroids are the k cluster
|
|
// means in x0 space (snapshot of the gate decision) and are reused for ColID
|
|
// assignment.
|
|
func detectColumnCount(boxes []pdf.TextBox, indices []int) (int, []float64) {
|
|
lines := make([]pdf.TextBox, len(indices))
|
|
for i, idx := range indices {
|
|
lines[i] = boxes[idx]
|
|
}
|
|
g := gapColumnCount(lines, gapMinFrac, 0.15, binPt)
|
|
if g >= 2 {
|
|
_, width := pageExtent(lines)
|
|
if width > 0 {
|
|
widths := gapColumnWidths(lines)
|
|
maxw := 0.0
|
|
for _, w := range widths {
|
|
if w > maxw {
|
|
maxw = w
|
|
}
|
|
}
|
|
if maxw < tableMaxColFrac*width {
|
|
// Narrow columns => table cells, not text columns. The page
|
|
// is one reading block; return 1 and skip the balance gate
|
|
// (which would otherwise re-split the table's x0).
|
|
return 1, nil
|
|
}
|
|
}
|
|
if g > 2 {
|
|
// gap >= 3 with wide columns: a real multi-column layout.
|
|
// Cap the count (maxColumnCount) so spurious gutters cannot
|
|
// split a single page into many columns, then prune empty or
|
|
// too-sparse columns so an indentation-created sliver does not
|
|
// survive as a spurious column.
|
|
k := g
|
|
if k > maxColumnCount {
|
|
k = maxColumnCount
|
|
}
|
|
if k > len(lines) {
|
|
k = len(lines)
|
|
}
|
|
_, w := pageExtent(lines)
|
|
cents := kmeansCentroids(lines, k, w)
|
|
if pk, pc, ok := pruneColumns(lines, cents); ok {
|
|
return pk, pc
|
|
}
|
|
return 1, nil
|
|
}
|
|
// g == 2: unreliable (fake gutter or real 2-col) -> defer to balance.
|
|
}
|
|
if ok, cents, body := balancedBodyK2(lines, 0.30, 0.10); ok {
|
|
// prune on the SAME body the gate clustered, not all lines: full-width
|
|
// titles/abstracts were deliberately excluded from the balance check
|
|
// and must not be re-counted here (they would inflate one column and
|
|
// let prune wrongly collapse a real two-column page to one).
|
|
if pk, pc, ok2 := pruneColumns(body, cents); ok2 {
|
|
return pk, pc
|
|
}
|
|
return 1, nil
|
|
}
|
|
// 2D rescue: a clean vertical gutter the 1D projection masks via bridging
|
|
// rows (full-width front matter + in-body headings/captions). Recovers
|
|
// title-bridged doubles the balance gate correctly rejects (sparse
|
|
// minority). Runs only after both gap>=2 and the balance gate fail, so it
|
|
// never touches already-correct pages.
|
|
if k, cents := detectColumnCount2D(lines); k >= 2 {
|
|
return k, cents
|
|
}
|
|
// L3: median-width-ratio complement (PR #10475). Fires only after gap,
|
|
// balance, and the 2D valley rescue all returned 1, so it never touches
|
|
// the pages they already handle. Targets "gutter-less" doubles/triples.
|
|
if k, cents := detectColumnCountMedian(lines); k >= 2 {
|
|
return k, cents
|
|
}
|
|
return 1, nil
|
|
}
|
|
|
|
// pruneColumns drops empty (0-line) or too-sparse (< minColLineFrac) columns
|
|
// from a k-centroid partition and returns the surviving (k', cents'). A column
|
|
// is "real" only if it captures enough of the page's lines. If fewer than 2
|
|
// real columns survive, ok is false and the caller should treat the page as a
|
|
// single column.
|
|
func pruneColumns(lines []pdf.TextBox, cents []float64) (int, []float64, bool) {
|
|
n := len(lines)
|
|
if n == 0 || len(cents) < 2 {
|
|
return len(cents), cents, len(cents) >= 2
|
|
}
|
|
counts := make([]int, len(cents))
|
|
for _, b := range lines {
|
|
best, bestD := 0, math.Abs(b.X0-cents[0])
|
|
for c := 1; c < len(cents); c++ {
|
|
if d := math.Abs(b.X0 - cents[c]); d < bestD {
|
|
bestD, best = d, c
|
|
}
|
|
}
|
|
counts[best]++
|
|
}
|
|
keep := make([]int, 0, len(cents))
|
|
for c := range cents {
|
|
if counts[c] > 0 && float64(counts[c]) >= minColLineFrac*float64(n) {
|
|
keep = append(keep, c)
|
|
}
|
|
}
|
|
if len(keep) < 2 {
|
|
return len(keep), nil, false
|
|
}
|
|
newCents := make([]float64, len(keep))
|
|
for i, c := range keep {
|
|
newCents[i] = cents[c]
|
|
}
|
|
return len(keep), newCents, true
|
|
}
|
|
|
|
// gapColumnWidths returns the width (in page units) of each column found by
|
|
// the same gutter voting as gapColumnCount. Used to tell real wide text
|
|
// columns apart from narrow table-cell columns.
|
|
func gapColumnWidths(lines []pdf.TextBox) []float64 {
|
|
n := len(lines)
|
|
if n == 0 {
|
|
return nil
|
|
}
|
|
minX0, width := pageExtent(lines)
|
|
if width <= 0 {
|
|
return nil
|
|
}
|
|
binPt := 2.0
|
|
nb := int(width/binPt) + 1
|
|
cov := make([]int, nb)
|
|
for _, b := range lines {
|
|
i0 := clampInt(int((b.X0-minX0)/binPt), 0, nb-1)
|
|
i1 := clampInt(int((b.X1-minX0)/binPt), 0, nb-1)
|
|
for i := i0; i <= i1; i++ {
|
|
cov[i]++
|
|
}
|
|
}
|
|
thr := 0.15 * float64(n)
|
|
var widths []float64
|
|
i := 0
|
|
for i < nb {
|
|
if float64(cov[i]) < thr {
|
|
i++
|
|
continue
|
|
}
|
|
j := i
|
|
for j < nb && float64(cov[j]) >= thr {
|
|
j++
|
|
}
|
|
widths = append(widths, float64(j-i)*binPt)
|
|
i = j
|
|
}
|
|
return widths
|
|
}
|
|
|
|
func clampInt(v, lo, hi int) int {
|
|
if v < lo {
|
|
return lo
|
|
}
|
|
if v > hi {
|
|
return hi
|
|
}
|
|
return v
|
|
}
|
|
|
|
// gapColumnCount mirrors column_detectors.gap_column_counts: rasterize the
|
|
// [minX0, maxX1] text region into x-bins, count how many lines cover each bin,
|
|
// and treat a covered-fraction-below-crossTol run wider than gapMinFrac*width
|
|
// as a column-separating gutter.
|
|
func gapColumnCount(lines []pdf.TextBox, gapMinFrac, crossTol, binPt float64) int {
|
|
n := len(lines)
|
|
if n == 0 {
|
|
return 1
|
|
}
|
|
// A1: image/equation placeholders must not feed the projection — a figure
|
|
// spanning the gutter would otherwise fill the gap and mask a real column
|
|
// boundary.
|
|
lines = textProjectionLines(lines)
|
|
if len(lines) == 0 {
|
|
return 1
|
|
}
|
|
// A2: robust page extent discards malformed/outlier lines so a single bad
|
|
// box cannot balloon the width and collapse detection to one column.
|
|
minX0, width := robustPageExtent(lines)
|
|
if width <= 0 {
|
|
return 1
|
|
}
|
|
minGap := gapMinFrac * width
|
|
// Cap the bin count so a ballooned extent cannot allocate an OOM-scale
|
|
// histogram. When the extent is huge, widen the bin so the projection still
|
|
// resolves real gutters.
|
|
effBin := binPt
|
|
if width/float64(maxBins) > effBin {
|
|
effBin = width / float64(maxBins)
|
|
}
|
|
nb := int(width/effBin) + 1
|
|
if nb < 1 {
|
|
nb = 1
|
|
}
|
|
cov := make([]int, nb)
|
|
for _, b := range lines {
|
|
i0 := int((b.X0 - minX0) / effBin)
|
|
if i0 < 0 {
|
|
i0 = 0
|
|
}
|
|
i1 := int((b.X1 - minX0) / effBin)
|
|
if i1 > nb-1 {
|
|
i1 = nb - 1
|
|
}
|
|
for i := i0; i <= i1; i++ {
|
|
cov[i]++
|
|
}
|
|
}
|
|
thr := crossTol * float64(n)
|
|
cols := 1
|
|
run := 0.0
|
|
for _, c := range cov {
|
|
if float64(c) < thr {
|
|
run += effBin
|
|
} else {
|
|
if run >= minGap {
|
|
cols++
|
|
}
|
|
run = 0
|
|
}
|
|
}
|
|
if run >= minGap {
|
|
cols++
|
|
}
|
|
return cols
|
|
}
|
|
|
|
// bridgingFrac: lines wider than this fraction of the page text width are
|
|
// treated as bridging elements — full-width front matter (already dropped by
|
|
// dropFullWidth at 0.9) plus partially-wide in-body headings/captions that
|
|
// span the gutter. Dropping them before the valley scan is what exposes the
|
|
// clean gutter of a title-bridged double column. 0.60 is the sweet spot
|
|
// measured on the 70-page corpus: lower (0.50) leaves too few real column
|
|
// lines on single pages and keeps enough bridging width to still hide some
|
|
// gutters; higher (0.65) lets the sparse bridging lines that hide the target
|
|
// gutters survive.
|
|
const bridgingFrac = 0.60
|
|
|
|
// medianFullWidthFrac is the full-width threshold for the L3 median-width
|
|
// detector. It is lower than dropFullWidth's 0.9 because the median path
|
|
// buckets by normalized center x and only needs to exclude lines that would
|
|
// otherwise dominate every bucket; lines between 0.8 and 0.9 width are rare
|
|
// and keeping them out of the buckets avoids a single wide line skewing cents.
|
|
const medianFullWidthFrac = 0.80
|
|
|
|
// medianColCap: max column count the median-width-ratio signal (L3, from PR
|
|
// #10475's page_w/median_w) may assign. Capped at 3 so a raw_cols estimate of
|
|
// 4 (common on 3-column pages) does not over-shoot, and well under
|
|
// maxColumnCount.
|
|
const medianColCap = 3
|
|
|
|
// shortLineFrac: L3 requires at least one line spanning >= this fraction of
|
|
// the page width. A real multi-column page has lines that span a column
|
|
// (~page_w/N); a single page of uniformly short lines also has a small median
|
|
// width, but no near-full-width line — this guard filters those false doubles.
|
|
const shortLineFrac = 0.45
|
|
|
|
// detectColumnCount2D is a rescue detector for title-bridged double columns:
|
|
// pages whose two body columns are separated by a clean gutter that the 1D x0
|
|
// projection loses once full-width front matter (and in-body bridging
|
|
// headings/captions) spans it. It runs only after gap>=2 and the balance gate
|
|
// both fail, so it never touches already-correct pages.
|
|
//
|
|
// Method (faithful to tool-py/column_detectors.gap_glyph_body_column_counts,
|
|
// extended with bridging removal): project the BODY — full-width lines dropped
|
|
// by dropFullWidth, then any still-wide bridging line dropped at
|
|
// bridgingFrac*width — onto the x-axis with glyph-count-per-bin weighting
|
|
// (each covered bin receives the line's full rune count, so a wide line
|
|
// contributes proportionally more), and look
|
|
// for INTERIOR valleys (low-ink runs bounded by high ink on both sides, wider
|
|
// than gapMinFrac*width, and not at the page edge). A single clean gutter
|
|
// splits the page into two real columns.
|
|
//
|
|
// The rescue ACCEPTS only exactly one interior valley (k=2). Zero valleys
|
|
// means no clean gutter (keep single). Two or more valleys means either a
|
|
// multi-column layout (already handled by the gap path) or a single page with
|
|
// a vertical blank band (figure/equation) — both are rejected so the rescue
|
|
// never over-splits a single column into 3+. The both-sides prune gate
|
|
// (pruneColumns, minColLineFrac) is the final guard: a spurious second block
|
|
// with too few lines is dropped.
|
|
func detectColumnCount2D(lines []pdf.TextBox) (int, []float64) {
|
|
// A1: strip figure/equation boxes (they span the gutter and would fill the
|
|
// projection, hiding a real column boundary). A2: robust extent so a
|
|
// malformed box cannot balloon the page width / histogram allocation.
|
|
projLines := textProjectionLines(lines)
|
|
minX0, width := robustPageExtent(projLines)
|
|
if width <= 0 {
|
|
return 0, nil
|
|
}
|
|
body := dropFullWidth(projLines, width)
|
|
body = dropWide(body, width, bridgingFrac)
|
|
if len(body) < 4 {
|
|
return 0, nil
|
|
}
|
|
// Cap the bin count so a ballooned extent cannot allocate an OOM-scale
|
|
// histogram. When the extent is huge, widen the bin so the projection still
|
|
// resolves real gutters.
|
|
effBin := binPt
|
|
if width/float64(maxBins) > effBin {
|
|
effBin = width / float64(maxBins)
|
|
}
|
|
nb := int(width/effBin) + 1
|
|
proj := make([]int, nb)
|
|
for _, b := range body {
|
|
w := utf8.RuneCountInString(b.Text)
|
|
if w <= 0 {
|
|
w = 1
|
|
}
|
|
i0 := clampInt(int((b.X0-minX0)/effBin), 0, nb-1)
|
|
i1 := clampInt(int((b.X1-minX0)/effBin), 0, nb-1)
|
|
for i := i0; i <= i1; i++ {
|
|
proj[i] += w
|
|
}
|
|
}
|
|
pk := 0
|
|
for _, p := range proj {
|
|
if p > pk {
|
|
pk = p
|
|
}
|
|
}
|
|
if pk == 0 {
|
|
return 0, nil
|
|
}
|
|
// A gutter is a run of bins whose glyph-weight is below valleyFrac of the
|
|
// page peak. Measured on the 70-page corpus this relative threshold (the
|
|
// tool-py reference value) is what actually recovers title-bridged doubles:
|
|
// their gutter is clean (≈0 glyphs) and the minority column still carries
|
|
// enough ink to sit above valleyFrac*peak and bound the gutter. A minority
|
|
// column below ~30% of peak ink (e.g. a very sparse 6-line column) merges
|
|
// with the gutter and is NOT recovered — that is a real limitation, not a
|
|
// bug; such pages fall back to the confidence-labeling track (issue #18079).
|
|
const valleyFrac = 0.30
|
|
minGap := gapMinFrac * width
|
|
edge := int(0.05 * width / effBin)
|
|
if edge < 0 {
|
|
edge = 0
|
|
}
|
|
// Find interior valleys; accept ONLY a single clean gutter (k=2). Zero
|
|
// valleys means no clean gutter (keep single). Two or more valleys means
|
|
// either a multi-column layout (handled by the gap path) or a single page
|
|
// with a vertical blank band (figure/equation) — both are rejected so the
|
|
// rescue never over-splits a single column into 3+.
|
|
var valleyC float64
|
|
count := 0
|
|
i := 0
|
|
for i < nb {
|
|
if float64(proj[i]) < valleyFrac*float64(pk) {
|
|
j := i
|
|
for j < nb && float64(proj[j]) < valleyFrac*float64(pk) {
|
|
j++
|
|
}
|
|
runW := float64(j-i) * effBin
|
|
isInterior := i > edge && j-1 < nb-1-edge
|
|
if runW >= minGap && isInterior {
|
|
count++
|
|
valleyC = minX0 + float64(i+j)*effBin/2
|
|
}
|
|
i = j
|
|
} else {
|
|
i++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
return 0, nil
|
|
}
|
|
// Split the body at the single valley into left/right blocks; each
|
|
// centroid is the mean X0 of its lines. Classify by b.X0 (not the center)
|
|
// so the split agrees with pruneColumns' X0-based assignment — lines whose
|
|
// center and X0 fall on opposite sides of the valley would otherwise be
|
|
// counted differently by the two steps.
|
|
var leftSum, rightSum float64
|
|
lc, rc := 0, 0
|
|
for _, b := range body {
|
|
if b.X0 < valleyC {
|
|
leftSum += b.X0
|
|
lc++
|
|
} else {
|
|
rightSum += b.X0
|
|
rc++
|
|
}
|
|
}
|
|
if lc == 0 || rc == 0 {
|
|
return 0, nil
|
|
}
|
|
cents := []float64{leftSum / float64(lc), rightSum / float64(rc)}
|
|
if pk2, pc, ok := pruneColumns(body, cents); ok {
|
|
return pk2, pc
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// dropWide removes lines whose width spans >= frac of the page text width.
|
|
// Used by detectColumnCount2D to strip in-body bridging headings/captions
|
|
// (partially-wide lines that span the gutter but are not full-width front
|
|
// matter) before the valley scan. Returns nil if every line is wide so the
|
|
// caller treats the page as single rather than pushing an empty body through.
|
|
func dropWide(lines []pdf.TextBox, width, frac float64) []pdf.TextBox {
|
|
if frac >= 1 {
|
|
return lines
|
|
}
|
|
thr := frac * width
|
|
out := make([]pdf.TextBox, 0, len(lines))
|
|
for _, b := range lines {
|
|
if b.X1-b.X0 < thr {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// medianWidth returns the median box width on the page. Used by the L3
|
|
// median-width-ratio column signal.
|
|
func medianWidth(lines []pdf.TextBox) float64 {
|
|
if len(lines) == 0 {
|
|
return 1.0
|
|
}
|
|
ws := make([]float64, len(lines))
|
|
for i, b := range lines {
|
|
ws[i] = b.X1 - b.X0
|
|
if ws[i] < 1 {
|
|
ws[i] = 1
|
|
}
|
|
}
|
|
sort.Float64s(ws)
|
|
n := len(ws)
|
|
if n%2 == 1 {
|
|
return ws[n/2]
|
|
}
|
|
return (ws[n/2-1] + ws[n/2]) / 2.0
|
|
}
|
|
|
|
// maxWidth returns the widest box on the page.
|
|
func maxWidth(lines []pdf.TextBox) float64 {
|
|
m := 0.0
|
|
for _, b := range lines {
|
|
if w := b.X1 - b.X0; w > m {
|
|
m = w
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// detectColumnCountMedian is the L3 complementary signal, inspired by PR
|
|
// #10475's _assign_column (page_w / median_line_width). It fires only after
|
|
// gap, the balance gate, and the 2D valley rescue have ALL returned a single
|
|
// column, so it never touches the pages they already handle correctly.
|
|
//
|
|
// It targets "gutter-less" doubles/triples: pages whose two (or three) body
|
|
// columns are separated by a gutter so narrow/bridged that the x-projection
|
|
// has no clean ink dip — so the geometric detectors miss them, yet each line
|
|
// is only ~page_w/N wide, giving raw_cols = page_w/median_w >= 2.
|
|
//
|
|
// Two gates keep it safe (measured on the 70-page corpus):
|
|
// - raw_cols > maxColumnCount (4): a huge ratio means table cells, not text
|
|
// columns (narrow cells yield a tiny median width) -> skip.
|
|
// - no line spanning >= shortLineFrac*page_w: the page is one column of
|
|
// uniformly short lines whose small median width is not a real multi-column
|
|
// signal -> skip.
|
|
//
|
|
// When it fires, columns are assigned by normalized center-x bucketing
|
|
// (matching PR #10475's col_id assignment); the bucket means become centroids.
|
|
func detectColumnCountMedian(lines []pdf.TextBox) (int, []float64) {
|
|
minX0, width := pageExtent(lines)
|
|
if width <= 0 {
|
|
return 0, nil
|
|
}
|
|
mw := medianWidth(lines)
|
|
if mw < 1 {
|
|
mw = 1
|
|
}
|
|
raw := int(width / mw)
|
|
if raw < 2 {
|
|
return 0, nil
|
|
}
|
|
if raw > maxColumnCount {
|
|
// Table-like (narrow cells): not a text layout.
|
|
return 0, nil
|
|
}
|
|
if maxWidth(lines) < shortLineFrac*width {
|
|
// Uniformly short lines, not real columns.
|
|
return 0, nil
|
|
}
|
|
k := raw
|
|
if k > medianColCap {
|
|
k = medianColCap
|
|
}
|
|
if k < 2 {
|
|
k = 2
|
|
}
|
|
// Bucket non-full-width lines by normalized center x, mirroring PR #10475.
|
|
// Collect the SAME non-full-width lines into body so the prune step counts
|
|
// the line set that produced the centroids — otherwise full-width
|
|
// titles/abstracts (which the bucket loop skips) would be re-counted by
|
|
// pruneColumns, inflate one column, and let a real multi-column page
|
|
// collapse to one. This is the same discipline balancedBodyK2 and
|
|
// detectColumnCount2D already follow.
|
|
fwThr := medianFullWidthFrac * width
|
|
body := make([]pdf.TextBox, 0, len(lines))
|
|
buckets := make([][]float64, k)
|
|
for _, b := range lines {
|
|
if b.X1-b.X0 >= fwThr {
|
|
continue
|
|
}
|
|
body = append(body, b)
|
|
cx := 0.5 * (b.X0 + b.X1)
|
|
norm := (cx - minX0) / width
|
|
if norm < 0 {
|
|
norm = 0
|
|
}
|
|
if norm > 0.999999 {
|
|
norm = 0.999999
|
|
}
|
|
bkt := int(norm * float64(k))
|
|
if bkt > k-1 {
|
|
bkt = k - 1
|
|
}
|
|
buckets[bkt] = append(buckets[bkt], b.X0)
|
|
}
|
|
cents := make([]float64, 0, k)
|
|
for _, bx := range buckets {
|
|
if len(bx) == 0 {
|
|
continue
|
|
}
|
|
var s float64
|
|
for _, x := range bx {
|
|
s += x
|
|
}
|
|
cents = append(cents, s/float64(len(bx)))
|
|
}
|
|
if len(cents) < 2 {
|
|
return 0, nil
|
|
}
|
|
// Require each column to hold enough lines (the same guard used by the
|
|
// rest of the detector) so a sparse side column does not become a false
|
|
// split.
|
|
if pk, pc, ok := pruneColumns(body, cents); ok {
|
|
return pk, pc
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// balancedBodyK2 runs a forced k=2 KMeans on the BODY x0 (full-width front
|
|
// matter excluded) and reports whether the split is a real two-column: two
|
|
// clusters each holding >= minModeFrac of body lines, separated by >=
|
|
// minSepFrac*width. Returns the 2 cluster centroids on success, plus the body
|
|
// slice it clustered on so the caller's prune step counts the SAME line set
|
|
// (otherwise full-width lines re-inflated into one column would let prune
|
|
// collapse a balanced two-column page back to one).
|
|
func balancedBodyK2(lines []pdf.TextBox, minModeFrac, minSepFrac float64) (bool, []float64, []pdf.TextBox) {
|
|
minX0, width := pageExtent(lines)
|
|
if width <= 0 {
|
|
return false, nil, nil
|
|
}
|
|
body := dropFullWidth(lines, width)
|
|
if len(body) < 4 {
|
|
return false, nil, nil
|
|
}
|
|
x0s := make([]float64, len(body))
|
|
for i, b := range body {
|
|
x0s[i] = b.X0
|
|
}
|
|
indentTol := width * 0.12
|
|
sx := snapX0s(x0s, minX0, indentTol)
|
|
labels, cents := kmeansK2PlusPlus(sx, 42)
|
|
if len(uniqueInts(labels)) < 2 {
|
|
return false, nil, nil
|
|
}
|
|
counts := make(map[int]int, 2)
|
|
for _, l := range labels {
|
|
counts[l]++
|
|
}
|
|
minCount := math.MaxInt32
|
|
for _, c := range counts {
|
|
if c < minCount {
|
|
minCount = c
|
|
}
|
|
}
|
|
if float64(minCount) < minModeFrac*float64(len(body)) {
|
|
return false, nil, nil
|
|
}
|
|
if math.Abs(cents[0]-cents[1]) < minSepFrac*width {
|
|
return false, nil, nil
|
|
}
|
|
return true, cents, body
|
|
}
|
|
|
|
// dropFullWidth removes lines whose width spans >=90% of the page text width
|
|
// (titles / abstracts / headings that legitimately bridge a gutter).
|
|
func dropFullWidth(lines []pdf.TextBox, width float64) []pdf.TextBox {
|
|
fwThr := 0.9 * width
|
|
out := make([]pdf.TextBox, 0, len(lines))
|
|
for _, b := range lines {
|
|
if b.X1-b.X0 < fwThr {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
// Every line is full-width: there is no narrow body to form a second
|
|
// column. Return nil (not the original lines) so the caller's
|
|
// len(body) < 4 guard treats the page as a single column instead of
|
|
// pushing the whole page through the balance gate, which could
|
|
// mis-split a full-width single column whose x0 happens to be bimodal.
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pageExtent returns minX0 (leftmost x0) and the text width (maxX1 - minX0).
|
|
func pageExtent(lines []pdf.TextBox) (minX0, width float64) {
|
|
minX0 = math.MaxFloat64
|
|
maxX1 := 0.0
|
|
for _, b := range lines {
|
|
if b.X0 < minX0 {
|
|
minX0 = b.X0
|
|
}
|
|
if b.X1 > maxX1 {
|
|
maxX1 = b.X1
|
|
}
|
|
}
|
|
return minX0, maxX1 - minX0
|
|
}
|
|
|
|
// textProjectionLines returns the lines that should feed the column
|
|
// projection. Image and equation placeholders are excluded because a figure
|
|
// that spans the gutter would otherwise fill the gutter's gap and mask a real
|
|
// column boundary (mirrors pdf-inspector stripping image placeholders). Table
|
|
// boxes are intentionally kept: the existing tableMaxColFrac gate already
|
|
// handles narrow table columns, and dropping them here would widen the blast
|
|
// radius unnecessarily.
|
|
func textProjectionLines(lines []pdf.TextBox) []pdf.TextBox {
|
|
out := make([]pdf.TextBox, 0, len(lines))
|
|
for _, b := range lines {
|
|
switch b.LayoutType {
|
|
case pdf.LayoutTypeFigure, pdf.LayoutTypeEquation:
|
|
continue
|
|
default:
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// robustPageExtent returns the X extent (minX0, width) of the text body,
|
|
// discarding a small number of outlier lines (malformed coordinates / stray
|
|
// boxes) that would otherwise balloon the extent and collapse multi-column
|
|
// detection. Normal pages return exactly pageExtent's result.
|
|
func robustPageExtent(lines []pdf.TextBox) (minX0, width float64) {
|
|
if len(lines) == 0 {
|
|
return 0, 0
|
|
}
|
|
// Drop implausibly wide (malformed) lines outright: a single box with an
|
|
// absurd X1 (e.g. 1e6) would otherwise set the page width to 1e6.
|
|
work := make([]pdf.TextBox, 0, len(lines))
|
|
dropped := 0
|
|
for _, b := range lines {
|
|
if b.X1-b.X0 > maxPageExtent {
|
|
dropped++
|
|
continue
|
|
}
|
|
work = append(work, b)
|
|
}
|
|
if len(work) == 0 || float64(dropped)/float64(len(lines)) >= maxTrimFraction {
|
|
// Everything malformed, or too many dropped: the outliers are the
|
|
// norm. Fall back to the raw extent so the page is never altered.
|
|
return pageExtent(lines)
|
|
}
|
|
// Cluster lines by left edge; discard clusters separated from the main
|
|
// body by more than a full page width, provided they are a minority.
|
|
return clusteredExtent(work)
|
|
}
|
|
|
|
// clusteredExtent keeps the largest cluster of lines (by count) and returns its
|
|
// extent, but only when the discarded minority is below maxTrimFraction;
|
|
// otherwise it returns the raw extent of all lines. This catches outliers whose
|
|
// width alone is plausible (e.g. a tiny box placed at an absurd X coordinate).
|
|
func clusteredExtent(lines []pdf.TextBox) (minX0, width float64) {
|
|
if len(lines) <= 1 {
|
|
return pageExtent(lines)
|
|
}
|
|
xs := make([]float64, len(lines))
|
|
for i, b := range lines {
|
|
xs[i] = b.X0
|
|
}
|
|
sort.Float64s(xs)
|
|
// Split into clusters at gaps larger than a full page.
|
|
clusters := [][]float64{{xs[0]}}
|
|
for i := 1; i < len(xs); i++ {
|
|
if xs[i]-xs[i-1] > maxPageExtent {
|
|
clusters = append(clusters, []float64{xs[i]})
|
|
} else {
|
|
clusters[len(clusters)-1] = append(clusters[len(clusters)-1], xs[i])
|
|
}
|
|
}
|
|
if len(clusters) == 1 {
|
|
return pageExtent(lines)
|
|
}
|
|
best := 0
|
|
for i := 1; i < len(clusters); i++ {
|
|
if len(clusters[i]) > len(clusters[best]) {
|
|
best = i
|
|
}
|
|
}
|
|
dropped := len(lines) - len(clusters[best])
|
|
if float64(dropped)/float64(len(lines)) >= maxTrimFraction {
|
|
return pageExtent(lines)
|
|
}
|
|
lo, hi := clusters[best][0], clusters[best][0]
|
|
for _, x := range clusters[best] {
|
|
if x < lo {
|
|
lo = x
|
|
}
|
|
if x > hi {
|
|
hi = x
|
|
}
|
|
}
|
|
return lo, hi - lo
|
|
}
|
|
|
|
// snapX0s pulls x0 values within indentTol of minX0 back to minX0, so slightly
|
|
// indented lines still cluster with the left edge (mirrors _assign_column).
|
|
func snapX0s(x0s []float64, minX0, indentTol float64) []float64 {
|
|
out := make([]float64, len(x0s))
|
|
for i, v := range x0s {
|
|
if math.Abs(v-minX0) < indentTol {
|
|
out[i] = minX0
|
|
} else {
|
|
out[i] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// kmeansK2PlusPlus is a density-aware k=2 clustering (k-means++ init, single
|
|
// Lloyd pass). Unlike util.KMeans1D (even-spaced init, a range partition), the
|
|
// first center is a random data point and the second is the farthest point, so
|
|
// it respects natural x0 density — required for the balance check to reject a
|
|
// single column whose x0 merely has a wide range. Deterministic via seed.
|
|
func kmeansK2PlusPlus(x0s []float64, seed int64) ([]int, []float64) {
|
|
n := len(x0s)
|
|
labels := make([]int, n)
|
|
if n == 0 {
|
|
return labels, nil
|
|
}
|
|
rng := rand.New(rand.NewSource(seed))
|
|
first := rng.Intn(n)
|
|
c0 := x0s[first]
|
|
bestJ, bestD := 0, -1.0
|
|
for j, v := range x0s {
|
|
d := (v - c0) * (v - c0)
|
|
if d > bestD {
|
|
bestD, bestJ = d, j
|
|
}
|
|
}
|
|
c1 := x0s[bestJ]
|
|
cents := []float64{c0, c1}
|
|
for iter := 0; iter < 100; iter++ {
|
|
changed := false
|
|
for i, v := range x0s {
|
|
bestC := 0
|
|
if math.Abs(v-c1) < math.Abs(v-c0) {
|
|
bestC = 1
|
|
}
|
|
if labels[i] != bestC {
|
|
changed = true
|
|
labels[i] = bestC
|
|
}
|
|
}
|
|
if !changed {
|
|
break
|
|
}
|
|
sum := [2]float64{}
|
|
cnt := [2]int{}
|
|
for i, v := range x0s {
|
|
sum[labels[i]] += v
|
|
cnt[labels[i]]++
|
|
}
|
|
for c := 0; c < 2; c++ {
|
|
if cnt[c] > 0 {
|
|
cents[c] = sum[c] / float64(cnt[c])
|
|
}
|
|
}
|
|
}
|
|
return labels, cents
|
|
}
|
|
|
|
// kmeansCentroids returns the k cluster centroids from util.KMeans1D on the
|
|
// snapped x0s of all lines; used to partition a page when gap reports >=2.
|
|
func kmeansCentroids(lines []pdf.TextBox, k int, width float64) []float64 {
|
|
minX0, _ := pageExtent(lines)
|
|
x0s := make([]float64, len(lines))
|
|
for i, b := range lines {
|
|
x0s[i] = b.X0
|
|
}
|
|
sx := snapX0s(x0s, minX0, width*0.12)
|
|
_, cents := util.KMeans1D(sx, k)
|
|
return cents
|
|
}
|
|
|
|
// assignColIDs sets ColID for a page's boxes by nearest centroid, remapped so
|
|
// the leftmost centroid becomes column 0.
|
|
func assignColIDs(boxes, result []pdf.TextBox, indices []int, k int, cents []float64) {
|
|
if k <= 1 || len(cents) == 0 {
|
|
for _, idx := range indices {
|
|
result[idx].ColID = 0
|
|
}
|
|
return
|
|
}
|
|
order := make([]int, len(cents))
|
|
idxByVal := make([]int, len(cents))
|
|
for i := range cents {
|
|
idxByVal[i] = i
|
|
}
|
|
sort.Slice(idxByVal, func(a, b int) bool { return cents[idxByVal[a]] < cents[idxByVal[b]] })
|
|
for newL, oldL := range idxByVal {
|
|
order[oldL] = newL
|
|
}
|
|
for _, idx := range indices {
|
|
x := boxes[idx].X0
|
|
best, bestD := 0, math.Abs(x-cents[0])
|
|
for c := 1; c < len(cents); c++ {
|
|
if d := math.Abs(x - cents[c]); d < bestD {
|
|
bestD, best = d, c
|
|
}
|
|
}
|
|
result[idx].ColID = order[best]
|
|
}
|
|
}
|
|
|
|
func uniqueInts(xs []int) []int {
|
|
seen := make(map[int]struct{}, len(xs))
|
|
for _, x := range xs {
|
|
seen[x] = struct{}{}
|
|
}
|
|
out := make([]int, 0, len(seen))
|
|
for x := range seen {
|
|
out = append(out, x)
|
|
}
|
|
return out
|
|
}
|