From c5bb17f115577e2dba27863bfc3da61907716179 Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Fri, 23 Oct 2026 15:27:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E5=89=8D=E3=81=AB=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E3=81=8C=E3=81=82=E3=82=8B=E3=81=A8=E3=83=86=E3=83=BC=E3=83=96?= =?UTF-8?q?=E3=83=AB=E3=81=8C2=E3=83=9A=E3=83=BC=E3=82=B8=E7=9B=AE?= =?UTF-8?q?=E3=81=B8=E7=A7=BB=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IssueRunner #2 --- CHANGELOG.md | 5 ++ .../builder/39_table_after_content_test.go | 51 ++++++++++++++ .../gotemplate/39_table_after_content_test.go | 56 +++++++++++++++ _examples/json/39_table_after_content_test.go | 52 ++++++++++++++ .../golden/39_table_after_content.pdf | Bin 0 -> 8290 bytes _examples/testutil/pages.go | 66 ++++++++++++++++++ template/grid.go | 29 +++++++- template/template_test.go | 32 +++++++++ 8 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 _examples/builder/39_table_after_content_test.go create mode 100644 _examples/gotemplate/39_table_after_content_test.go create mode 100644 _examples/json/39_table_after_content_test.go create mode 100644 _examples/testdata/golden/39_table_after_content.pdf create mode 100644 _examples/testutil/pages.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7906b65..35f7774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - JSON / GoTemplate schema: `table.columnAlign: ["left", "center", "right"]` - Example tests: `_examples/{builder,json,gotemplate}/38_table_column_align_test.go` with shared golden +### Fixed +- A multi-page table preceded by other rows no longer moves entirely to the next page, leaving the first page mostly blank. Since #24 every row was `BreakInside=BreakAvoid`, so a row containing a table that did not fit in the remaining space was deferred as a whole; the table now starts on the current page, repeats its header on each page, and renders every body row exactly once + - `template/grid.go`: `RowBuilder.build()` keeps `BreakAvoid` for ordinary rows but uses `BreakAuto` for rows that contain a table (tables paginate themselves) + - Example tests: `_examples/{builder,json,gotemplate}/39_table_after_content_test.go` with shared golden + ## [1.0.9] - 2026-05-06 ### Fixed diff --git a/_examples/builder/39_table_after_content_test.go b/_examples/builder/39_table_after_content_test.go new file mode 100644 index 0000000..d702130 --- /dev/null +++ b/_examples/builder/39_table_after_content_test.go @@ -0,0 +1,51 @@ +package builder_test + +import ( + "fmt" + "testing" + + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/document" + "github.com/gpdf-dev/gpdf/template" +) + +// TestExample_39_TableAfterContent is a regression test: a multi-page table +// preceded by other rows must start on the first page and paginate from +// there (header repeated on each page), instead of the whole table being +// pushed to page 2 and leaving page 1 mostly blank. Rows were made atomic +// (BreakAvoid) for issue #24; rows containing a table must stay splittable. +func TestExample_39_TableAfterContent(t *testing.T) { + doc := template.New( + template.WithPageSize(document.A4), + template.WithMargins(document.UniformEdges(document.Mm(15))), + ) + + rows := make([][]string, 60) + ids := make([]string, len(rows)) + for i := range rows { + ids[i] = fmt.Sprintf("Item-%03d", i+1) + rows[i] = []string{ids[i], fmt.Sprintf("%d", (i+1)*100)} + } + + page := doc.AddPage() + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("Table After Content", template.FontSize(18), template.Bold()) + c.Text("Introductory paragraph before the table.") + c.Spacer(document.Mm(5)) + }) + }) + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Table([]string{"Item", "Amount"}, rows) + }) + }) + + testutil.GeneratePDFSharedGolden(t, "39_table_after_content.pdf", doc) + + data, err := doc.Generate() + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + testutil.AssertTableSplitsFromFirstPage(t, data, "Table After Content", "Item", ids) +} diff --git a/_examples/gotemplate/39_table_after_content_test.go b/_examples/gotemplate/39_table_after_content_test.go new file mode 100644 index 0000000..1e2d3a5 --- /dev/null +++ b/_examples/gotemplate/39_table_after_content_test.go @@ -0,0 +1,56 @@ +package gotemplate_test + +import ( + "fmt" + "testing" + + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/template" +) + +// TestTmpl_39_TableAfterContent mirrors the builder regression test: a +// multi-page table preceded by other rows must start on the first page. +func TestTmpl_39_TableAfterContent(t *testing.T) { + schema := []byte(`{ + "page": {"size": "A4", "margins": "15mm"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "{{.Title}}", "style": {"size": 18, "bold": true}}, + {"type": "text", "content": "{{.Intro}}"}, + {"type": "spacer", "height": "5mm"} + ]} + ]}}, + {"row": {"cols": [ + {"span": 12, "table": { + "header": ["Item", "Amount"], + "rows": {{toJSON .Rows}} + }} + ]}} + ] + }`) + + ids := make([]string, 60) + rows := make([][]string, len(ids)) + for i := range ids { + ids[i] = fmt.Sprintf("Item-%03d", i+1) + rows[i] = []string{ids[i], fmt.Sprintf("%d", (i+1)*100)} + } + data := map[string]any{ + "Title": "Table After Content", + "Intro": "Introductory paragraph before the table.", + "Rows": rows, + } + + doc, err := template.FromJSON(schema, data) + if err != nil { + t.Fatalf("FromJSON error: %v", err) + } + testutil.GeneratePDFSharedGolden(t, "39_table_after_content.pdf", doc) + + out, err := doc.Generate() + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + testutil.AssertTableSplitsFromFirstPage(t, out, "Table After Content", "Item", ids) +} diff --git a/_examples/json/39_table_after_content_test.go b/_examples/json/39_table_after_content_test.go new file mode 100644 index 0000000..04168f5 --- /dev/null +++ b/_examples/json/39_table_after_content_test.go @@ -0,0 +1,52 @@ +package json_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/template" +) + +// TestJSON_39_TableAfterContent mirrors the builder regression test: a +// multi-page table preceded by other rows must start on the first page. +func TestJSON_39_TableAfterContent(t *testing.T) { + ids := make([]string, 60) + rowJSON := make([]string, len(ids)) + for i := range ids { + ids[i] = fmt.Sprintf("Item-%03d", i+1) + rowJSON[i] = fmt.Sprintf(`["%s", "%d"]`, ids[i], (i+1)*100) + } + + schema := []byte(fmt.Sprintf(`{ + "page": {"size": "A4", "margins": "15mm"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "Table After Content", "style": {"size": 18, "bold": true}}, + {"type": "text", "content": "Introductory paragraph before the table."}, + {"type": "spacer", "height": "5mm"} + ]} + ]}}, + {"row": {"cols": [ + {"span": 12, "table": { + "header": ["Item", "Amount"], + "rows": [%s] + }} + ]}} + ] + }`, strings.Join(rowJSON, ","))) + + doc, err := template.FromJSON(schema, nil) + if err != nil { + t.Fatalf("FromJSON error: %v", err) + } + testutil.GeneratePDFSharedGolden(t, "39_table_after_content.pdf", doc) + + data, err := doc.Generate() + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + testutil.AssertTableSplitsFromFirstPage(t, data, "Table After Content", "Item", ids) +} diff --git a/_examples/testdata/golden/39_table_after_content.pdf b/_examples/testdata/golden/39_table_after_content.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2a4093ac4b6ae71d82430ae08114afa554c80950 GIT binary patch literal 8290 zcmc(lO>f*b5Qgsy^gr+tAURmeZ%RNAAa-LkXoDKg-hv+Ftk!nmtPHJOB>nXrY9%eH zB|CHLA|H;Rmm^XypP3!z)y=Qh7ou=0#W-v4zOCNAW$VlO&|J6so~?gtp8jt7$FKFp zW&5;c>(4KD{mt480C*RPpQ-7B18%QV>@aCJv&}Ac01cf`G?DFTlbXG)SN^ zq#%Yb&0x_WfyR=8D4REfL&GK2jucpmr5Q>zT%ajQLAYC*p+bWMnu-#{fUQMlKZ*Q) z9QRXN4I4SHoOiNu<-#EX#YLzQBwiS+vlbC3FVbp30*2Am!Xp9&MyL@aVi;u>CJ`tx zLX99H!+>|QEB=JbW z{s8x+BcnV@t0jp?$__>DDY6bHjRGknmn0%7?Q7p))*%%nN~DZj!hqSbV%|wg4dw4V zZ}@rv-*G-3Esg>yt(GJnDKXT<>BuOV(rQU!l!Cn#x=e-{K;;*0#8pNPTPrV}*BhSD zlj}6=kSPjC7{s zeH|aXYiOa(2}Gkj%g8l}XF~Z({uDbzsYZF0RzuN7nYHkBd=NQ&+ZqL$NXs>eXo3%Q z{OmLnO&cXzMy^RjgY6X>&4f_VDA3YsP2!mw3jTT0k;74%CB>S=G8=yduwnYpD9h4n zO=6jjzW_kzlfRHaL^VdvBXV`_g@F4$L0|3~l}42f_UD01S35neXo3LKoiu8jb<{ zAoq>$%a2dv;Ew4iVeH%saA~GF44L7Rqa_tJhjwE7`mjA+$wjn04z*x{-KK2 zDEwWd(j?|PQx;|s$>hm3%`Q^G#>*>a!aJ=C=x#)FGAP(%l_Bxm$tpY~8CK7r9FJ9o z#BV1|#vd2lp!iO!4T;{c_0Jq)P-w?0Lt?cp?YToxM#m~cVzUMRdWXd4WXJE228DB4 zZD@2h?%Xjrl_M4GBEl}PTIC&s(m1U)0gdNV$mt+$-hWn>=Z-;{5~~b}DOTt+$JqSZ zj7*7D@Y-g@d4?$irq3D1gZ_WXFg{&P8Rp8Lg+c;b|Ngi=urEFp-ZKBVw~*faG4(M& z9fAZ^1@fEWJQq%cU*A7(cW;8bM0giuxWkkskAq&-ef`wl2ZA46vxAdl!B=y&!b?V|K5*eprQ)3%Ppr K>cgMctA7FSkpQFs literal 0 HcmV?d00001 diff --git a/_examples/testutil/pages.go b/_examples/testutil/pages.go new file mode 100644 index 0000000..0e7df8b --- /dev/null +++ b/_examples/testutil/pages.go @@ -0,0 +1,66 @@ +package testutil + +import ( + "regexp" + "strings" + "testing" +) + +var ( + pageContentsRef = regexp.MustCompile(`<< /Contents (\d+) 0 R [^>]*>> >> /Type /Page >>`) + streamObject = regexp.MustCompile(`(?s)(?:^|\n)(\d+) 0 obj\n<< /Length \d+ >>\nstream\n(.*?)endstream`) +) + +// PageContents returns the uncompressed content stream of each page in +// the generated PDF, in page order. It only understands the plain +// (unfiltered, unencrypted) output produced by gpdf's default writer. +func PageContents(t *testing.T, data []byte) []string { + t.Helper() + streams := map[string]string{} + for _, m := range streamObject.FindAllSubmatch(data, -1) { + streams[string(m[1])] = string(m[2]) + } + var pages []string + for _, m := range pageContentsRef.FindAllSubmatch(data, -1) { + content, ok := streams[string(m[1])] + if !ok { + t.Fatalf("content stream %s 0 obj not found", m[1]) + } + pages = append(pages, content) + } + if len(pages) == 0 { + t.Fatal("no pages found in PDF") + } + return pages +} + +// AssertTableSplitsFromFirstPage checks that a table preceded by other +// content starts on the first page (right after intro), that its header +// is repeated on every page, and that every body row is rendered exactly +// once across the whole document. +func AssertTableSplitsFromFirstPage(t *testing.T, data []byte, intro, header string, rows []string) { + t.Helper() + pages := PageContents(t, data) + if len(pages) < 2 { + t.Fatalf("expected the table to span multiple pages, got %d page(s)", len(pages)) + } + lit := func(s string) string { return "(" + s + ")" } + + if !strings.Contains(pages[0], lit(intro)) { + t.Errorf("expected %q on page 1", intro) + } + if !strings.Contains(pages[0], lit(rows[0])) { + t.Errorf("expected first table row %q on page 1 (table must start on the first page)", rows[0]) + } + for i, p := range pages { + if got := strings.Count(p, lit(header)); got != 1 { + t.Errorf("page %d: expected table header %q once, got %d", i+1, header, got) + } + } + all := strings.Join(pages, "\n") + for _, r := range rows { + if got := strings.Count(all, lit(r)); got != 1 { + t.Errorf("expected row %q exactly once, got %d", r, got) + } + } +} diff --git a/template/grid.go b/template/grid.go index 759156d..4d65183 100644 --- a/template/grid.go +++ b/template/grid.go @@ -135,14 +135,16 @@ func (r *RowBuilder) Col(span int, fn func(c *ColBuilder)) { // that a row which does not fit in the remaining vertical space on the // current page is moved as a whole to the next page, rather than splitting // between its columns. +// +// Rows containing a table are the exception: tables paginate themselves +// (splitting between body rows and repeating the header), so such rows use +// BreakAuto and the table starts on the current page instead of being +// pushed to the next one. func (r *RowBuilder) build(height document.Value, auto bool) document.DocumentNode { box := &document.Box{ BoxStyle: document.BoxStyle{ Direction: document.DirectionHorizontal, }, - BreakPolicy: document.BreakPolicy{ - BreakInside: document.BreakAvoid, - }, } if !auto { @@ -164,9 +166,30 @@ func (r *RowBuilder) build(height document.Value, auto bool) document.DocumentNo box.Content = append(box.Content, colBox) } + if !containsTable(box.Content) { + box.BreakPolicy.BreakInside = document.BreakAvoid + } + return box } +// containsTable reports whether any of the given nodes, or their +// descendants, is a table. +func containsTable(nodes []document.DocumentNode) bool { + for _, n := range nodes { + if n == nil { + continue + } + if n.NodeType() == document.NodeTable { + return true + } + if containsTable(n.Children()) { + return true + } + } + return false +} + // ColBuilder constructs content within a column. type ColBuilder struct { doc *Document diff --git a/template/template_test.go b/template/template_test.go index 2f32b2f..6f762e9 100644 --- a/template/template_test.go +++ b/template/template_test.go @@ -2038,3 +2038,35 @@ func TestRowBuildSetsBreakInsideAvoid(t *testing.T) { }) } } + +// TestRowBuildTableRowIsSplittable verifies that rows containing a table +// (directly or nested in a column box) use BreakInside=BreakAuto, so the +// table starts on the current page and paginates itself with a repeated +// header instead of the whole row being pushed to the next page. +func TestRowBuildTableRowIsSplittable(t *testing.T) { + tests := []struct { + name string + fn func(c *ColBuilder) + }{ + {"Direct", func(c *ColBuilder) { + c.Table([]string{"A"}, [][]string{{"1"}}) + }}, + {"Nested", func(c *ColBuilder) { + c.nodes = append(c.nodes, &document.Box{Content: []document.DocumentNode{&document.Table{}}}) + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rb := &RowBuilder{} + rb.Col(4, func(c *ColBuilder) { c.Text("side") }) + rb.Col(8, tc.fn) + box, ok := rb.build(document.Mm(10), true).(*document.Box) + if !ok { + t.Fatal("expected *document.Box") + } + if box.BreakPolicy.BreakInside != document.BreakAuto { + t.Errorf("expected BreakInside=BreakAuto for a row containing a table, got %v", box.BreakPolicy.BreakInside) + } + }) + } +} From 8d35d2b5169d37574d0eee391fa81d7c3aab80a6 Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Fri, 23 Oct 2026 19:32:59 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E5=89=8D=E3=81=AB=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E3=81=8C=E3=81=82=E3=82=8B=E3=81=A8=E3=83=86=E3=83=BC=E3=83=96?= =?UTF-8?q?=E3=83=AB=E3=81=8C2=E3=83=9A=E3=83=BC=E3=82=B8=E7=9B=AE?= =?UTF-8?q?=E3=81=B8=E7=A7=BB=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IssueRunner #2 --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f7774..ae4cd15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed +- A multi-page table preceded by other rows no longer moves entirely to the next page, leaving the first page mostly blank. Since #24 every row was `BreakInside=BreakAvoid`, so a row containing a table that did not fit in the remaining space was deferred as a whole; the table now starts on the current page, repeats its header on each page, and renders every body row exactly once + - `template/grid.go`: `RowBuilder.build()` keeps `BreakAvoid` for ordinary rows but uses `BreakAuto` for rows that contain a table (tables paginate themselves) + - Example tests: `_examples/{builder,json,gotemplate}/39_table_after_content_test.go` with shared golden + ## [1.0.13] - 2026-09-04 ### Fixed @@ -54,11 +59,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - JSON / GoTemplate schema: `table.columnAlign: ["left", "center", "right"]` - Example tests: `_examples/{builder,json,gotemplate}/38_table_column_align_test.go` with shared golden -### Fixed -- A multi-page table preceded by other rows no longer moves entirely to the next page, leaving the first page mostly blank. Since #24 every row was `BreakInside=BreakAvoid`, so a row containing a table that did not fit in the remaining space was deferred as a whole; the table now starts on the current page, repeats its header on each page, and renders every body row exactly once - - `template/grid.go`: `RowBuilder.build()` keeps `BreakAvoid` for ordinary rows but uses `BreakAuto` for rows that contain a table (tables paginate themselves) - - Example tests: `_examples/{builder,json,gotemplate}/39_table_after_content_test.go` with shared golden - ## [1.0.9] - 2026-05-06 ### Fixed