diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 959f871..2a8d9fa 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -63,7 +63,7 @@ body: id: gpdf-version attributes: label: gpdf Version - placeholder: "v1.0.7" + placeholder: "v1.0.8" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index a45fff8..fcd3ab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.0.8] - 2026-05-06 + +### Added +- Padding support on Text elements following the CSS box model (#23) + - Builder: `template.TextPadding(Edges)` + - JSON / GoTemplate schema: `text.style.padding` (uniform, e.g. `"10mm"`) and `text.style.paddings` (CSS shorthand `[top, right, bottom, left]`, 1–4 values) + - Layout engine: `Style.Padding` and `Style.Border` participate in text flow — wrap width shrinks to the inner content area, lines are offset by the top/left inset, and `BgColor` fills the padded box (`document/layout/flow.go`) +- Example tests: `_examples/{builder,json,gotemplate}/36_text_padding_test.go` with shared golden + ## [1.0.7] - 2026-04-29 ### Added @@ -150,7 +159,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.7...HEAD +[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.8...HEAD +[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 [1.0.5]: https://github.com/gpdf-dev/gpdf/compare/v1.0.4...v1.0.5 diff --git a/README.md b/README.md index c61549b..9e034ef 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.4%25-green) +![coverage](https://img.shields.io/badge/coverage-84.1%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/36_text_padding_test.go b/_examples/builder/36_text_padding_test.go new file mode 100644 index 0000000..9efca9e --- /dev/null +++ b/_examples/builder/36_text_padding_test.go @@ -0,0 +1,115 @@ +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" +) + +// TestExample_36_TextPadding exercises [template.TextPadding] (issue #23). +// Before the fix, Padding set on Style via TextOption was silently ignored +// because LayoutText only consumed FontSize / LineHeight / TextIndent. +// The page lays out matched pairs of Text cells — one without padding, +// one with — so that any regression where padding is dropped would change +// the rendered heights and the resulting golden bytes. The same shared +// golden is consumed by TestJSON_36_TextPadding and TestTmpl_36_TextPadding +// to ensure all three entry points produce byte-identical output. +func TestExample_36_TextPadding(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("Text padding (issue #23)", template.FontSize(18), template.Bold()) + c.Spacer(document.Mm(5)) + }) + }) + + // Pair A: uniform padding around a single line. + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("A. Uniform 10mm padding fills the BgColor area", + template.FontSize(11), template.Bold()) + c.Spacer(document.Mm(2)) + }) + }) + page.AutoRow(func(r *template.RowBuilder) { + r.Col(6, func(c *template.ColBuilder) { + c.Text("No padding", + template.BgColor(pdf.Gray(0.85))) + }) + r.Col(6, func(c *template.ColBuilder) { + c.Text("Should have 10 mm padding", + template.BgColor(pdf.Gray(0.85)), + template.TextPadding(document.UniformEdges(document.Mm(10)))) + }) + }) + + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { c.Spacer(document.Mm(8)) }) + }) + + // Pair B: asymmetric padding — top/bottom only — to confirm horizontal + // wrap width is unchanged when only vertical padding is applied. + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("B. Asymmetric padding (top/bottom only)", + template.FontSize(11), template.Bold()) + c.Spacer(document.Mm(2)) + }) + }) + page.AutoRow(func(r *template.RowBuilder) { + r.Col(6, func(c *template.ColBuilder) { + c.Text("Plain wrapped text that spans across multiple lines so that "+ + "line wrapping is exercised in this column.", + template.BgColor(pdf.RGBHex(0xE3F2FD))) + }) + r.Col(6, func(c *template.ColBuilder) { + c.Text("Plain wrapped text that spans across multiple lines so that "+ + "line wrapping is exercised in this column.", + template.BgColor(pdf.RGBHex(0xE3F2FD)), + template.TextPadding(document.Edges{ + Top: document.Mm(6), + Bottom: document.Mm(6), + })) + }) + }) + + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { c.Spacer(document.Mm(8)) }) + }) + + // Pair C: horizontal padding — narrows wrap width, forcing extra lines. + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("C. Horizontal padding narrows the wrap width", + template.FontSize(11), template.Bold()) + c.Spacer(document.Mm(2)) + }) + }) + page.AutoRow(func(r *template.RowBuilder) { + r.Col(6, func(c *template.ColBuilder) { + c.Text("Lorem ipsum dolor sit amet consectetur adipiscing elit "+ + "sed do eiusmod tempor incididunt ut labore et dolore.", + template.BgColor(pdf.RGBHex(0xFFF3E0))) + }) + r.Col(6, func(c *template.ColBuilder) { + c.Text("Lorem ipsum dolor sit amet consectetur adipiscing elit "+ + "sed do eiusmod tempor incididunt ut labore et dolore.", + template.BgColor(pdf.RGBHex(0xFFF3E0)), + template.TextPadding(document.Edges{ + Left: document.Mm(12), + Right: document.Mm(12), + })) + }) + }) + + testutil.GeneratePDFSharedGolden(t, "36_text_padding.pdf", doc) +} diff --git a/_examples/gotemplate/36_text_padding_test.go b/_examples/gotemplate/36_text_padding_test.go new file mode 100644 index 0000000..534ffcf --- /dev/null +++ b/_examples/gotemplate/36_text_padding_test.go @@ -0,0 +1,98 @@ +package gotemplate_test + +import ( + "testing" + + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/template" +) + +// TestTmpl_36_TextPadding mirrors TestExample_36_TextPadding / +// TestJSON_36_TextPadding so the shared golden 36_text_padding.pdf +// compares byte-identically across all three entry points (issue #23). +func TestTmpl_36_TextPadding(t *testing.T) { + schema := []byte(`{ + "page": {"size": "A4", "margins": "20mm"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "{{.Title}}", "style": {"size": 18, "bold": true}}, + {"type": "spacer", "height": "5mm"} + ]} + ]}}, + + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "A. Uniform 10mm padding fills the BgColor area", "style": {"size": 11, "bold": true}}, + {"type": "spacer", "height": "2mm"} + ]} + ]}}, + {"row": {"cols": [ + {"span": 6, "elements": [ + {"type": "text", "content": "No padding", "style": {"background": "{{.BgA}}"}} + ]}, + {"span": 6, "elements": [ + {"type": "text", "content": "Should have 10 mm padding", "style": {"background": "{{.BgA}}", "padding": "{{.PadA}}"}} + ]} + ]}}, + + {"row": {"cols": [ + {"span": 12, "elements": [{"type": "spacer", "height": "8mm"}]} + ]}}, + + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "B. Asymmetric padding (top/bottom only)", "style": {"size": 11, "bold": true}}, + {"type": "spacer", "height": "2mm"} + ]} + ]}}, + {"row": {"cols": [ + {"span": 6, "elements": [ + {"type": "text", "content": "{{.LongB}}", "style": {"background": "{{.BgB}}"}} + ]}, + {"span": 6, "elements": [ + {"type": "text", "content": "{{.LongB}}", "style": {"background": "{{.BgB}}", "paddings": {{toJSON .PadsB}}}} + ]} + ]}}, + + {"row": {"cols": [ + {"span": 12, "elements": [{"type": "spacer", "height": "8mm"}]} + ]}}, + + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "C. Horizontal padding narrows the wrap width", "style": {"size": 11, "bold": true}}, + {"type": "spacer", "height": "2mm"} + ]} + ]}}, + {"row": {"cols": [ + {"span": 6, "elements": [ + {"type": "text", "content": "{{.LongC}}", "style": {"background": "{{.BgC}}"}} + ]}, + {"span": 6, "elements": [ + {"type": "text", "content": "{{.LongC}}", "style": {"background": "{{.BgC}}", "paddings": {{toJSON .PadsC}}}} + ]} + ]}} + ] + }`) + + data := map[string]any{ + "Title": "Text padding (issue #23)", + "BgA": "gray(0.85)", + "PadA": "10mm", + "LongB": "Plain wrapped text that spans across multiple lines so that " + + "line wrapping is exercised in this column.", + "BgB": "#E3F2FD", + "PadsB": []string{"6mm", "0", "6mm", "0"}, + "LongC": "Lorem ipsum dolor sit amet consectetur adipiscing elit " + + "sed do eiusmod tempor incididunt ut labore et dolore.", + "BgC": "#FFF3E0", + "PadsC": []string{"0", "12mm", "0", "12mm"}, + } + + doc, err := template.FromJSON(schema, data) + if err != nil { + t.Fatalf("FromJSON error: %v", err) + } + testutil.GeneratePDFSharedGolden(t, "36_text_padding.pdf", doc) +} diff --git a/_examples/json/36_text_padding_test.go b/_examples/json/36_text_padding_test.go new file mode 100644 index 0000000..4250d52 --- /dev/null +++ b/_examples/json/36_text_padding_test.go @@ -0,0 +1,84 @@ +package json_test + +import ( + "testing" + + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/template" +) + +// TestJSON_36_TextPadding mirrors TestExample_36_TextPadding so the shared +// golden file ../testdata/golden/36_text_padding.pdf compares byte-identically +// across Builder, JSON, and Go-template entry points (issue #23). +func TestJSON_36_TextPadding(t *testing.T) { + schema := []byte(`{ + "page": {"size": "A4", "margins": "20mm"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "Text padding (issue #23)", "style": {"size": 18, "bold": true}}, + {"type": "spacer", "height": "5mm"} + ]} + ]}}, + + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "A. Uniform 10mm padding fills the BgColor area", "style": {"size": 11, "bold": true}}, + {"type": "spacer", "height": "2mm"} + ]} + ]}}, + {"row": {"cols": [ + {"span": 6, "elements": [ + {"type": "text", "content": "No padding", "style": {"background": "gray(0.85)"}} + ]}, + {"span": 6, "elements": [ + {"type": "text", "content": "Should have 10 mm padding", "style": {"background": "gray(0.85)", "padding": "10mm"}} + ]} + ]}}, + + {"row": {"cols": [ + {"span": 12, "elements": [{"type": "spacer", "height": "8mm"}]} + ]}}, + + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "B. Asymmetric padding (top/bottom only)", "style": {"size": 11, "bold": true}}, + {"type": "spacer", "height": "2mm"} + ]} + ]}}, + {"row": {"cols": [ + {"span": 6, "elements": [ + {"type": "text", "content": "Plain wrapped text that spans across multiple lines so that line wrapping is exercised in this column.", "style": {"background": "#E3F2FD"}} + ]}, + {"span": 6, "elements": [ + {"type": "text", "content": "Plain wrapped text that spans across multiple lines so that line wrapping is exercised in this column.", "style": {"background": "#E3F2FD", "paddings": ["6mm", "0", "6mm", "0"]}} + ]} + ]}}, + + {"row": {"cols": [ + {"span": 12, "elements": [{"type": "spacer", "height": "8mm"}]} + ]}}, + + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "C. Horizontal padding narrows the wrap width", "style": {"size": 11, "bold": true}}, + {"type": "spacer", "height": "2mm"} + ]} + ]}}, + {"row": {"cols": [ + {"span": 6, "elements": [ + {"type": "text", "content": "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore.", "style": {"background": "#FFF3E0"}} + ]}, + {"span": 6, "elements": [ + {"type": "text", "content": "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore.", "style": {"background": "#FFF3E0", "paddings": ["0", "12mm", "0", "12mm"]}} + ]} + ]}} + ] + }`) + + doc, err := template.FromJSON(schema, nil) + if err != nil { + t.Fatalf("FromJSON error: %v", err) + } + testutil.GeneratePDFSharedGolden(t, "36_text_padding.pdf", doc) +} diff --git a/_examples/testdata/golden/36_text_padding.pdf b/_examples/testdata/golden/36_text_padding.pdf new file mode 100644 index 0000000..faa05ff Binary files /dev/null and b/_examples/testdata/golden/36_text_padding.pdf differ diff --git a/document/layout/flow.go b/document/layout/flow.go index c08237d..5af2d3c 100644 --- a/document/layout/flow.go +++ b/document/layout/flow.go @@ -14,6 +14,13 @@ type FlowLayout struct{} // LayoutText breaks the given text into wrapped lines and returns a // Result describing the bounds and per-line placed nodes. If the text // does not fit vertically, the remaining text is returned as overflow. +// +// Style.Padding and Style.Border participate in layout following the CSS +// box model: lines are wrapped within the inner content area, individual +// lines are offset by the top/left inset, and the returned Bounds +// include the full padded/bordered box. Style.Margin is intentionally +// not consumed here — inter-node spacing is handled by the parent +// block layout. func (fl *FlowLayout) LayoutText(text string, style document.Style, constraints Constraints) Result { if text == "" { return Result{ @@ -33,30 +40,49 @@ func (fl *FlowLayout) LayoutText(text string, style document.Style, constraints lineSpacing := fontSize * lineHeight - // Resolve text indent for the first line. - indent := style.TextIndent.Resolve(constraints.AvailableWidth, fontSize) + padding := style.Padding.Resolve(constraints.AvailableWidth, constraints.AvailableHeight, fontSize) + borderWidths := resolveBorderWidths(style.Border, constraints.AvailableWidth, fontSize) + insetX := padding.Left + borderWidths.Left + insetY := padding.Top + borderWidths.Top + verticalInset := padding.Vertical() + borderWidths.Vertical() + + inner := constraints + inner.AvailableWidth = constraints.AvailableWidth - padding.Horizontal() - borderWidths.Horizontal() + inner.AvailableHeight = constraints.AvailableHeight - verticalInset + if inner.AvailableWidth < 0 { + inner.AvailableWidth = 0 + } + if inner.AvailableHeight < 0 { + inner.AvailableHeight = 0 + } + + // Resolve text indent for the first line, against the inner width. + indent := style.TextIndent.Resolve(inner.AvailableWidth, fontSize) // Break text into lines, using a narrower first line when indented. - lines := breakTextLinesIndented(text, style, fontSize, indent, constraints) + lines := breakTextLinesIndented(text, style, fontSize, indent, inner) var placed []PlacedNode cursorY := 0.0 for i, line := range lines { // Check whether this line fits in the remaining vertical space. - if cursorY+lineSpacing > constraints.AvailableHeight { - return overflowResult(lines[i:], style, constraints.AvailableWidth, cursorY, placed) + if cursorY+lineSpacing > inner.AvailableHeight { + return overflowResult(lines[i:], style, constraints.AvailableWidth, cursorY+verticalInset, placed) } - lineWidth := measureLineWidth(line, style, fontSize, constraints) + lineWidth := measureLineWidth(line, style, fontSize, inner) lineIndent := 0.0 if i == 0 { lineIndent = indent } - pn := placeLine(line, lineWidth, style, i == len(lines)-1, constraints.AvailableWidth, lineSpacing, cursorY) + pn := placeLine(line, lineWidth, style, i == len(lines)-1, inner.AvailableWidth, lineSpacing, cursorY) if lineIndent != 0 { pn.Position.X += lineIndent } + // Shift the line into the padded/bordered content area. + pn.Position.X += insetX + pn.Position.Y += insetY placed = append(placed, pn) cursorY += lineSpacing } @@ -64,7 +90,7 @@ func (fl *FlowLayout) LayoutText(text string, style document.Style, constraints return Result{ Bounds: document.Rectangle{ Width: constraints.AvailableWidth, - Height: cursorY, + Height: cursorY + verticalInset, }, Children: placed, } @@ -128,6 +154,10 @@ func measureLineWidth(line string, style document.Style, fontSize float64, const // justification and alignment as needed. func placeLine(line string, lineWidth float64, style document.Style, isLastLine bool, availWidth, lineSpacing, cursorY float64) PlacedNode { lineStyle := style + // Box-model properties belong to the parent Text node; strip them so + // they are not redrawn around every wrapped line. + lineStyle.Padding = document.Edges{} + lineStyle.Border = document.BorderEdges{} // For justified text, distribute extra space between words. // The last line of a paragraph uses left alignment (standard typographic convention). diff --git a/document/layout/layout_test.go b/document/layout/layout_test.go index 1a58a0a..7f22c95 100644 --- a/document/layout/layout_test.go +++ b/document/layout/layout_test.go @@ -928,6 +928,122 @@ func TestFlowLayoutTextIndentSingleLine(t *testing.T) { } } +func TestFlowLayoutTextPadding(t *testing.T) { + fl := &FlowLayout{} + style := document.Style{ + FontSize: 12, + LineHeight: 1.2, + Padding: document.UniformEdges(document.Pt(10)), + } + constraints := Constraints{ + AvailableWidth: 500, + AvailableHeight: 700, + FontResolver: &mockFontResolver{}, + } + result := fl.LayoutText("Hello", style, constraints) + if len(result.Children) != 1 { + t.Fatalf("Expected 1 line, got %d", len(result.Children)) + } + // Line should be inset by the top/left padding. + if !approxEqual(result.Children[0].Position.X, 10, 0.01) { + t.Errorf("Line X = %v, want 10", result.Children[0].Position.X) + } + if !approxEqual(result.Children[0].Position.Y, 10, 0.01) { + t.Errorf("Line Y = %v, want 10", result.Children[0].Position.Y) + } + // Bounds should grow by vertical padding (top + bottom = 20). + wantHeight := 12.0*1.2 + 20.0 + if !approxEqual(result.Bounds.Height, wantHeight, 0.001) { + t.Errorf("Height = %v, want %v", result.Bounds.Height, wantHeight) + } + // Width is unchanged at the outer container size. + if !approxEqual(result.Bounds.Width, 500, 0.001) { + t.Errorf("Width = %v, want 500", result.Bounds.Width) + } +} + +func TestFlowLayoutTextPaddingShrinksWrapWidth(t *testing.T) { + fl := &FlowLayout{} + // With fontSize=12, mockFontResolver gives 6pt per char. AvailableWidth=60 + // fits 10 chars; with 10pt padding on each side, inner=40 fits 6 chars, + // forcing each 3-char word onto its own line. + const text = "abc def ghi jkl mno pqr" + constraints := Constraints{ + AvailableWidth: 60, + AvailableHeight: 700, + FontResolver: &mockFontResolver{}, + } + + plain := fl.LayoutText(text, document.Style{ + FontSize: 12, + LineHeight: 1.2, + }, constraints) + + padded := fl.LayoutText(text, document.Style{ + FontSize: 12, + LineHeight: 1.2, + Padding: document.UniformEdges(document.Pt(10)), + }, constraints) + + if len(padded.Children) <= len(plain.Children) { + t.Errorf("Padded layout should wrap into more lines than plain: padded=%d plain=%d", + len(padded.Children), len(plain.Children)) + } +} + +func TestFlowLayoutTextBorder(t *testing.T) { + fl := &FlowLayout{} + style := document.Style{ + FontSize: 12, + LineHeight: 1.2, + Border: document.UniformBorder(document.Pt(2), document.BorderSolid, pdf.Color{}), + } + constraints := Constraints{ + AvailableWidth: 500, + AvailableHeight: 700, + FontResolver: &mockFontResolver{}, + } + result := fl.LayoutText("Hello", style, constraints) + if len(result.Children) != 1 { + t.Fatalf("Expected 1 line, got %d", len(result.Children)) + } + if !approxEqual(result.Children[0].Position.X, 2, 0.01) { + t.Errorf("Line X = %v, want 2", result.Children[0].Position.X) + } + if !approxEqual(result.Children[0].Position.Y, 2, 0.01) { + t.Errorf("Line Y = %v, want 2", result.Children[0].Position.Y) + } + wantHeight := 12.0*1.2 + 4.0 // vertical border = 2 + 2 + if !approxEqual(result.Bounds.Height, wantHeight, 0.001) { + t.Errorf("Height = %v, want %v", result.Bounds.Height, wantHeight) + } +} + +func TestFlowLayoutTextOverflowIncludesPadding(t *testing.T) { + fl := &FlowLayout{} + lineSpacing := 12.0 * 1.5 // 18 + style := document.Style{ + FontSize: 12, + LineHeight: 1.5, + Padding: document.UniformEdges(document.Pt(4)), + } + // Inner height = AvailableHeight - 8. Allow exactly one line to fit. + constraints := Constraints{ + AvailableWidth: 60, + AvailableHeight: lineSpacing + 8, // 8 = vertical padding + FontResolver: &mockFontResolver{}, + } + result := fl.LayoutText("Hello world this is a longer text that wraps", style, constraints) + if result.Overflow == nil { + t.Fatal("Expected overflow with limited height") + } + // One line placed; bounds height should include vertical padding (8). + wantHeight := lineSpacing + 8 + if !approxEqual(result.Bounds.Height, wantHeight, 0.001) { + t.Errorf("Overflow Bounds.Height = %v, want %v", result.Bounds.Height, wantHeight) + } +} + func TestApproximateLineBreak(t *testing.T) { // fontSize=12 => avgCharWidth=6. maxWidth=60 => 10 chars per line. lines := approximateLineBreak("Hello world this is test", 12, 60) diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 408136a..20b9107 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.7" +const Version = "1.0.8" diff --git a/template/component.go b/template/component.go index cce98bd..8eaebe4 100644 --- a/template/component.go +++ b/template/component.go @@ -67,6 +67,13 @@ func TextIndent(v document.Value) TextOption { return func(s *document.Style) { s.TextIndent = v } } +// TextPadding sets per-edge padding inside the text element. The padded +// area is included in the element's height and is filled by [BgColor] +// when set, matching the CSS box model. +func TextPadding(e document.Edges) TextOption { + return func(s *document.Style) { s.Padding = e } +} + // Underline adds underline decoration to text. func Underline() TextOption { return func(s *document.Style) { s.TextDecoration |= document.DecorationUnderline } diff --git a/template/schema.go b/template/schema.go index e5000d3..0c9b1b2 100644 --- a/template/schema.go +++ b/template/schema.go @@ -111,18 +111,25 @@ type SchemaElement struct { } // SchemaStyle defines text styling properties. +// +// Padding is the uniform padding inside the text element (e.g. "10mm"). +// Paddings overrides Padding with per-edge values in CSS order +// [top, right, bottom, left]. The padded area is included in the +// element's height and is filled by Background when set. type SchemaStyle struct { - Size float64 `json:"size,omitempty"` - Bold bool `json:"bold,omitempty"` - Italic bool `json:"italic,omitempty"` - Align string `json:"align,omitempty"` // "left", "center", "right" - Color string `json:"color,omitempty"` // "#RRGGBB" or named - Background string `json:"background,omitempty"` // "#RRGGBB" or named - FontFamily string `json:"fontFamily,omitempty"` - Underline bool `json:"underline,omitempty"` - Strikethrough bool `json:"strikethrough,omitempty"` - LetterSpacing float64 `json:"letterSpacing,omitempty"` - TextIndent string `json:"textIndent,omitempty"` // e.g. "24pt", "10mm" + Size float64 `json:"size,omitempty"` + Bold bool `json:"bold,omitempty"` + Italic bool `json:"italic,omitempty"` + Align string `json:"align,omitempty"` // "left", "center", "right" + Color string `json:"color,omitempty"` // "#RRGGBB" or named + Background string `json:"background,omitempty"` // "#RRGGBB" or named + FontFamily string `json:"fontFamily,omitempty"` + Underline bool `json:"underline,omitempty"` + Strikethrough bool `json:"strikethrough,omitempty"` + LetterSpacing float64 `json:"letterSpacing,omitempty"` + TextIndent string `json:"textIndent,omitempty"` // e.g. "24pt", "10mm" + Padding string `json:"padding,omitempty"` // uniform, e.g. "10mm" + Paddings []string `json:"paddings,omitempty"` // per-edge [top, right, bottom, left] } // SchemaImage defines an image element. @@ -387,9 +394,60 @@ func applySchemaStyle(ss *SchemaStyle) []TextOption { opts = append(opts, TextIndent(v)) } } + if e, ok := parseSchemaPadding(ss); ok { + opts = append(opts, TextPadding(e)) + } return opts } +// parseSchemaPadding resolves [SchemaStyle.Padding] / [SchemaStyle.Paddings] +// into [document.Edges]. Paddings, when present, overrides Padding and +// follows the CSS [top, right, bottom, left] order. +func parseSchemaPadding(ss *SchemaStyle) (document.Edges, bool) { + if len(ss.Paddings) > 0 { + edges, err := parseEdgeList(ss.Paddings) + if err != nil { + return document.Edges{}, false + } + return edges, true + } + if ss.Padding != "" { + v, err := parseValue(ss.Padding) + if err != nil { + return document.Edges{}, false + } + return document.UniformEdges(v), true + } + return document.Edges{}, false +} + +// parseEdgeList converts a CSS-style 1/2/3/4 element shorthand into +// per-edge [document.Edges]. The mapping mirrors the CSS padding +// shorthand: [top right bottom left] for 4 values, [top horizontal bottom] +// for 3, [vertical horizontal] for 2, and [all] for 1. +func parseEdgeList(values []string) (document.Edges, error) { + parsed := make([]document.Value, len(values)) + for i, s := range values { + v, err := parseValue(s) + if err != nil { + return document.Edges{}, fmt.Errorf("edge %d: %w", i, err) + } + parsed[i] = v + } + switch len(parsed) { + case 1: + return document.UniformEdges(parsed[0]), nil + case 2: + return document.Edges{Top: parsed[0], Right: parsed[1], Bottom: parsed[0], Left: parsed[1]}, nil + case 3: + return document.Edges{Top: parsed[0], Right: parsed[1], Bottom: parsed[2], Left: parsed[1]}, nil + case 4: + return document.Edges{Top: parsed[0], Right: parsed[1], Bottom: parsed[2], Left: parsed[3]}, nil + default: + return document.Edges{}, fmt.Errorf("expected 1-4 values, got %d", len(parsed)) + } +} + // appendColorOpt parses a color string and appends the resulting option if valid. func appendColorOpt(opts []TextOption, s string, fn func(pdf.Color) TextOption) []TextOption { if s == "" {