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
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ examples/
├── merge/ # parse, merge, and extract text from PDFs
├── redact/ # permanently remove sensitive text (SSNs, emails, PII)
├── report/ # multi-page report with layout API (tables, lists, columns)
├── table-rowspan/ # table cells spanning multiple rows and columns
├── sign/ # PAdES digital signature with self-signed certificate
├── zugferd/ # PDF/A-3B invoice with Factur-X XML attachment
└── README.md
Expand Down
80 changes: 80 additions & 0 deletions examples/table-rowspan/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright 2026 Carlos Munoz and the Folio Authors
// SPDX-License-Identifier: Apache-2.0

// Table-rowspan creates a one-page PDF demonstrating cells that span
// multiple rows (rowspan) alongside multi-column spans (colspan).
//
// Usage:
//
// go run ./examples/table-rowspan
package main

import (
"fmt"
"os"

"github.com/carlos7ags/folio/document"
"github.com/carlos7ags/folio/font"
"github.com/carlos7ags/folio/layout"
)

func main() {
if err := buildDocument().Save("table-rowspan.pdf"); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Println("Created table-rowspan.pdf")
}

// buildDocument assembles a page with two tables: the minimal rowspan
// case from issue #357, and a richer schedule grid where a cell spans
// three rows. Extracted from main() so the example test can build the
// same document against an in-memory buffer.
func buildDocument() *document.Document {
doc := document.NewDocument(document.PageSizeA4)
doc.Info.Title = "Table Rowspan"
doc.Info.Author = "Folio"

doc.Add(layout.NewHeading("Rowspan", layout.H1))
doc.Add(layout.NewParagraph(
"The first column spans both rows; the second column has one cell per row.",
font.Helvetica, 11,
))

basic := layout.NewTable()
r1 := basic.AddRow()
r1.AddCell("Span", font.HelveticaBold, 10).SetRowspan(2).SetVAlign(layout.VAlignMiddle)
r1.AddCell("B1", font.Helvetica, 10)
r2 := basic.AddRow()
r2.AddCell("B2", font.Helvetica, 10)
doc.Add(basic)

doc.Add(layout.NewHeading("Schedule grid", layout.H2))
doc.Add(layout.NewParagraph(
"\"Morning\" spans three time slots; \"All day\" spans the whole column.",
font.Helvetica, 11,
))

sched := layout.NewTable()
sched.SetColumnWidths([]float64{90, 160, 160})

h := sched.AddHeaderRow()
h.AddCell("Block", font.HelveticaBold, 10)
h.AddCell("Track A", font.HelveticaBold, 10)
h.AddCell("Track B", font.HelveticaBold, 10)

row1 := sched.AddRow()
row1.AddCell("Morning", font.HelveticaBold, 10).SetRowspan(3).SetVAlign(layout.VAlignMiddle)
row1.AddCell("Registration", font.Helvetica, 10)
row1.AddCell("All day: help desk", font.Helvetica, 10).SetRowspan(3).SetVAlign(layout.VAlignMiddle)

row2 := sched.AddRow()
row2.AddCell("Keynote", font.Helvetica, 10)

row3 := sched.AddRow()
row3.AddCell("Workshop intro", font.Helvetica, 10)

doc.Add(sched)

return doc
}
73 changes: 73 additions & 0 deletions examples/table-rowspan/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2026 Carlos Munoz and the Folio Authors
// SPDX-License-Identifier: Apache-2.0

package main

import (
"bytes"
"strings"
"sync"
"testing"

"github.com/carlos7ags/folio/reader"
)

var examplePDFBytes = sync.OnceValue(func() []byte {
var buf bytes.Buffer
if _, err := buildDocument().WriteTo(&buf); err != nil {
panic("WriteTo: " + err.Error())
}
return buf.Bytes()
})

func examplePDFReader(t *testing.T) *reader.PdfReader {
t.Helper()
r, err := reader.Parse(examplePDFBytes())
if err != nil {
t.Fatalf("reader.Parse: %v", err)
}
return r
}

func TestRowspanExampleProducesValidPDF(t *testing.T) {
pdf := examplePDFBytes()
if !bytes.HasPrefix(pdf, []byte("%PDF-")) {
t.Errorf("output does not start with %%PDF- header (got %q)", pdf[:min(8, len(pdf))])
}
if len(pdf) < 1000 {
t.Errorf("output PDF is suspiciously small (%d bytes); expected >1 KB", len(pdf))
}
}

func TestRowspanExampleSinglePage(t *testing.T) {
r := examplePDFReader(t)
if got := r.PageCount(); got != 1 {
t.Errorf("PageCount = %d, want 1", got)
}
}

// TestRowspanExampleContent confirms every cell — including the cells
// in rows that sit beside a rowspanning cell — is emitted. Before the
// rowspan geometry fix the spanning cells still rendered; the failure
// mode was purely visual (drawn one row tall), so this guards content
// rather than geometry. The geometry assertions live in the layout
// package's TestTableRowspan* tests.
func TestRowspanExampleContent(t *testing.T) {
r := examplePDFReader(t)
page, err := r.Page(0)
if err != nil {
t.Fatalf("Page(0): %v", err)
}
text, err := page.ExtractText()
if err != nil {
t.Fatalf("ExtractText: %v", err)
}
for _, want := range []string{
"Span", "B1", "B2",
"Morning", "Registration", "Keynote", "Workshop intro", "All day: help desk",
} {
if !strings.Contains(text, want) {
t.Errorf("page text missing %q; extracted:\n%s", want, text)
}
}
}
83 changes: 72 additions & 11 deletions layout/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -722,9 +722,10 @@ func cellIntrinsicWidths(cell *Cell, availWidth float64) (minW, maxW float64) {

// gridCell is a cell positioned in the flat grid.
type gridCell struct {
cell *Cell
col int // starting column index
spanWidth float64 // total width across spanned columns
cell *Cell
col int // starting column index
spanWidth float64 // total width across spanned columns
spanHeight float64 // total height across spanned rows (0 = single row)
}

// gridRow is a row in the flat grid with computed height.
Expand Down Expand Up @@ -798,10 +799,15 @@ func (t *Table) buildGrid(colWidths []float64) []gridRow {
col++
}

// Compute row height: tallest cell content + padding.
// Compute row height: tallest cell content + padding. Rowspanning
// cells are excluded here so they don't inflate their starting row;
// their height is distributed across the spanned rows below.
maxH := 0.0
for i := range gr.cells {
h := t.cellContentHeight(&gr.cells[i])
if gr.cells[i].cell.rowspan > 1 {
continue
}
if h > maxH {
maxH = h
}
Expand All @@ -810,9 +816,58 @@ func (t *Table) buildGrid(colWidths []float64) []gridRow {
grid = append(grid, gr)
}

t.resolveRowspanHeights(grid)
return grid
}

// resolveRowspanHeights computes spanHeight for every rowspanning cell once
// all natural row heights are known. If a spanning cell needs more height than
// its spanned rows provide, the deficit grows the last spanned row; spanHeight
// is then the total covered height (spanned row heights plus the spacing gaps
// between them).
//
// Note: a rowspan that straddles a page break is not handled — PlanLayout
// splits between rows without span awareness, so such a cell draws its full
// height past the page bottom. Tracked as a known limitation (issue #357).
func (t *Table) resolveRowspanHeights(grid []gridRow) {
sv := t.effectiveSpacingV()

// Pass 1: grow the last spanned row to cover any content deficit.
for r := range grid {
for i := range grid[r].cells {
gc := &grid[r].cells[i]
if gc.cell.rowspan <= 1 {
continue
}
end := min(r+gc.cell.rowspan, len(grid))
avail := float64(end-r-1) * sv
for k := r; k < end; k++ {
avail += grid[k].height
}
if need := t.cellContentHeight(gc); need > avail {
grid[end-1].height += need - avail
}
}
}

// Pass 2: record each spanning cell's total height from the final row
// heights (rows may have grown in pass 1).
for r := range grid {
for i := range grid[r].cells {
gc := &grid[r].cells[i]
if gc.cell.rowspan <= 1 {
continue
}
end := min(r+gc.cell.rowspan, len(grid))
h := float64(end-r-1) * sv
for k := r; k < end; k++ {
h += grid[k].height
}
gc.spanHeight = h
}
}
}

// cellContentHeight computes the height needed for a cell's content.
// For Element cells, it also caches the laid-out lines in gc.
func (t *Table) cellContentHeight(gc *gridCell) float64 {
Expand Down Expand Up @@ -1357,31 +1412,37 @@ func drawTableRowDirect(ctx DrawContext, tbl *Table, grid []gridRow, rowIndex in
cellX += colWidths[c] + sh
}
}
cellBottomY := topY - gr.height
// Rowspanning cells extend below their starting row; spanHeight
// covers the full span (0 for single-row cells).
cellH := gr.height
if gc.spanHeight > 0 {
cellH = gc.spanHeight
}
cellBottomY := topY - cellH

// Background fill (with optional rounded corners).
r := gc.cell.borderRadius
hasRadius := r[0] > 0 || r[1] > 0 || r[2] > 0 || r[3] > 0
if gc.cell.bgColor != nil {
if hasRadius {
drawBackgroundRounded(ctx, *gc.cell.bgColor, cellX, cellBottomY, gc.spanWidth, gr.height, r)
drawBackgroundRounded(ctx, *gc.cell.bgColor, cellX, cellBottomY, gc.spanWidth, cellH, r)
} else {
drawBackground(ctx, *gc.cell.bgColor, cellX, topY, gc.spanWidth, gr.height)
drawBackground(ctx, *gc.cell.bgColor, cellX, topY, gc.spanWidth, cellH)
}
}

// Borders (with optional rounded corners).
if hasRadius {
drawCellBordersRounded(ctx.Stream, gc.cell.borders, cellX, cellBottomY, gc.spanWidth, gr.height, r)
drawCellBordersRounded(ctx.Stream, gc.cell.borders, cellX, cellBottomY, gc.spanWidth, cellH, r)
} else {
drawCellBorders(ctx.Stream, gc.cell.borders, cellX, cellBottomY, gc.spanWidth, gr.height)
drawCellBorders(ctx.Stream, gc.cell.borders, cellX, cellBottomY, gc.spanWidth, cellH)
}

// Cell content.
if gc.cell.content != nil {
drawCellElementDirect(ctx, gc, cellX, topY, gr.height)
drawCellElementDirect(ctx, gc, cellX, topY, cellH)
} else {
drawCellTextDirect(ctx, gc.cell, cellX, topY, gc.spanWidth, gr.height)
drawCellTextDirect(ctx, gc.cell, cellX, topY, gc.spanWidth, cellH)
}
}
}
Expand Down
Loading
Loading