From 1c0432a81644cd89a670ab3d32b2bd2d8f3b2b92 Mon Sep 17 00:00:00 2001 From: zcxGGmu <72263081+zcxGGmu@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:31:27 +0800 Subject: [PATCH] Fix centered PDF title reading order (#17009) --- internal/deepdoc/parser/pdf/layout/layout.go | 38 +++++++-- .../deepdoc/parser/pdf/layout/layout_test.go | 81 +++++++++++++++++++ internal/deepdoc/parser/pdf/parser.go | 5 +- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/internal/deepdoc/parser/pdf/layout/layout.go b/internal/deepdoc/parser/pdf/layout/layout.go index 1dc840d488..d898763632 100644 --- a/internal/deepdoc/parser/pdf/layout/layout.go +++ b/internal/deepdoc/parser/pdf/layout/layout.go @@ -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 { diff --git a/internal/deepdoc/parser/pdf/layout/layout_test.go b/internal/deepdoc/parser/pdf/layout/layout_test.go index dd12e943af..4626a031a1 100644 --- a/internal/deepdoc/parser/pdf/layout/layout_test.go +++ b/internal/deepdoc/parser/pdf/layout/layout_test.go @@ -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. diff --git a/internal/deepdoc/parser/pdf/parser.go b/internal/deepdoc/parser/pdf/parser.go index 79a0017f05..a1a9184b31 100644 --- a/internal/deepdoc/parser/pdf/parser.go +++ b/internal/deepdoc/parser/pdf/parser.go @@ -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)