From 3c8c3450044a073cdc998a299f60a020eb97bfe2 Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Thu, 7 May 2026 21:33:21 +0900 Subject: [PATCH 1/4] feat(template): add per-column horizontal alignment for tables (#26) --- CHANGELOG.md | 6 ++ .../builder/38_table_column_align_test.go | 67 ++++++++++++++++++ .../gotemplate/38_table_column_align_test.go | 58 +++++++++++++++ _examples/json/38_table_column_align_test.go | 52 ++++++++++++++ .../testdata/golden/38_table_column_align.pdf | Bin 0 -> 3658 bytes docs/03-json-schema.md | 1 + docs/05-elements.md | 1 + template/component.go | 20 ++++++ template/grid.go | 10 ++- template/schema.go | 10 +++ template/template_test.go | 14 ++++ 11 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 _examples/builder/38_table_column_align_test.go create mode 100644 _examples/gotemplate/38_table_column_align_test.go create mode 100644 _examples/json/38_table_column_align_test.go create mode 100644 _examples/testdata/golden/38_table_column_align.pdf 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/_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 0000000000000000000000000000000000000000..35b14b9db9544c888c30f037ecb93111fbfe306a GIT binary patch literal 3658 zcmc&%OK;mo5WW-Wf0%;|*axrny~`p9U@0*gv`HLUx1dh-VQgQRrVc~(LHH@EoVs&tZE4)Vnm z`nT)5>T%_uKgLhC`2+p>?Ay1I%cfq1Jg(|LYEglfKU_Ai?t#aG0@^97!X?Xy!aN#` zqy9AmPGLNYlqNbBV6-AEHZYz>=i}sV;b53A*2@g87U?{LcW#+q!1#OgejKV`JXFCo zQ7pE#j!`dm##=BkYrPstc${BlSi1;2FhO>y?;fNP|Jnlv1H6>X{d^U z5)o@^tQ1TbuB6{F;Sp;&6!Z2Tlo8I9RlqbQ8im>=fk{->Sx~rW7JZ3c**2F%8-Bzd zdwPa0hExz;yYC<3L*H4!P5i%T;U%71Eqe<*#X6aO84*Qi^S(k>ZuT`<}`_CCfHhe6e|nhkMBU=Rm~` z(hrriT>@H11cGo{3EZPihY;RYNwqGU;C(5GyOiF#elnxKs$ff zoE^C`Ul$Wsf*(NYfujDxzz-&y1@k-{-PXt?Uys~D98T#k|oKb#H zrzL##j*if*A6zzaWgut*!J8tVt|wRw&gZM?>>@aY#NVp-mdnQbjnZLKC5wC>2s{*S zhBJnZo}LM`QrPy)e%bB`Vep22LDueZW1sKg0UQ^)=P0#^$+JDCcJ^>`!0UISq>f$M17R!9>eu*<9Nm12z6O!}j?CkyL>*ya=4KS_% literal 0 HcmV?d00001 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/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..d29ade4 100644 --- a/template/grid.go +++ b/template/grid.go @@ -288,7 +288,7 @@ func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption // Build header row. if len(header) > 0 { headerRow := document.TableRow{} - for _, h := range header { + for j, h := range header { cellStyle := c.defaultStyle() cellStyle.FontWeight = document.WeightBold if tblCfg.headerBgColor != nil { @@ -297,6 +297,9 @@ func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption if tblCfg.headerTextColor != nil { cellStyle.Color = *tblCfg.headerTextColor } + if j < len(tblCfg.columnAligns) { + cellStyle.TextAlign = tblCfg.columnAligns[j] + } headerRow.Cells = append(headerRow.Cells, document.TableCell{ Content: []document.DocumentNode{ &document.Text{Content: h, TextStyle: cellStyle}, @@ -312,7 +315,7 @@ 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 { + for j, cell := range row { cellStyle := c.defaultStyle() if tblCfg.stripeColor != nil && i%2 == 1 { cellStyle.Background = tblCfg.stripeColor @@ -320,6 +323,9 @@ func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption if tblCfg.hasCellVAlign { cellStyle.VerticalAlign = tblCfg.cellVAlign } + if j < len(tblCfg.columnAligns) { + cellStyle.TextAlign = tblCfg.columnAligns[j] + } bodyRow.Cells = append(bodyRow.Cells, document.TableCell{ Content: []document.DocumentNode{ &document.Text{Content: cell, TextStyle: cellStyle}, diff --git a/template/schema.go b/template/schema.go index 0c9b1b2..f5265ee 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" @@ -864,6 +865,15 @@ func buildSchemaTable(c *ColBuilder, tbl *SchemaTable) { opts = append(opts, TableCellVAlign(align)) } } + if len(tbl.ColumnAlign) > 0 { + aligns := make([]document.TextAlign, len(tbl.ColumnAlign)) + for i, a := range tbl.ColumnAlign { + if align, ok := parseImageAlign(a); ok { + aligns[i] = align + } + } + 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 From 2dec0bd14ead4538491e880c3fb0567000b97448 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 12:34:56 +0000 Subject: [PATCH 2/4] chore: bump version to v1.0.10 --- internal/buildinfo/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 392db9f91c23775d2a5e5eed985b98993c883cad Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Thu, 7 May 2026 21:53:25 +0900 Subject: [PATCH 3/4] refactor(template): lower table builder & schema cyclomatic complexity (#26) --- template/grid.go | 59 ++++++++++++++++++++++++++++------------------ template/schema.go | 24 +++++++++++++------ 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/template/grid.go b/template/grid.go index d29ade4..759156d 100644 --- a/template/grid.go +++ b/template/grid.go @@ -289,20 +289,9 @@ func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption if len(header) > 0 { headerRow := document.TableRow{} for j, 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 - } - if j < len(tblCfg.columnAligns) { - cellStyle.TextAlign = tblCfg.columnAligns[j] - } 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, @@ -316,19 +305,9 @@ func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption for i, row := range rows { bodyRow := document.TableRow{} for j, cell := range row { - cellStyle := c.defaultStyle() - if tblCfg.stripeColor != nil && i%2 == 1 { - cellStyle.Background = tblCfg.stripeColor - } - if tblCfg.hasCellVAlign { - cellStyle.VerticalAlign = tblCfg.cellVAlign - } - if j < len(tblCfg.columnAligns) { - cellStyle.TextAlign = tblCfg.columnAligns[j] - } 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, @@ -352,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 f5265ee..1f84e1f 100644 --- a/template/schema.go +++ b/template/schema.go @@ -843,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 @@ -865,13 +881,7 @@ func buildSchemaTable(c *ColBuilder, tbl *SchemaTable) { opts = append(opts, TableCellVAlign(align)) } } - if len(tbl.ColumnAlign) > 0 { - aligns := make([]document.TextAlign, len(tbl.ColumnAlign)) - for i, a := range tbl.ColumnAlign { - if align, ok := parseImageAlign(a); ok { - aligns[i] = align - } - } + if aligns := parseSchemaColumnAligns(tbl.ColumnAlign); len(aligns) > 0 { opts = append(opts, ColumnAlign(aligns...)) } if spec, ok := parseSchemaBorder(tbl.Border); ok { From 5c54d0cf5e727e94b21aadde5fe1ba5210427e9c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 12:55:34 +0000 Subject: [PATCH 4/4] update coverage badge to 84.2% --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/)