Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ body:
id: gpdf-version
attributes:
label: gpdf Version
placeholder: "v1.0.8"
placeholder: "v1.0.9"
validations:
required: true

Expand Down
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [1.0.9] - 2026-05-06

### Fixed
- AutoRow/Row no longer splits across page boundaries — a row that does not fit in the remaining space on the current page now moves as a whole to the next page, instead of placing the columns that fit on the current page and re-rendering the full row on the next (#24)
- `template/grid.go`: `RowBuilder.build()` now sets `BreakInside=BreakAvoid` on the horizontal Box so rows are treated as atomic layout units
- `document/layout/block.go`: `layoutVerticalChild` falls back to a normal split when a `BreakAvoid` child is the first node on a fresh page, preventing an infinite loop of empty pages when a row is taller than a full page

## [1.0.8] - 2026-05-06

### Added
Expand Down Expand Up @@ -159,7 +166,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- Reed-Solomon coefficient order in QR code encoder
- binary.Write return value handling for errcheck lint

[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.8...HEAD
[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.9...HEAD
[1.0.9]: https://github.com/gpdf-dev/gpdf/compare/v1.0.8...v1.0.9
[1.0.8]: https://github.com/gpdf-dev/gpdf/compare/v1.0.7...v1.0.8
[1.0.7]: https://github.com/gpdf-dev/gpdf/compare/v1.0.6...v1.0.7
[1.0.6]: https://github.com/gpdf-dev/gpdf/compare/v1.0.5...v1.0.6
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![Go Reference](https://pkg.go.dev/badge/github.com/gpdf-dev/gpdf.svg)](https://pkg.go.dev/github.com/gpdf-dev/gpdf)
[![CI](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml/badge.svg)](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml)
![coverage](https://img.shields.io/badge/coverage-84.1%25-green)
![coverage](https://img.shields.io/badge/coverage-84.3%25-green)
[![Go Report Card](https://goreportcard.com/badge/github.com/gpdf-dev/gpdf)](https://goreportcard.com/report/github.com/gpdf-dev/gpdf)
[![Go Version](https://img.shields.io/badge/Go-%3E%3D1.22-blue)](https://go.dev/)
[![Website](https://img.shields.io/badge/Website-gpdf.dev-blue)](https://gpdf.dev/)
Expand Down
90 changes: 90 additions & 0 deletions _examples/builder/37_row_break_avoid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package builder_test

import (
"bytes"
"image/color"
"testing"

"github.com/gpdf-dev/gpdf/_examples/testutil"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/pdf"
"github.com/gpdf-dev/gpdf/template"
)

// TestExample_37_RowBreakAvoid is a regression test for issue #24:
// when an AutoRow's columns partially fit at the bottom of a page —
// some columns fit, others overflow — the entire row must move to the
// next page instead of being split between its columns.
//
// In the buggy behavior, the text column's content rendered at the
// bottom of page 1 (overlapping the footer) and the complete row
// rendered again at the top of page 2.
func TestExample_37_RowBreakAvoid(t *testing.T) {
// Tall image (1:4 aspect) so the image column is much taller than the
// text column and reliably overflows the remaining space at the bottom
// of the first page.
imgData := testutil.TestImagePNG(t, 50, 200, color.RGBA{R: 30, G: 136, B: 229, A: 255})

doc := template.New(
template.WithPageSize(document.A4),
template.WithMargins(document.Edges{
Top: document.Mm(13),
Right: document.Mm(13),
Bottom: document.Mm(19),
Left: document.Mm(13),
}),
template.WithDefaultFont("Helvetica", 9),
)

doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.PageNumber(template.AlignRight(), template.FontSize(8),
template.TextColor(pdf.Gray(0.5)))
})
})
})

page := doc.AddPage()

// Fill the page with fixed-height rows to deterministically push the last
// row near the bottom. Each filler is 13mm tall × 20 rows = 260mm,
// leaving roughly 5mm at the bottom of the body area — enough for the
// text column of the final row but not for the (taller) image column.
for range 20 {
page.Row(document.Mm(13), func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("Filler row — some content here")
})
})
}

// This row is tall enough that it partially overflows the remaining
// space on page 1: col 1 text fits, col 2 image does not.
page.AutoRow(func(r *template.RowBuilder) {
r.Col(9, func(c *template.ColBuilder) {
c.Text("GROUP HEADER", template.Bold(), template.FontSize(12))
c.Text("Patient name", template.FontSize(10))
c.Spacer(document.Mm(2))
})
r.Col(3, func(c *template.ColBuilder) {
c.Image(imgData, template.FitWidth(document.Mm(20)))
})
})

testutil.GeneratePDFSharedGolden(t, "37_row_break_avoid.pdf", doc)

// Behavioural assertion: the row must move as a whole to the next page,
// so each text in the final row appears exactly once in the document.
// The pre-fix behaviour would render col 1 on page 1 (overlapping the
// footer) AND the entire row on page 2 — duplicating both strings.
data, err := doc.Generate()
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
for _, marker := range []string{"GROUP HEADER", "Patient name"} {
if got := bytes.Count(data, []byte(marker)); got != 1 {
t.Errorf("expected %q to appear once (row moved as a whole), got %d occurrences", marker, got)
}
}
}
88 changes: 88 additions & 0 deletions _examples/gotemplate/37_row_break_avoid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package gotemplate_test

import (
"bytes"
"encoding/base64"
"fmt"
"image/color"
"strings"
"testing"

"github.com/gpdf-dev/gpdf/_examples/testutil"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)

// TestTmpl_37_RowBreakAvoid mirrors the builder regression test for
// issue #24 — a row whose columns partially fit at the bottom of a
// page must move as a whole to the next page rather than splitting
// between its columns.
func TestTmpl_37_RowBreakAvoid(t *testing.T) {
imgB64 := base64.StdEncoding.EncodeToString(
testutil.TestImagePNG(t, 50, 200, color.RGBA{R: 30, G: 136, B: 229, A: 255}),
)

var fillerRows strings.Builder
for i := 0; i < 20; i++ {
fillerRows.WriteString(`,
{"row": {"height": "13mm", "cols": [
{"span": 12, "text": "{{.Filler}}"}
]}}`)
}

schema := []byte(fmt.Sprintf(`{
"page": {"size": "A4"},
"footer": [
{"row": {"cols": [
{"span": 12, "elements": [
{"type": "pageNumber", "style": {"align": "right", "size": 8, "color": "gray(0.5)"}}
]}
]}}
],
"body": [
{"row": {"cols": [
{"span": 12, "text": ""}
]}}%s,
{"row": {"cols": [
{"span": 9, "elements": [
{"type": "text", "content": "{{.GroupHeader}}", "style": {"bold": true, "size": 12}},
{"type": "text", "content": "{{.PatientName}}", "style": {"size": 10}},
{"type": "spacer", "height": "2mm"}
]},
{"span": 3, "elements": [
{"type": "image", "image": {"src": "%s", "width": "20mm"}}
]}
]}}
]
}`, fillerRows.String(), imgB64))

data := map[string]any{
"Filler": "Filler row — some content here",
"GroupHeader": "GROUP HEADER",
"PatientName": "Patient name",
}

doc, err := template.FromJSON(schema, data,
template.WithMargins(document.Edges{
Top: document.Mm(13),
Right: document.Mm(13),
Bottom: document.Mm(19),
Left: document.Mm(13),
}),
template.WithDefaultFont("Helvetica", 9),
)
if err != nil {
t.Fatalf("FromJSON error: %v", err)
}
testutil.GeneratePDFSharedGolden(t, "37_row_break_avoid.pdf", doc)

out, err := doc.Generate()
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
for _, marker := range []string{"GROUP HEADER", "Patient name"} {
if got := bytes.Count(out, []byte(marker)); got != 1 {
t.Errorf("expected %q to appear once (row moved as a whole), got %d occurrences", marker, got)
}
}
}
82 changes: 82 additions & 0 deletions _examples/json/37_row_break_avoid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package json_test

import (
"bytes"
"encoding/base64"
"fmt"
"image/color"
"strings"
"testing"

"github.com/gpdf-dev/gpdf/_examples/testutil"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)

// TestJSON_37_RowBreakAvoid mirrors the builder regression test for
// issue #24 — a row whose columns partially fit at the bottom of a page
// must move as a whole to the next page rather than splitting between
// its columns.
func TestJSON_37_RowBreakAvoid(t *testing.T) {
imgB64 := base64.StdEncoding.EncodeToString(
testutil.TestImagePNG(t, 50, 200, color.RGBA{R: 30, G: 136, B: 229, A: 255}),
)

var fillerRows strings.Builder
for i := 0; i < 20; i++ {
fillerRows.WriteString(`,
{"row": {"height": "13mm", "cols": [
{"span": 12, "text": "Filler row — some content here"}
]}}`)
}

schema := []byte(fmt.Sprintf(`{
"page": {"size": "A4"},
"footer": [
{"row": {"cols": [
{"span": 12, "elements": [
{"type": "pageNumber", "style": {"align": "right", "size": 8, "color": "gray(0.5)"}}
]}
]}}
],
"body": [
{"row": {"cols": [
{"span": 12, "text": ""}
]}}%s,
{"row": {"cols": [
{"span": 9, "elements": [
{"type": "text", "content": "GROUP HEADER", "style": {"bold": true, "size": 12}},
{"type": "text", "content": "Patient name", "style": {"size": 10}},
{"type": "spacer", "height": "2mm"}
]},
{"span": 3, "elements": [
{"type": "image", "image": {"src": "%s", "width": "20mm"}}
]}
]}}
]
}`, fillerRows.String(), imgB64))

doc, err := template.FromJSON(schema, nil,
template.WithMargins(document.Edges{
Top: document.Mm(13),
Right: document.Mm(13),
Bottom: document.Mm(19),
Left: document.Mm(13),
}),
template.WithDefaultFont("Helvetica", 9),
)
if err != nil {
t.Fatalf("FromJSON error: %v", err)
}
testutil.GeneratePDFSharedGolden(t, "37_row_break_avoid.pdf", doc)

data, err := doc.Generate()
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
for _, marker := range []string{"GROUP HEADER", "Patient name"} {
if got := bytes.Count(data, []byte(marker)); got != 1 {
t.Errorf("expected %q to appear once (row moved as a whole), got %d occurrences", marker, got)
}
}
}
Binary file added _examples/testdata/golden/37_row_break_avoid.pdf
Binary file not shown.
8 changes: 6 additions & 2 deletions document/layout/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,12 @@ func (bl *BlockLayout) layoutVerticalChild(bc *blockContext, child document.Docu
childResult := bl.layoutChild(child, childConstraints)

// BreakInside=BreakAvoid: if the child overflowed, move the
// entire child to overflow instead of splitting it.
if bp.BreakInside == document.BreakAvoid && childResult.Overflow != nil {
// entire child to overflow instead of splitting it. Skip this
// guard when the child is the first node on a fresh page (nothing
// placed yet) — the child cannot be made to fit by deferring it,
// so fall back to the default split to avoid an infinite loop of
// empty pages.
if bp.BreakInside == document.BreakAvoid && childResult.Overflow != nil && (i > 0 || cursorY > 0 || len(placed) > 0) {
return bc.overflowResult(placed, cursorY, children[i:]), true
}

Expand Down
Loading
Loading