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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added
- Per-column horizontal text alignment for tables (#26)
- Builder: `template.ColumnAlign(aligns ...document.TextAlign)` — sets the horizontal alignment for each column in both the header and body. Columns without a provided alignment fall back to the default left alignment.
- JSON / GoTemplate schema: `table.columnAlign: ["left", "center", "right"]`
- Example tests: `_examples/{builder,json,gotemplate}/38_table_column_align_test.go` with shared golden

## [1.0.9] - 2026-05-06

### Fixed
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.3%25-green)
![coverage](https://img.shields.io/badge/coverage-84.2%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
67 changes: 67 additions & 0 deletions _examples/builder/38_table_column_align_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package builder_test

import (
"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"
)

func TestExample_38_TableColumnAlign(t *testing.T) {
doc := template.New(
template.WithPageSize(document.A4),
template.WithMargins(document.UniformEdges(document.Mm(20))),
)

page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("Table Column Align Demo", template.FontSize(20), template.Bold())
c.Spacer(document.Mm(8))

c.Text("Right-aligned numeric and currency columns:", template.Bold())
c.Spacer(document.Mm(3))
c.Table(
[]string{"Item", "Qty", "Price"},
[][]string{
{"Apple", "3", "$1.50"},
{"Banana", "12", "$0.30"},
{"Cherry", "120", "$5.00"},
},
template.TableHeaderStyle(
template.BgColor(pdf.RGBHex(0x1565C0)),
template.TextColor(pdf.White),
),
template.ColumnAlign(
document.AlignLeft,
document.AlignRight,
document.AlignRight,
),
)
c.Spacer(document.Mm(8))

c.Text("Mixed alignments (left / center / right):", template.Bold())
c.Spacer(document.Mm(3))
c.Table(
[]string{"Name", "Status", "Amount"},
[][]string{
{"Alice", "active", "$100.00"},
{"Bob", "pending", "$42.50"},
},
template.TableHeaderStyle(
template.BgColor(pdf.RGBHex(0x2E7D32)),
template.TextColor(pdf.White),
),
template.ColumnAlign(
document.AlignLeft,
document.AlignCenter,
document.AlignRight,
),
)
})
})

testutil.GeneratePDFSharedGolden(t, "38_table_column_align.pdf", doc)
}
58 changes: 58 additions & 0 deletions _examples/gotemplate/38_table_column_align_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package gotemplate_test

import (
"testing"

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

func TestTmpl_38_TableColumnAlign(t *testing.T) {
schema := []byte(`{
"page": {"size": "A4", "margins": "20mm"},
"body": [
{"row": {"cols": [
{"span": 12, "elements": [
{"type": "text", "content": "{{.Title}}", "style": {"size": 20, "bold": true}},
{"type": "spacer", "height": "8mm"},
{"type": "text", "content": "Right-aligned numeric and currency columns:", "style": {"bold": true}},
{"type": "spacer", "height": "3mm"},
{"type": "table", "table": {
"header": ["Item", "Qty", "Price"],
"rows": {{toJSON .Items}},
"headerStyle": {"bold": true, "color": "white", "background": "#1565C0"},
"columnAlign": ["left", "right", "right"]
}},
{"type": "spacer", "height": "8mm"},
{"type": "text", "content": "Mixed alignments (left / center / right):", "style": {"bold": true}},
{"type": "spacer", "height": "3mm"},
{"type": "table", "table": {
"header": ["Name", "Status", "Amount"],
"rows": {{toJSON .People}},
"headerStyle": {"bold": true, "color": "white", "background": "#2E7D32"},
"columnAlign": ["left", "center", "right"]
}}
]}
]}}
]
}`)

data := map[string]any{
"Title": "Table Column Align Demo",
"Items": [][]string{
{"Apple", "3", "$1.50"},
{"Banana", "12", "$0.30"},
{"Cherry", "120", "$5.00"},
},
"People": [][]string{
{"Alice", "active", "$100.00"},
{"Bob", "pending", "$42.50"},
},
}

doc, err := template.FromJSON(schema, data)
if err != nil {
t.Fatalf("FromJSON error: %v", err)
}
testutil.GeneratePDFSharedGolden(t, "38_table_column_align.pdf", doc)
}
52 changes: 52 additions & 0 deletions _examples/json/38_table_column_align_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package json_test

import (
"testing"

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

func TestJSON_38_TableColumnAlign(t *testing.T) {
schema := []byte(`{
"page": {"size": "A4", "margins": "20mm"},
"body": [
{"row": {"cols": [
{"span": 12, "elements": [
{"type": "text", "content": "Table Column Align Demo", "style": {"size": 20, "bold": true}},
{"type": "spacer", "height": "8mm"},
{"type": "text", "content": "Right-aligned numeric and currency columns:", "style": {"bold": true}},
{"type": "spacer", "height": "3mm"},
{"type": "table", "table": {
"header": ["Item", "Qty", "Price"],
"rows": [
["Apple", "3", "$1.50"],
["Banana", "12", "$0.30"],
["Cherry", "120", "$5.00"]
],
"headerStyle": {"bold": true, "color": "white", "background": "#1565C0"},
"columnAlign": ["left", "right", "right"]
}},
{"type": "spacer", "height": "8mm"},
{"type": "text", "content": "Mixed alignments (left / center / right):", "style": {"bold": true}},
{"type": "spacer", "height": "3mm"},
{"type": "table", "table": {
"header": ["Name", "Status", "Amount"],
"rows": [
["Alice", "active", "$100.00"],
["Bob", "pending", "$42.50"]
],
"headerStyle": {"bold": true, "color": "white", "background": "#2E7D32"},
"columnAlign": ["left", "center", "right"]
}}
]}
]}}
]
}`)

doc, err := template.FromJSON(schema, nil)
if err != nil {
t.Fatalf("FromJSON error: %v", err)
}
testutil.GeneratePDFSharedGolden(t, "38_table_column_align.pdf", doc)
}
Binary file not shown.
1 change: 1 addition & 0 deletions docs/03-json-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ The `style` object can be applied to text elements:
["Bob", "25", "New York"]
],
"columnWidths": [40, 30, 30],
"columnAlign": ["left", "right", "left"],
"headerStyle": {"bold": true, "color": "#FFFFFF", "background": "#1A237E"},
"stripeColor": "#F5F5F5"
}
Expand Down
1 change: 1 addition & 0 deletions docs/05-elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ c.Table(
| Option | Description |
|---|---|
| `ColumnWidths(widths...)` | Column width percentages (should sum to 100) |
| `ColumnAlign(aligns...)` | Per-column horizontal alignment (`AlignLeft`, `AlignCenter`, `AlignRight`); applies to header and body cells |
| `TableHeaderStyle(opts...)` | Header text/background styling (takes `TextOption`s) |
| `TableStripe(color)` | Alternating row background color |
| `TableCellVAlign(align)` | Vertical alignment for body cells (`VAlignTop`, `VAlignMiddle`, `VAlignBottom`) |
Expand Down
2 changes: 1 addition & 1 deletion internal/buildinfo/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ package buildinfo

// Version is the library version. It is the single source of truth used by
// the public gpdf.Version constant and the default PDF Producer metadata.
const Version = "1.0.9"
const Version = "1.0.10"
20 changes: 20 additions & 0 deletions template/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ type tableConfig struct {
headerTextColor *pdf.Color
stripeColor *pdf.Color
columnWidths []float64
columnAligns []document.TextAlign
cellVAlign document.VerticalAlign
hasCellVAlign bool
border *BorderSpec
Expand Down Expand Up @@ -219,6 +220,25 @@ func TableCellVAlign(align document.VerticalAlign) TableOption {
}
}

// ColumnAlign sets the horizontal text alignment for each table column. Each
// argument applies to the column at the same index in both the header and
// body rows; columns without a provided alignment fall back to the default
// left alignment. Typical use case is right-aligning numeric or currency
// columns:
//
// c.Table(header, rows,
// template.ColumnAlign(
// document.AlignLeft, // Item
// document.AlignRight, // Qty
// document.AlignRight, // Price
// ),
// )
func ColumnAlign(aligns ...document.TextAlign) TableOption {
return func(cfg *tableConfig) {
cfg.columnAligns = aligns
}
}

// WithTableBorder draws a border around the table using the given [BorderSpec].
func WithTableBorder(spec BorderSpec) TableOption {
return func(cfg *tableConfig) {
Expand Down
57 changes: 38 additions & 19 deletions template/grid.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,18 +288,10 @@ func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption
// Build header row.
if len(header) > 0 {
headerRow := document.TableRow{}
for _, h := range header {
cellStyle := c.defaultStyle()
cellStyle.FontWeight = document.WeightBold
if tblCfg.headerBgColor != nil {
cellStyle.Background = tblCfg.headerBgColor
}
if tblCfg.headerTextColor != nil {
cellStyle.Color = *tblCfg.headerTextColor
}
for j, h := range header {
headerRow.Cells = append(headerRow.Cells, document.TableCell{
Content: []document.DocumentNode{
&document.Text{Content: h, TextStyle: cellStyle},
&document.Text{Content: h, TextStyle: c.tableHeaderCellStyle(&tblCfg, j)},
},
ColSpan: 1,
RowSpan: 1,
Expand All @@ -312,17 +304,10 @@ func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption
// Build body rows.
for i, row := range rows {
bodyRow := document.TableRow{}
for _, cell := range row {
cellStyle := c.defaultStyle()
if tblCfg.stripeColor != nil && i%2 == 1 {
cellStyle.Background = tblCfg.stripeColor
}
if tblCfg.hasCellVAlign {
cellStyle.VerticalAlign = tblCfg.cellVAlign
}
for j, cell := range row {
bodyRow.Cells = append(bodyRow.Cells, document.TableCell{
Content: []document.DocumentNode{
&document.Text{Content: cell, TextStyle: cellStyle},
&document.Text{Content: cell, TextStyle: c.tableBodyCellStyle(&tblCfg, i, j)},
},
ColSpan: 1,
RowSpan: 1,
Expand All @@ -346,6 +331,40 @@ func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption
c.nodes = append(c.nodes, tbl)
}

// tableHeaderCellStyle builds the per-cell text Style for a header cell at
// column j, applying header background/text color and per-column alignment.
func (c *ColBuilder) tableHeaderCellStyle(cfg *tableConfig, j int) document.Style {
s := c.defaultStyle()
s.FontWeight = document.WeightBold
if cfg.headerBgColor != nil {
s.Background = cfg.headerBgColor
}
if cfg.headerTextColor != nil {
s.Color = *cfg.headerTextColor
}
if j < len(cfg.columnAligns) {
s.TextAlign = cfg.columnAligns[j]
}
return s
}

// tableBodyCellStyle builds the per-cell text Style for a body cell at row i,
// column j, applying stripe color, vertical alignment, and per-column
// horizontal alignment.
func (c *ColBuilder) tableBodyCellStyle(cfg *tableConfig, i, j int) document.Style {
s := c.defaultStyle()
if cfg.stripeColor != nil && i%2 == 1 {
s.Background = cfg.stripeColor
}
if cfg.hasCellVAlign {
s.VerticalAlign = cfg.cellVAlign
}
if j < len(cfg.columnAligns) {
s.TextAlign = cfg.columnAligns[j]
}
return s
}

// applyTableDecoration copies border, background, and border-collapse
// settings from a tableConfig onto the constructed Table node.
func applyTableDecoration(tbl *document.Table, cfg *tableConfig) {
Expand Down
20 changes: 20 additions & 0 deletions template/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ type SchemaTable struct {
Header []string `json:"header"`
Rows [][]string `json:"rows"`
ColumnWidths []float64 `json:"columnWidths,omitempty"`
ColumnAlign []string `json:"columnAlign,omitempty"` // per-column horizontal alignment: "left", "center", "right"
HeaderStyle *SchemaStyle `json:"headerStyle,omitempty"`
StripeColor string `json:"stripeColor,omitempty"`
CellVAlign string `json:"cellVAlign,omitempty"` // "top", "middle", "bottom"
Expand Down Expand Up @@ -842,6 +843,22 @@ func parseImageAlign(s string) (document.TextAlign, bool) {
}
}

// parseSchemaColumnAligns converts per-column alignment strings into TextAlign
// values. Unrecognized entries fall back to AlignLeft. Returns nil for an
// empty input so callers can skip applying the option entirely.
func parseSchemaColumnAligns(s []string) []document.TextAlign {
if len(s) == 0 {
return nil
}
aligns := make([]document.TextAlign, len(s))
for i, a := range s {
if align, ok := parseImageAlign(a); ok {
aligns[i] = align
}
}
return aligns
}

func buildSchemaTable(c *ColBuilder, tbl *SchemaTable) {
if tbl == nil {
return
Expand All @@ -864,6 +881,9 @@ func buildSchemaTable(c *ColBuilder, tbl *SchemaTable) {
opts = append(opts, TableCellVAlign(align))
}
}
if aligns := parseSchemaColumnAligns(tbl.ColumnAlign); len(aligns) > 0 {
opts = append(opts, ColumnAlign(aligns...))
}
if spec, ok := parseSchemaBorder(tbl.Border); ok {
opts = append(opts, WithTableBorder(spec))
}
Expand Down
Loading
Loading