Fix centered PDF title reading order (#17009)

This commit is contained in:
zcxGGmu
2026-07-18 01:31:27 +08:00
committed by GitHub
parent 982b9c7b25
commit 1c0432a816
3 changed files with 116 additions and 8 deletions

View File

@@ -357,10 +357,9 @@ func mergeTwoBoxes(prev, curr pdf.TextBox) pdf.TextBox {
}
// processPageBoxes vertically merges the boxes of a single page. Boxes are
// bucketed by column (ColID) first so the merge never crosses columns; within
// a column the merge runs top→bottom, and columns are emitted in ascending
// ColID order (leftmost first). This preserves the column-major reading order
// established by FinalReadingOrderMerge (page → ColID → top → x0).
// bucketed by column first so merges never cross columns. Titles that precede
// all non-title content and occupy their own column are moved ahead of the
// column groups.
func processPageBoxes(boxes []pdf.TextBox, mh, mw float64, isEnglish bool) []pdf.TextBox {
if len(boxes) == 0 {
return boxes
@@ -384,7 +383,7 @@ func processPageBoxes(boxes []pdf.TextBox, mh, mw float64, isEnglish bool) []pdf
})
out = append(out, mergeColumnBoxes(bxs, mh, mw, isEnglish)...)
}
return out
return moveLeadingTitlesFirst(out)
}
// groupBoxesByCol groups boxes by column id and returns the groups plus the
@@ -444,6 +443,35 @@ func mergeColumnBoxes(sortedBoxes []pdf.TextBox, mh, mw float64, isEnglish bool)
return out
}
func moveLeadingTitlesFirst(boxes []pdf.TextBox) []pdf.TextBox {
firstNonTitleTop := math.Inf(1)
colsWithNonTitle := make(map[int]struct{})
for _, box := range boxes {
if box.LayoutType != pdf.LayoutTypeTitle {
firstNonTitleTop = math.Min(firstNonTitleTop, box.Top)
colsWithNonTitle[box.ColID] = struct{}{}
}
}
titles := make([]pdf.TextBox, 0)
rest := make([]pdf.TextBox, 0, len(boxes))
for _, box := range boxes {
_, sharesColumnWithContent := colsWithNonTitle[box.ColID]
if box.LayoutType == pdf.LayoutTypeTitle && !sharesColumnWithContent && box.Bottom <= firstNonTitleTop {
titles = append(titles, box)
continue
}
rest = append(rest, box)
}
sort.SliceStable(titles, func(i, j int) bool {
if titles[i].Top != titles[j].Top {
return titles[i].Top < titles[j].Top
}
return titles[i].X0 < titles[j].X0
})
return append(titles, rest...)
}
// ---- rune-based text helpers (CJK-safe) ----
func lastRune(s string) rune {

View File

@@ -516,6 +516,27 @@ func TestNaiveVerticalMergeNonMerge(t *testing.T) {
}
}
func TestNaiveVerticalMerge_CenteredTitlePreservesPageOrder(t *testing.T) {
boxes := []pdf.TextBox{
{PageNumber: 0, X0: 250, X1: 450, Top: 50, Bottom: 70, Text: "Document Title", LayoutNo: "title", LayoutType: pdf.LayoutTypeTitle},
{PageNumber: 0, X0: 50, X1: 650, Top: 100, Bottom: 112, Text: "First paragraph.", LayoutNo: "body"},
{PageNumber: 0, X0: 50, X1: 650, Top: 200, Bottom: 212, Text: "Second paragraph.", LayoutNo: "body"},
}
boxes = AssignColumn(boxes)
result := NaiveVerticalMerge(boxes, map[int]float64{0: 12}, map[int]float64{0: 5}, map[int]bool{0: true})
want := []string{"Document Title", "First paragraph.", "Second paragraph."}
if len(result) != len(want) {
t.Fatalf("expected %d boxes, got %d", len(want), len(result))
}
for i, text := range want {
if result[i].Text != text {
t.Errorf("position %d: want %q, got %q", i, text, result[i].Text)
}
}
}
// TestNaiveVerticalMerge_MultiColumnOrder guards against the multi-column
// reading-order regression: after AssignColumn assigns ColID, the final
// reading order must be column-major (all of column 0, then all of column 1),
@@ -582,6 +603,66 @@ func TestNaiveVerticalMerge_MultiColumnOrder(t *testing.T) {
}
}
func TestNaiveVerticalMerge_LeadingTitlePreservesMultiColumnOrder(t *testing.T) {
boxes := []pdf.TextBox{
{PageNumber: 0, ColID: 2, X0: 250, X1: 450, Top: 50, Bottom: 70, Text: "Document Title", LayoutNo: "title", LayoutType: pdf.LayoutTypeTitle},
{PageNumber: 0, ColID: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "L0-a", LayoutNo: "left"},
{PageNumber: 0, ColID: 0, X0: 50, X1: 250, Top: 300, Bottom: 312, Text: "L0-b", LayoutNo: "left"},
{PageNumber: 0, ColID: 1, X0: 400, X1: 600, Top: 150, Bottom: 162, Text: "R0-a", LayoutNo: "right"},
{PageNumber: 0, ColID: 1, X0: 400, X1: 600, Top: 250, Bottom: 262, Text: "R0-b", LayoutNo: "right"},
}
result := NaiveVerticalMerge(boxes, map[int]float64{0: 12}, map[int]float64{0: 5}, nil)
want := []string{"Document Title", "L0-a", "L0-b", "R0-a", "R0-b"}
if len(result) != len(want) {
t.Fatalf("expected %d boxes, got %d", len(want), len(result))
}
for i, text := range want {
if result[i].Text != text {
t.Errorf("position %d: want %q, got %q", i, text, result[i].Text)
}
}
}
func TestNaiveVerticalMerge_DoesNotPromoteColumnTitlePastEarlierBody(t *testing.T) {
boxes := []pdf.TextBox{
{PageNumber: 0, ColID: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "Left body", LayoutNo: "left"},
{PageNumber: 0, ColID: 1, X0: 400, X1: 600, Top: 150, Bottom: 162, Text: "Right heading", LayoutNo: "right-title", LayoutType: pdf.LayoutTypeTitle},
{PageNumber: 0, ColID: 1, X0: 400, X1: 600, Top: 200, Bottom: 212, Text: "Right body", LayoutNo: "right"},
}
result := NaiveVerticalMerge(boxes, map[int]float64{0: 12}, map[int]float64{0: 5}, nil)
want := []string{"Left body", "Right heading", "Right body"}
if len(result) != len(want) {
t.Fatalf("expected %d boxes, got %d", len(want), len(result))
}
for i, text := range want {
if result[i].Text != text {
t.Errorf("position %d: want %q, got %q", i, text, result[i].Text)
}
}
}
func TestNaiveVerticalMerge_DoesNotPromoteTitlesFromBodyColumns(t *testing.T) {
boxes := []pdf.TextBox{
{PageNumber: 0, ColID: 0, X0: 50, X1: 250, Top: 50, Bottom: 62, Text: "Left heading", LayoutNo: "left-title", LayoutType: pdf.LayoutTypeTitle},
{PageNumber: 0, ColID: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "Left body", LayoutNo: "left"},
{PageNumber: 0, ColID: 1, X0: 400, X1: 600, Top: 60, Bottom: 72, Text: "Right heading", LayoutNo: "right-title", LayoutType: pdf.LayoutTypeTitle},
{PageNumber: 0, ColID: 1, X0: 400, X1: 600, Top: 120, Bottom: 132, Text: "Right body", LayoutNo: "right"},
}
result := NaiveVerticalMerge(boxes, map[int]float64{0: 12}, map[int]float64{0: 5}, nil)
want := []string{"Left heading", "Left body", "Right heading", "Right body"}
if len(result) != len(want) {
t.Fatalf("expected %d boxes, got %d", len(want), len(result))
}
for i, text := range want {
if result[i].Text != text {
t.Errorf("position %d: want %q, got %q", i, text, result[i].Text)
}
}
}
func TestFinalReadingOrderMerge_ColumnMajor(t *testing.T) {
// Same interleaved scenario as the pipeline test, but at the
// FinalReadingOrderMerge level: column must dominate vertical position.

View File

@@ -456,9 +456,8 @@ func (p *Parser) buildLayout(ctx context.Context,
boxes = lyt.TextMerge(boxes, medianHeights)
result.Metrics.BoxesTextMerge = len(boxes)
// FinalReadingOrderMerge sorts page → column (ColID) → top → x0, which
// is the correct multi-column reading order. NaiveVerticalMerge preserves
// this column-major order (it buckets by ColID before merging).
// Preserve column-major content order while NaiveVerticalMerge promotes a
// page-leading title isolated in its own column.
boxes = lyt.FinalReadingOrderMerge(boxes)
boxes = lyt.NaiveVerticalMerge(boxes, medianHeights, medianWidths, pageEnglish)