diff --git a/CHANGELOG.md b/CHANGELOG.md index 217ccb1..3e4430a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 383e324..16dc9a3 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/_examples/builder/38_table_column_align_test.go b/_examples/builder/38_table_column_align_test.go new file mode 100644 index 0000000..5e2ad87 --- /dev/null +++ b/_examples/builder/38_table_column_align_test.go @@ -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) +} diff --git a/_examples/gotemplate/38_table_column_align_test.go b/_examples/gotemplate/38_table_column_align_test.go new file mode 100644 index 0000000..549f774 --- /dev/null +++ b/_examples/gotemplate/38_table_column_align_test.go @@ -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) +} diff --git a/_examples/json/38_table_column_align_test.go b/_examples/json/38_table_column_align_test.go new file mode 100644 index 0000000..001174b --- /dev/null +++ b/_examples/json/38_table_column_align_test.go @@ -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) +} diff --git a/_examples/testdata/golden/38_table_column_align.pdf b/_examples/testdata/golden/38_table_column_align.pdf new file mode 100644 index 0000000..35b14b9 Binary files /dev/null and b/_examples/testdata/golden/38_table_column_align.pdf differ diff --git a/docs/03-json-schema.md b/docs/03-json-schema.md index 238b9ae..da920ec 100644 --- a/docs/03-json-schema.md +++ b/docs/03-json-schema.md @@ -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" } diff --git a/docs/05-elements.md b/docs/05-elements.md index 700677b..b8a9c60 100644 --- a/docs/05-elements.md +++ b/docs/05-elements.md @@ -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`) | diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index bda1b21..86ec36d 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -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" diff --git a/template/component.go b/template/component.go index 8eaebe4..02f3bad 100644 --- a/template/component.go +++ b/template/component.go @@ -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 @@ -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) { diff --git a/template/grid.go b/template/grid.go index fa4ad22..759156d 100644 --- a/template/grid.go +++ b/template/grid.go @@ -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, @@ -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, @@ -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) { diff --git a/template/schema.go b/template/schema.go index 0c9b1b2..1f84e1f 100644 --- a/template/schema.go +++ b/template/schema.go @@ -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" @@ -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 @@ -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)) } diff --git a/template/template_test.go b/template/template_test.go index beb2508..2f32b2f 100644 --- a/template/template_test.go +++ b/template/template_test.go @@ -931,6 +931,20 @@ func TestColumnWidthsOption(t *testing.T) { } } +func TestColumnAlignOption(t *testing.T) { + cfg := tableConfig{} + ColumnAlign(document.AlignLeft, document.AlignRight, document.AlignCenter)(&cfg) + if len(cfg.columnAligns) != 3 { + t.Fatalf("column aligns len: got %d, want 3", len(cfg.columnAligns)) + } + expected := []document.TextAlign{document.AlignLeft, document.AlignRight, document.AlignCenter} + for i, a := range cfg.columnAligns { + if a != expected[i] { + t.Errorf("column align[%d]: got %v, want %v", i, a, expected[i]) + } + } +} + func TestLineColorOption(t *testing.T) { cfg := lineConfig{} c := pdf.Green