From 72a85b2f9427d823f09246922dcc879064490413 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 12 Oct 2026 09:32:41 +0200 Subject: [PATCH] TablesInRuns: a caller who knows more can take the lines out first A numbered heading above a table leaves a gap after its number; the table below it leaves that same gap empty on every row; so the two become ONE two-column table -- and the real four-column table under it is lost, because the heading's own text reaches across every one of its boundaries. Measured on a 6-page technical paper: page 2's four-column comparison came back as two columns, the first of them holding "2." and nothing else. Nothing in the ALIGNMENT says that line is a heading. Its SIZE says so, and what counts as a heading size is a property of the whole document -- which is go-pdfkit/document's business, not this package's. So the detection is exposed over runs the caller chose, and the caller who knows takes the line out and asks again. The test asserts BOTH readings of the same page: the swallowed two-column one when asked about the page, and the four-column one when asked about the runs below the heading. A test that only asserted the second would not show what the first costs. 100% of statements, no uncovered blocks. Co-Authored-By: Claude Opus 5 --- tables.go | 22 +++++++++++++++++++- tables_test.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/tables.go b/tables.go index eb72d39..ca9fd76 100644 --- a/tables.go +++ b/tables.go @@ -88,7 +88,27 @@ func TablesWithOptions(d *reader.Document, page int, o TableOptions) ([]Table, e if err != nil { return nil, err } - return tablesIn(runs, page, o.withDefaults()), nil + return TablesInRuns(runs, page, o), nil +} + +// TablesInRuns is [TablesWithOptions] over runs a caller chose, and page is +// only the number it will put in each [Table.Page]. +// +// ⛔ It exists so that a caller who knows something this does not can take +// lines OUT before asking. A numbered heading above a table is the case that +// made it necessary: "2. Vue d'ensemble" leaves a gap after its number, the +// table below it leaves that same gap empty on every row, so the heading and +// the table became ONE two-column table — and the real four-column table +// under it was lost, because the heading's own text reached across every one +// of its boundaries. +// +// Nothing in the alignment says that line is a heading. Its SIZE says so, and +// what counts as a heading size is a property of the whole document, which is +// [go-pdfkit/document]'s business and not this package's. +// +// [go-pdfkit/document]: https://pkg.go.dev/github.com/go-pdfkit/document +func TablesInRuns(runs []Run, page int, o TableOptions) []Table { + return tablesIn(runs, page, o.withDefaults()) } // withDefaults fills in the fields left at zero. diff --git a/tables_test.go b/tables_test.go index 001af71..bc93423 100644 --- a/tables_test.go +++ b/tables_test.go @@ -639,3 +639,58 @@ func TestLinesAndJoinAgreeWithAssemble(t *testing.T) { t.Errorf("the first line reads %q", Join(lines[0])) } } + +// ⛔ THE CASE THAT MADE TablesInRuns NECESSARY. A numbered heading leaves a gap +// after its number; the table below it leaves that same gap empty on every +// row; so the two become ONE two-column table, and the real four-column table +// is lost because the heading's own text reaches across every boundary of it. +// +// Nothing in the alignment says that line is a heading — its SIZE does, and +// what counts as a heading size is a property of the whole document. So the +// caller who knows takes the line out and asks again. +func TestAHeadingAboveATableSwallowsIt(t *testing.T) { + d := pageWith(t, strings.Join([]string{ + // "2." at 20, its text at 40 and running to 170. + "BT /F1 12 Tf 20 180 Td (2.) Tj 20 0 Td (Vue d'ensemble technique) Tj ET", + "BT /F1 10 Tf 40 160 Td (Name) Tj 60 0 Td (Age) Tj 60 0 Td (City) Tj ET", + "BT /F1 10 Tf 40 145 Td (Ada) Tj 60 0 Td (36) Tj 60 0 Td (Caen) Tj ET", + "BT /F1 10 Tf 40 130 Td (Bob) Tj 60 0 Td (41) Tj 60 0 Td (Lyon) Tj ET", + }, "\n"), nil) + + // Asked about the page, the heading is in and the table is two columns of + // nothing useful. + whole, err := Tables(d, 1) + if err != nil { + t.Fatal(err) + } + if len(whole) != 1 { + t.Fatalf("%d tables, want 1: %+v", len(whole), whole) + } + if n := len(whole[0].Rows[0]); n != 2 { + t.Errorf("%d columns with the heading in, want 2 — the defect: %q", n, whole[0].Rows) + } + + // Asked about the runs below it, the real table comes back. + runs, err := Runs(d, 1) + if err != nil { + t.Fatal(err) + } + var below []Run + for _, r := range runs { + if r.Size < 12 { + below = append(below, r) + } + } + got := TablesInRuns(below, 1, TableOptions{}) + if len(got) != 1 { + t.Fatalf("%d tables without the heading, want 1: %+v", len(got), got) + } + want := [][]string{{"Name", "Age", "City"}, {"Ada", "36", "Caen"}, {"Bob", "41", "Lyon"}} + if !reflect.DeepEqual(got[0].Rows, want) { + t.Errorf("without the heading the table reads %q, want %q", got[0].Rows, want) + } + // And the page number is the caller's to say, since runs do not carry one. + if got[0].Page != 1 { + t.Errorf("the table says it is on page %d", got[0].Page) + } +}