diff --git a/api/builder.go b/api/builder.go
index f2ab93e7..cacf1461 100644
--- a/api/builder.go
+++ b/api/builder.go
@@ -21,7 +21,7 @@ func NewText(content string) *TextBuilder {
return &TextBuilder{
text: Text{
Content: content,
- Children: make([]Text, 0),
+ Children: make([]Textable, 0),
},
}
}
diff --git a/api/builder_test.go b/api/builder_test.go
index e47ed356..a61b95eb 100644
--- a/api/builder_test.go
+++ b/api/builder_test.go
@@ -165,7 +165,7 @@ func TestChildBuilder(t *testing.T) {
t.Errorf("expected 1 child, got %d", len(result.Children))
}
- if result.Children[0].Content != "Child" {
- t.Errorf("expected child content %q, got %q", "Child", result.Children[0].Content)
+ if result.Children[0].String() != "Child" {
+ t.Errorf("expected child content %q, got %q", "Child", result.Children[0].String())
}
}
diff --git a/api/icons/icons.go b/api/icons/icons.go
new file mode 100644
index 00000000..34b23652
--- /dev/null
+++ b/api/icons/icons.go
@@ -0,0 +1,87 @@
+package icons
+
+import "fmt"
+
+type Icon struct {
+ Unicode string
+ Iconify string
+ Style string
+}
+
+// String returns the Unicode representation of the icon
+func (i Icon) String() string {
+ return i.Unicode
+}
+
+// ANSI returns the Unicode representation (same as String for icons)
+func (i Icon) ANSI() string {
+ return i.Unicode
+}
+
+// HTML returns an HTML representation using Iconify classes or Unicode fallback
+func (i Icon) HTML() string {
+ if i.Iconify != "" {
+ return fmt.Sprintf(`%s`, i.Iconify, i.Unicode)
+ }
+ return i.Unicode
+}
+
+// Markdown returns the Unicode representation (same as String for icons)
+func (i Icon) Markdown() string {
+ return i.Unicode
+}
+
+var (
+ Config = "⚙️"
+ Success Icon = Icon{Unicode: "✓", Iconify: "check", Style: "success"}
+ Error = Icon{Unicode: "✗", Iconify: "close", Style: "error"}
+ Fail = Icon{Unicode: "✗", Iconify: "close", Style: "error"}
+ Pass = Icon{Unicode: "✓", Iconify: "check", Style: "success"}
+ Skip = Icon{Unicode: "→", Iconify: "arrow-right", Style: "warning"}
+ Unknown = Icon{Unicode: "?", Iconify: "help", Style: "muted"}
+ Info = Icon{Unicode: "•", Iconify: "bullet", Style: "info"}
+ Warning = Icon{Unicode: "!", Iconify: "alert-circle", Style: "warning"}
+ Circle = Icon{Unicode: "○", Iconify: "circle", Style: "muted"}
+ ArrowUp = Icon{Unicode: "↑", Iconify: "arrow-up", Style: "muted"}
+ ArrowDown = Icon{Unicode: "↓", Iconify: "arrow-down", Style: "muted"}
+ ArrowLeft = Icon{Unicode: "←", Iconify: "arrow-left", Style: "muted"}
+ ArrowUpRight = Icon{Unicode: "↗", Iconify: "arrow-up-right", Style: "muted"}
+ ArrowDownRight = Icon{Unicode: "↘", Iconify: "arrow-down-right", Style: "muted"}
+ ArrowDownLeft = Icon{Unicode: "↙", Iconify: "arrow-down-left", Style: "muted"}
+ ArrowUpLeft = Icon{Unicode: "↖", Iconify: "arrow-up-left", Style: "muted"}
+ ArrowDoubleUpDown = Icon{Unicode: "⇕", Iconify: "arrows-up-down", Style: "muted"}
+ ArrowUpDown = Icon{Unicode: "⇵", Iconify: "arrow-up-down", Style: "muted"}
+ ArrowLeftRight = Icon{Unicode: "⇄", Iconify: "arrows-left-right", Style: "muted"}
+ ArrowoubleLeftRight = Icon{Unicode: "⇔", Iconify: "arrows-left-right", Style: "muted"}
+ ArrowDoubleRight = Icon{Unicode: "⇒", Iconify: "arrow-right", Style: "muted"}
+ ArrowDoubleLeft = Icon{Unicode: "⇐", Iconify: "arrow-left", Style: "muted"}
+ ArrowRight = Icon{Unicode: "→", Iconify: "arrow-right", Style: "muted"}
+ ChevronUp = Icon{Unicode: "▲", Iconify: "chevron-up", Style: "muted"}
+ ChevronDown = Icon{Unicode: "▼", Iconify: "chevron-down", Style: "muted"}
+ ChevronLeft = Icon{Unicode: "◀", Iconify: "chevron-left", Style: "muted"}
+ ChevronRight = Icon{Unicode: "▶", Iconify: "chevron-right", Style: "muted"}
+ InfoAlt = Icon{Unicode: "ℹ️", Iconify: "info", Style: "info"}
+ Star = Icon{Unicode: "★", Iconify: "star", Style: "muted"}
+ Heart = Icon{Unicode: "❤️", Iconify: "heart", Style: "muted"}
+ Link = Icon{Unicode: "🔗", Iconify: "link", Style: "muted"}
+ Golang = Icon{Unicode: "🐹", Iconify: "go", Style: "muted"}
+ Python = Icon{Unicode: "🐍", Iconify: "python", Style: "muted"}
+ JS = Icon{Unicode: "🟨", Iconify: "javascript", Style: "muted"}
+ Java = Icon{Unicode: "☕", Iconify: "java", Style: "muted"}
+ TS = Icon{Unicode: "🟦", Iconify: "typescript", Style: "muted"}
+ MD = Icon{Unicode: "📝", Iconify: "markdown", Style: "muted"}
+ File = Icon{Unicode: "📄", Iconify: "file", Style: "muted"}
+ Folder = Icon{Unicode: "📁", Iconify: "folder", Style: "muted"}
+ Search = Icon{Unicode: "🔍", Iconify: "search", Style: "muted"}
+ Cloud = Icon{Unicode: "☁️", Iconify: "cloud", Style: "muted"}
+ Package = Icon{Unicode: "📦", Iconify: "package", Style: "muted"}
+ Lambda = Icon{Unicode: "λ", Iconify: "lambda", Style: "muted"}
+ Method = Icon{Unicode: "ƒ", Iconify: "function", Style: "muted"}
+ Variable = Icon{Unicode: "𝑣", Iconify: "variable", Style: "muted"}
+ Type = Icon{Unicode: "🏷️", Iconify: "tag", Style: "muted"}
+ Interface = Icon{Unicode: "🔗", Iconify: "link", Style: "muted"}
+ Constant = Icon{Unicode: "π", Iconify: "constant", Style: "muted"}
+ Http = Icon{Unicode: "🌐", Iconify: "globe", Style: "muted"}
+ Queue = Icon{Unicode: "📥", Iconify: "inbox", Style: "muted"}
+ DB = Icon{Unicode: "🗄️", Iconify: "database", Style: "muted"}
+)
diff --git a/api/parser.go b/api/parser.go
index 59e9e10f..79cfbb98 100644
--- a/api/parser.go
+++ b/api/parser.go
@@ -7,6 +7,7 @@ import (
"strings"
"time"
+ "github.com/flanksource/commons/logger"
"gopkg.in/yaml.v3"
)
@@ -852,6 +853,54 @@ func (p *StructParser) GetTableFields(val reflect.Value) ([]PrettyField, error)
return fields, nil
}
+// StructToRowWithOptions converts a struct to a PrettyDataRow, checking for PrettyRow interface first
+func (p *StructParser) StructToRowWithOptions(val reflect.Value, opts interface{}) (PrettyDataRow, error) {
+ // Dereference pointer if needed
+ if val.Kind() == reflect.Ptr {
+ if val.IsNil() {
+ return nil, fmt.Errorf("cannot convert nil pointer to row")
+ }
+ val = val.Elem()
+ }
+
+ if val.Kind() != reflect.Struct {
+ return nil, fmt.Errorf("expected struct, got %s", val.Kind())
+ }
+
+ structType := val.Type()
+ logger.V(4).Infof("Processing struct type %s with %d fields for PrettyRow conversion", structType.Name(), val.NumField())
+
+ // Check if the struct implements PrettyRow interface
+ if val.CanInterface() {
+ if prettyRowInterface, ok := val.Interface().(PrettyRow); ok {
+ logger.Debugf("Struct %s implements PrettyRow interface - using custom implementation", structType.Name())
+
+ // Use the custom PrettyRow implementation
+ prettyRowMap := prettyRowInterface.PrettyRow(opts)
+ logger.V(4).Infof("PrettyRow() returned %d columns for struct %s", len(prettyRowMap), structType.Name())
+
+ // Convert map[string]Text to PrettyDataRow
+ row := make(PrettyDataRow)
+ for key, text := range prettyRowMap {
+ row[key] = FieldValue{
+ Value: text.Content,
+ Text: &text,
+ Field: PrettyField{Name: key},
+ }
+ }
+ return row, nil
+ } else {
+ logger.V(4).Infof("Struct %s does not implement PrettyRow interface - checking CanInterface capability", structType.Name())
+ }
+ } else {
+ logger.V(4).Infof("Struct %s cannot interface - skipping PrettyRow check", structType.Name())
+ }
+
+ // Fall back to reflection-based approach
+ logger.Debugf("Falling back to reflection-based parsing for struct %s (no PrettyRow interface)", structType.Name())
+ return p.StructToRow(val)
+}
+
// StructToRow converts a struct to a PrettyDataRow
func (p *StructParser) StructToRow(val reflect.Value) (PrettyDataRow, error) {
// Dereference pointer if needed
diff --git a/api/pretty_row_test.go b/api/pretty_row_test.go
new file mode 100644
index 00000000..ed17f7e9
--- /dev/null
+++ b/api/pretty_row_test.go
@@ -0,0 +1,148 @@
+package api
+
+import (
+ "fmt"
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestStruct implements PrettyRow interface for testing
+type TestStruct struct {
+ Name string
+ Count int
+ Status string
+}
+
+// PrettyRow implements the PrettyRow interface
+func (t TestStruct) PrettyRow(opts interface{}) map[string]Text {
+ result := make(map[string]Text)
+
+ // Custom column name and content
+ result["Name"] = Text{Content: t.Name, Style: "font-bold"}
+
+ // Conditional styling based on format options
+ countStyle := "text-blue-600"
+ if opts != nil {
+ // Simple check for NoColor - in real implementation, you'd type assert to FormatOptions
+ if s := fmt.Sprintf("%+v", opts); s != "" && fmt.Sprintf("%v", opts) != "" {
+ // For testing, assume NoColor is passed in a simple struct
+ if noColorOpts, ok := opts.(struct{ NoColor bool }); ok && noColorOpts.NoColor {
+ countStyle = ""
+ }
+ }
+ }
+ result["Count"] = Text{Content: fmt.Sprintf("%d", t.Count), Style: countStyle}
+
+ // Status with conditional coloring
+ statusStyle := "text-green-600"
+ if t.Status == "error" {
+ statusStyle = "text-red-600"
+ }
+ if opts != nil {
+ if noColorOpts, ok := opts.(struct{ NoColor bool }); ok && noColorOpts.NoColor {
+ statusStyle = ""
+ }
+ }
+ result["Status"] = Text{Content: t.Status, Style: statusStyle}
+
+ return result
+}
+
+func TestPrettyRowInterface(t *testing.T) {
+ // Create a test struct that implements PrettyRow
+ testStruct := TestStruct{
+ Name: "Test Item",
+ Count: 5,
+ Status: "success",
+ }
+
+ // Test basic PrettyRow functionality
+ prettyRow := testStruct.PrettyRow(nil)
+ assert.Equal(t, 3, len(prettyRow))
+ assert.Equal(t, "Test Item", prettyRow["Name"].Content)
+ assert.Equal(t, "font-bold", prettyRow["Name"].Style)
+ assert.Equal(t, "5", prettyRow["Count"].Content)
+ assert.Equal(t, "text-blue-600", prettyRow["Count"].Style)
+ assert.Equal(t, "success", prettyRow["Status"].Content)
+ assert.Equal(t, "text-green-600", prettyRow["Status"].Style)
+}
+
+func TestPrettyRowWithFormatOptions(t *testing.T) {
+ testStruct := TestStruct{
+ Name: "Test Item",
+ Count: 3,
+ Status: "error",
+ }
+
+ // Mock FormatOptions with NoColor
+ opts := struct{ NoColor bool }{NoColor: true}
+
+ prettyRow := testStruct.PrettyRow(opts)
+
+ // Verify that styles are disabled when NoColor is true
+ assert.Equal(t, "", prettyRow["Count"].Style)
+ assert.Equal(t, "", prettyRow["Status"].Style)
+ assert.Equal(t, "font-bold", prettyRow["Name"].Style) // Name should still have style
+}
+
+func TestStructToRowWithOptionsUsesInterface(t *testing.T) {
+ parser := NewStructParser()
+ testStruct := TestStruct{
+ Name: "Interface Test",
+ Count: 7,
+ Status: "active",
+ }
+
+ val := reflect.ValueOf(testStruct)
+ opts := struct{ NoColor bool }{NoColor: false}
+
+ // Call StructToRowWithOptions which should detect and use the PrettyRow interface
+ row, err := parser.StructToRowWithOptions(val, opts)
+ assert.NoError(t, err)
+ assert.NotNil(t, row)
+
+ // Verify that the PrettyRow interface was used
+ assert.Equal(t, 3, len(row))
+
+ // Check that the custom implementation was used
+ nameField, exists := row["Name"]
+ assert.True(t, exists)
+ assert.Equal(t, "Interface Test", nameField.Value)
+ assert.NotNil(t, nameField.Text)
+ assert.Equal(t, "Interface Test", nameField.Text.Content)
+ assert.Equal(t, "font-bold", nameField.Text.Style)
+
+ countField, exists := row["Count"]
+ assert.True(t, exists)
+ assert.Equal(t, "7", countField.Value)
+ assert.NotNil(t, countField.Text)
+ assert.Equal(t, "text-blue-600", countField.Text.Style)
+}
+
+func TestStructToRowFallbackWithoutInterface(t *testing.T) {
+ parser := NewStructParser()
+
+ // Regular struct without PrettyRow interface
+ regularStruct := struct {
+ Name string
+ Value int
+ }{
+ Name: "Regular Struct",
+ Value: 42,
+ }
+
+ val := reflect.ValueOf(regularStruct)
+ opts := struct{ NoColor bool }{NoColor: false}
+
+ // Should fall back to reflection-based approach
+ row, err := parser.StructToRowWithOptions(val, opts)
+ assert.NoError(t, err)
+ assert.NotNil(t, row)
+
+ // Verify fallback behavior
+ nameField, exists := row["Name"]
+ assert.True(t, exists)
+ assert.Equal(t, "Regular Struct", nameField.Value)
+}
\ No newline at end of file
diff --git a/api/tailwind/alignment.go b/api/tailwind/alignment.go
new file mode 100644
index 00000000..8d13130b
--- /dev/null
+++ b/api/tailwind/alignment.go
@@ -0,0 +1,126 @@
+package tailwind
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/flanksource/maroto/v2/pkg/consts/align"
+)
+
+// Compiled regex patterns for alignment parsing
+var (
+ // textAlignRegex matches text alignment classes: text-left, text-center, text-right, text-justify
+ // with optional vertical alignment: text-left-top, text-center-middle, etc.
+ textAlignRegex = regexp.MustCompile(`^text-(left|center|right|justify)(?:-(top|middle|bottom))?$`)
+
+ // verticalAlignRegex matches standalone vertical alignment: align-top, align-middle, align-bottom
+ verticalAlignRegex = regexp.MustCompile(`^align-(top|middle|bottom)$`)
+)
+
+// VerticalAlign represents vertical alignment options
+type VerticalAlign int
+
+const (
+ VerticalTop VerticalAlign = iota
+ VerticalMiddle
+ VerticalBottom
+)
+
+// ParsedAlignment contains both horizontal and vertical alignment information
+type ParsedAlignment struct {
+ Horizontal align.Type
+ Vertical VerticalAlign
+}
+
+// AlignmentParser handles parsing of Tailwind alignment classes
+type AlignmentParser struct{}
+
+// NewAlignmentParser creates a new alignment parser
+func NewAlignmentParser() *AlignmentParser {
+ return &AlignmentParser{}
+}
+
+// ParseAlignment extracts alignment information from a style string
+func (ap *AlignmentParser) ParseAlignment(style string) ParsedAlignment {
+ classes := strings.Fields(style)
+ alignment := ParsedAlignment{
+ Horizontal: align.Left, // Default horizontal alignment
+ Vertical: VerticalMiddle, // Default vertical alignment
+ }
+
+ // Parse alignment classes - last one wins for conflicts
+ for _, class := range classes {
+ if parsed := ap.parseAlignmentClass(class); parsed != nil {
+ if parsed.Horizontal != align.Left || class == "text-left" {
+ alignment.Horizontal = parsed.Horizontal
+ }
+ if parsed.Vertical != VerticalMiddle || strings.Contains(class, "align-") {
+ alignment.Vertical = parsed.Vertical
+ }
+ }
+ }
+
+ return alignment
+}
+
+// stringToHorizontalAlign converts string to horizontal alignment type
+func stringToHorizontalAlign(s string) align.Type {
+ switch s {
+ case "left":
+ return align.Left
+ case "center":
+ return align.Center
+ case "right":
+ return align.Right
+ case "justify":
+ return align.Justify
+ default:
+ return align.Left
+ }
+}
+
+// stringToVerticalAlign converts string to vertical alignment type
+func stringToVerticalAlign(s string) VerticalAlign {
+ switch s {
+ case "top":
+ return VerticalTop
+ case "middle":
+ return VerticalMiddle
+ case "bottom":
+ return VerticalBottom
+ default:
+ return VerticalMiddle
+ }
+}
+
+// parseAlignmentClass parses a single alignment class using regex patterns
+func (ap *AlignmentParser) parseAlignmentClass(class string) *ParsedAlignment {
+ // Try text alignment pattern first (covers most cases including combined alignment)
+ if matches := textAlignRegex.FindStringSubmatch(class); matches != nil {
+ horizontal := stringToHorizontalAlign(matches[1])
+ vertical := VerticalMiddle // Default
+
+ // Check if vertical alignment is specified
+ if len(matches) > 2 && matches[2] != "" {
+ vertical = stringToVerticalAlign(matches[2])
+ }
+
+ return &ParsedAlignment{Horizontal: horizontal, Vertical: vertical}
+ }
+
+ // Try standalone vertical alignment pattern
+ if matches := verticalAlignRegex.FindStringSubmatch(class); matches != nil {
+ vertical := stringToVerticalAlign(matches[1])
+ return &ParsedAlignment{Horizontal: align.Left, Vertical: vertical}
+ }
+
+ return nil
+}
+
+// Global parser instance for public API
+var DefaultAlignmentParser = NewAlignmentParser()
+
+// ParseAlignment is the public API for parsing alignment from style strings
+func ParseAlignment(style string) ParsedAlignment {
+ return DefaultAlignmentParser.ParseAlignment(style)
+}
diff --git a/api/tailwind/alignment_test.go b/api/tailwind/alignment_test.go
new file mode 100644
index 00000000..ff411d7e
--- /dev/null
+++ b/api/tailwind/alignment_test.go
@@ -0,0 +1,194 @@
+package tailwind
+
+import (
+ "testing"
+
+ "github.com/flanksource/maroto/v2/pkg/consts/align"
+)
+
+func TestParseAlignment(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expectedHoriz align.Type
+ expectedVert VerticalAlign
+ }{
+ // Basic horizontal alignments
+ {
+ name: "text-left",
+ input: "text-left",
+ expectedHoriz: align.Left,
+ expectedVert: VerticalMiddle,
+ },
+ {
+ name: "text-center",
+ input: "text-center",
+ expectedHoriz: align.Center,
+ expectedVert: VerticalMiddle,
+ },
+ {
+ name: "text-right",
+ input: "text-right",
+ expectedHoriz: align.Right,
+ expectedVert: VerticalMiddle,
+ },
+ {
+ name: "text-justify",
+ input: "text-justify",
+ expectedHoriz: align.Justify,
+ expectedVert: VerticalMiddle,
+ },
+
+ // Basic vertical alignments
+ {
+ name: "align-top",
+ input: "align-top",
+ expectedHoriz: align.Left,
+ expectedVert: VerticalTop,
+ },
+ {
+ name: "align-middle",
+ input: "align-middle",
+ expectedHoriz: align.Left,
+ expectedVert: VerticalMiddle,
+ },
+ {
+ name: "align-bottom",
+ input: "align-bottom",
+ expectedHoriz: align.Left,
+ expectedVert: VerticalBottom,
+ },
+
+ // Extended syntax combinations
+ {
+ name: "text-left-top",
+ input: "text-left-top",
+ expectedHoriz: align.Left,
+ expectedVert: VerticalTop,
+ },
+ {
+ name: "text-center-middle",
+ input: "text-center-middle",
+ expectedHoriz: align.Center,
+ expectedVert: VerticalMiddle,
+ },
+ {
+ name: "text-right-bottom",
+ input: "text-right-bottom",
+ expectedHoriz: align.Right,
+ expectedVert: VerticalBottom,
+ },
+
+ // Complex style strings (should extract alignment)
+ {
+ name: "style with text-center",
+ input: "font-bold text-center bg-gray-100",
+ expectedHoriz: align.Center,
+ expectedVert: VerticalMiddle,
+ },
+ {
+ name: "style with align-top",
+ input: "w-[200px] align-top font-mono",
+ expectedHoriz: align.Left,
+ expectedVert: VerticalTop,
+ },
+ {
+ name: "style with both alignments",
+ input: "text-right align-bottom font-bold",
+ expectedHoriz: align.Right,
+ expectedVert: VerticalBottom,
+ },
+
+ // No alignment specified (defaults)
+ {
+ name: "no alignment",
+ input: "font-bold bg-gray-100",
+ expectedHoriz: align.Left,
+ expectedVert: VerticalMiddle,
+ },
+
+ // Empty string
+ {
+ name: "empty string",
+ input: "",
+ expectedHoriz: align.Left,
+ expectedVert: VerticalMiddle,
+ },
+
+ // Multiple alignment classes (last one should win for conflicting properties)
+ {
+ name: "multiple horizontal alignments",
+ input: "text-left text-center text-right",
+ expectedHoriz: align.Right,
+ expectedVert: VerticalMiddle,
+ },
+ {
+ name: "multiple vertical alignments",
+ input: "align-top align-middle align-bottom",
+ expectedHoriz: align.Left,
+ expectedVert: VerticalBottom,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := ParseAlignment(tt.input)
+
+ if result.Horizontal != tt.expectedHoriz {
+ t.Errorf("ParseAlignment(%q).Horizontal = %v, want %v", tt.input, result.Horizontal, tt.expectedHoriz)
+ }
+
+ if result.Vertical != tt.expectedVert {
+ t.Errorf("ParseAlignment(%q).Vertical = %v, want %v", tt.input, result.Vertical, tt.expectedVert)
+ }
+ })
+ }
+}
+
+func TestRealWorldStyleStrings(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expectedHoriz align.Type
+ expectedVert VerticalAlign
+ }{
+ {
+ name: "table header style",
+ input: "font-bold bg-gray-800 text-white text-center align-middle",
+ expectedHoriz: align.Center,
+ expectedVert: VerticalMiddle,
+ },
+ {
+ name: "table cell style",
+ input: "w-[20ch] text-left align-middle font-medium",
+ expectedHoriz: align.Left,
+ expectedVert: VerticalMiddle,
+ },
+ {
+ name: "price column style",
+ input: "w-[10ch] text-right align-middle font-mono",
+ expectedHoriz: align.Right,
+ expectedVert: VerticalMiddle,
+ },
+ {
+ name: "status column style",
+ input: "w-[12ch] text-center align-top font-bold text-green-600",
+ expectedHoriz: align.Center,
+ expectedVert: VerticalTop,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := ParseAlignment(tt.input)
+
+ if result.Horizontal != tt.expectedHoriz {
+ t.Errorf("ParseAlignment(%q).Horizontal = %v, want %v", tt.input, result.Horizontal, tt.expectedHoriz)
+ }
+
+ if result.Vertical != tt.expectedVert {
+ t.Errorf("ParseAlignment(%q).Vertical = %v, want %v", tt.input, result.Vertical, tt.expectedVert)
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/api/tailwind/borders.go b/api/tailwind/borders.go
index 9277c086..2b0ab2b9 100644
--- a/api/tailwind/borders.go
+++ b/api/tailwind/borders.go
@@ -5,6 +5,23 @@ import (
"strings"
)
+// Border width lookup map for efficient parsing
+var borderWidths = map[string]float64{
+ "0": 0,
+ "2": 2,
+ "4": 4,
+ "8": 8,
+}
+
+// Border style lookup map for efficient parsing
+var borderStyles = map[string]LineStyle{
+ "solid": Solid,
+ "dashed": Dashed,
+ "dotted": Dotted,
+ "double": Double,
+ "none": None,
+}
+
// Border structs to avoid import cycle
type BorderColor struct {
Hex string
@@ -186,45 +203,32 @@ func parseBorderClass(class string, borders *Borders, globalWidth *float64, glob
// parseBorderWidth parses border width from a class suffix
// Supports: 0, 2, 4, 8, [3px], etc.
func parseBorderWidth(suffix string) (float64, bool) {
- switch suffix {
- case "0":
- return 0, true
- case "2":
- return 2, true
- case "4":
- return 4, true
- case "8":
- return 8, true
- default:
- // Handle arbitrary values like [3px]
- if strings.HasPrefix(suffix, "[") && strings.HasSuffix(suffix, "]") {
- value := strings.Trim(suffix, "[]")
- value = strings.TrimSuffix(value, "px") // Remove px unit
- if width, err := strconv.ParseFloat(value, 64); err == nil {
- return width, true
- }
- }
- // Handle numeric values
- if width, err := strconv.ParseFloat(suffix, 64); err == nil {
+ // Check standard border widths first
+ if width, exists := borderWidths[suffix]; exists {
+ return width, true
+ }
+
+ // Handle arbitrary values like [3px]
+ if strings.HasPrefix(suffix, "[") && strings.HasSuffix(suffix, "]") {
+ value := strings.Trim(suffix, "[]")
+ value = strings.TrimSuffix(value, "px") // Remove px unit
+ if width, err := strconv.ParseFloat(value, 64); err == nil {
return width, true
}
}
+
+ // Handle numeric values
+ if width, err := strconv.ParseFloat(suffix, 64); err == nil {
+ return width, true
+ }
+
return 0, false
}
// parseBorderStyle parses border style from a class suffix
func parseBorderStyle(suffix string) (LineStyle, bool) {
- switch suffix {
- case "solid":
- return Solid, true
- case "dashed":
- return Dashed, true
- case "dotted":
- return Dotted, true
- case "double":
- return Double, true
- case "none":
- return None, true
+ if style, exists := borderStyles[suffix]; exists {
+ return style, true
}
return Solid, false
}
diff --git a/api/tailwind/merger.go b/api/tailwind/merger.go
new file mode 100644
index 00000000..b612ced4
--- /dev/null
+++ b/api/tailwind/merger.go
@@ -0,0 +1,209 @@
+package tailwind
+
+import (
+ "slices"
+ "strings"
+)
+
+// Property classification for efficient lookup
+var (
+ // Width/height prefixes
+ widthPrefixes = []string{"w-", "min-w-", "max-w-"}
+ heightPrefixes = []string{"h-", "min-h-", "max-h-"}
+
+ // Text alignment keywords
+ textAlignments = []string{"left", "center", "right", "justify"}
+
+ // Vertical alignment prefixes
+ verticalAlignPrefixes = []string{"align-"}
+
+ // Font weight keywords
+ fontWeights = []string{"thin", "light", "normal", "medium", "semibold", "bold", "extrabold", "black"}
+
+ // Font size keywords
+ fontSizes = []string{"xs", "sm", "base", "lg", "xl"}
+
+ // Font family keywords
+ fontFamilies = []string{"sans", "serif", "mono"}
+
+ // Font style keywords
+ fontStyleKeywords = []string{"italic", "not-italic"}
+
+ // Padding/margin prefixes
+ paddingPrefixes = []string{"p-", "px-", "py-", "pt-", "pr-", "pb-", "pl-"}
+ marginPrefixes = []string{"m-", "mx-", "my-", "mt-", "mr-", "mb-", "ml-"}
+
+ // Border width keywords
+ borderWidthKeywords = []string{"0", "2", "4", "8"}
+
+ // Display values
+ displayValues = []string{"block", "inline", "inline-block", "flex", "inline-flex", "grid", "hidden"}
+
+ // Position values
+ positionValues = []string{"static", "fixed", "absolute", "relative", "sticky"}
+)
+
+// StyleMerger handles merging of Tailwind class strings with conflict resolution
+type StyleMerger struct{}
+
+// MergeStyles combines multiple Tailwind class strings
+// Last class wins for conflicting properties (e.g., text-red-500 overrides text-blue-500)
+func (sm *StyleMerger) MergeStyles(styles ...string) string {
+ if len(styles) == 0 {
+ return ""
+ }
+
+ // Parse all classes and create property map with order preservation
+ propertyMap := make(map[string]string)
+ propertyOrder := make([]string, 0) // Track property insertion order
+ propertyFirstSeen := make(map[string]bool) // Track which properties we've seen
+ uniqueClassSet := make(map[string]bool) // For deduplication
+ var uniqueClasses []string // Preserve order
+
+ for _, styleStr := range styles {
+ if styleStr == "" {
+ continue
+ }
+
+ classes := strings.Fields(styleStr)
+ for _, class := range classes {
+ // Categorize class by property type
+ property := sm.getPropertyType(class)
+
+ if property == "unique" {
+ // Non-conflicting classes are kept as-is but deduplicated
+ if !uniqueClassSet[class] {
+ uniqueClassSet[class] = true
+ uniqueClasses = append(uniqueClasses, class)
+ }
+ } else {
+ // Conflicting properties: last wins, but preserve first occurrence order
+ if !propertyFirstSeen[property] {
+ propertyFirstSeen[property] = true
+ propertyOrder = append(propertyOrder, property)
+ }
+ propertyMap[property] = class
+ }
+ }
+ }
+
+ // Rebuild class string from merged properties
+ var mergedClasses []string
+
+ // Add unique classes first (preserve input order)
+ mergedClasses = append(mergedClasses, uniqueClasses...)
+
+ // Add property-based classes in the order first seen
+ for _, property := range propertyOrder {
+ mergedClasses = append(mergedClasses, propertyMap[property])
+ }
+
+ return strings.Join(mergedClasses, " ")
+}
+
+// hasPrefix checks if class starts with any of the given prefixes
+func hasPrefix(class string, prefixes []string) bool {
+ for _, prefix := range prefixes {
+ if strings.HasPrefix(class, prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+// containsAnyKeyword checks if class contains any of the given keywords
+func containsAnyKeyword(class string, keywords []string) bool {
+ for _, keyword := range keywords {
+ if strings.Contains(class, keyword) {
+ return true
+ }
+ }
+ return false
+}
+
+// getPropertyType categorizes Tailwind classes by their CSS property
+// Returns "unique" for classes that don't conflict with others
+func (sm *StyleMerger) getPropertyType(class string) string {
+ switch {
+ // Width properties
+ case hasPrefix(class, widthPrefixes):
+ return "width"
+
+ // Height properties
+ case hasPrefix(class, heightPrefixes):
+ return "height"
+
+ // Text alignment
+ case strings.HasPrefix(class, "text-") && containsAnyKeyword(class, textAlignments):
+ return "text-align"
+
+ // Vertical alignment
+ case hasPrefix(class, verticalAlignPrefixes):
+ return "vertical-align"
+
+ // Background color
+ case strings.HasPrefix(class, "bg-") && !strings.Contains(class, "gradient"):
+ return "background-color"
+
+ // Font weight
+ case strings.HasPrefix(class, "font-") && containsAnyKeyword(class, fontWeights):
+ return "font-weight"
+
+ // Font size (check before text color to avoid conflicts)
+ case strings.HasPrefix(class, "text-") && containsAnyKeyword(class, fontSizes):
+ return "font-size"
+
+ // Text color
+ case strings.HasPrefix(class, "text-") && !strings.Contains(class, "align") && !strings.Contains(class, "decoration") && !strings.Contains(class, "transform"):
+ return "text-color"
+
+ // Font style
+ case slices.Contains(fontStyleKeywords, class):
+ return "font-style"
+
+ // Font family
+ case strings.HasPrefix(class, "font-") && containsAnyKeyword(class, fontFamilies):
+ return "font-family"
+
+ // Padding - treat as unique for non-conflicting combinations
+ case hasPrefix(class, paddingPrefixes):
+ return "unique"
+
+ // Margin - treat as unique for non-conflicting combinations
+ case hasPrefix(class, marginPrefixes):
+ return "unique"
+
+ // Border width
+ case strings.HasPrefix(class, "border-") && containsAnyKeyword(class, borderWidthKeywords) && !strings.Contains(class, "color"):
+ return "border-width"
+
+ // Border color
+ case strings.HasPrefix(class, "border-") && !strings.Contains(class, "width") && !strings.Contains(class, "style"):
+ return "border-color"
+
+ // Display
+ case slices.Contains(displayValues, class):
+ return "display"
+
+ // Position
+ case slices.Contains(positionValues, class):
+ return "position"
+
+ default:
+ // Non-conflicting classes are treated as unique
+ return "unique"
+ }
+}
+
+// Global merger instance for public API
+var DefaultStyleMerger = &StyleMerger{}
+
+// MergeStyles is the public API for merging style strings
+func MergeStyles(styles ...string) string {
+ return DefaultStyleMerger.MergeStyles(styles...)
+}
+
+// MergeClasses is a convenience function for merging base and override classes
+func MergeClasses(baseClasses, overrideClasses string) string {
+ return DefaultStyleMerger.MergeStyles(baseClasses, overrideClasses)
+}
diff --git a/api/tailwind/merger_test.go b/api/tailwind/merger_test.go
new file mode 100644
index 00000000..bd5093c3
--- /dev/null
+++ b/api/tailwind/merger_test.go
@@ -0,0 +1,256 @@
+package tailwind
+
+import (
+ "testing"
+)
+
+func TestMergeStyles(t *testing.T) {
+ tests := []struct {
+ name string
+ styles []string
+ expected string
+ }{
+ {
+ name: "single style",
+ styles: []string{"text-center font-bold"},
+ expected: "text-center font-bold",
+ },
+ {
+ name: "two styles no conflicts",
+ styles: []string{"text-center", "font-bold"},
+ expected: "text-center font-bold",
+ },
+ {
+ name: "text alignment conflict - last wins",
+ styles: []string{"text-left", "text-center"},
+ expected: "text-center",
+ },
+ {
+ name: "font weight conflict - last wins",
+ styles: []string{"font-normal", "font-bold"},
+ expected: "font-bold",
+ },
+ {
+ name: "color conflict - last wins",
+ styles: []string{"text-red-500", "text-blue-600"},
+ expected: "text-blue-600",
+ },
+ {
+ name: "background conflict - last wins",
+ styles: []string{"bg-gray-100", "bg-white"},
+ expected: "bg-white",
+ },
+ {
+ name: "complex merge with multiple conflicts",
+ styles: []string{"text-left font-normal bg-gray-100", "text-center font-bold", "text-right bg-white"},
+ expected: "text-right font-bold bg-white",
+ },
+ {
+ name: "margin and padding - no conflicts",
+ styles: []string{"m-4 p-2", "mx-2 py-4"},
+ expected: "m-4 p-2 mx-2 py-4",
+ },
+ {
+ name: "width specifications - last wins",
+ styles: []string{"w-1/2", "w-[200px]"},
+ expected: "w-[200px]",
+ },
+ {
+ name: "empty strings ignored",
+ styles: []string{"", "text-center", "", "font-bold", ""},
+ expected: "text-center font-bold",
+ },
+ {
+ name: "duplicate classes removed",
+ styles: []string{"text-center font-bold", "text-center bg-gray-100"},
+ expected: "text-center font-bold bg-gray-100",
+ },
+ {
+ name: "alignment with vertical positioning",
+ styles: []string{"align-top", "align-middle"},
+ expected: "align-middle",
+ },
+ {
+ name: "mixed alignment and text alignment",
+ styles: []string{"text-left align-top", "text-center align-middle"},
+ expected: "text-center align-middle",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := MergeStyles(tt.styles...)
+ if result != tt.expected {
+ t.Errorf("MergeStyles() = %q, want %q", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestStyleMergerCategorizeClass(t *testing.T) {
+ merger := &StyleMerger{}
+
+ tests := []struct {
+ name string
+ class string
+ expected string
+ }{
+ // Text alignment
+ {"left align", "text-left", "text-align"},
+ {"center align", "text-center", "text-align"},
+ {"right align", "text-right", "text-align"},
+ {"justify align", "text-justify", "text-align"},
+
+ // Font weight
+ {"normal weight", "font-normal", "font-weight"},
+ {"bold weight", "font-bold", "font-weight"},
+ {"light weight", "font-light", "font-weight"},
+ {"semibold weight", "font-semibold", "font-weight"},
+
+ // Font style
+ {"italic", "italic", "font-style"},
+ {"not italic", "not-italic", "font-style"},
+
+ // Text color
+ {"red text", "text-red-500", "text-color"},
+ {"blue text", "text-blue-600", "text-color"},
+ {"gray text", "text-gray-700", "text-color"},
+
+ // Background color
+ {"gray background", "bg-gray-100", "background-color"},
+ {"white background", "bg-white", "background-color"},
+ {"blue background", "bg-blue-500", "background-color"},
+
+ // Font size
+ {"text xs", "text-xs", "font-size"},
+ {"text sm", "text-sm", "font-size"},
+ {"text base", "text-base", "font-size"},
+ {"text lg", "text-lg", "font-size"},
+ {"text xl", "text-xl", "font-size"},
+
+ // Width
+ {"width 1/2", "w-1/2", "width"},
+ {"width full", "w-full", "width"},
+ {"width custom", "w-[200px]", "width"},
+ {"min width", "min-w-[100px]", "width"},
+ {"max width", "max-w-[300px]", "width"},
+
+ // Vertical alignment
+ {"align top", "align-top", "vertical-align"},
+ {"align middle", "align-middle", "vertical-align"},
+ {"align bottom", "align-bottom", "vertical-align"},
+
+ // Uncategorized (treated as unique/non-conflicting)
+ {"margin", "m-4", "unique"},
+ {"padding", "p-2", "unique"},
+ {"border", "border", "unique"},
+ {"custom class", "my-custom-class", "unique"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := merger.getPropertyType(tt.class)
+ if result != tt.expected {
+ t.Errorf("categorizeClass(%q) = %q, want %q", tt.class, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestStyleMergerMergeStyles(t *testing.T) {
+ merger := &StyleMerger{}
+
+ tests := []struct {
+ name string
+ styles []string
+ expected string
+ }{
+ {
+ name: "preserves order for non-conflicting classes",
+ styles: []string{"p-4 m-2", "border rounded"},
+ expected: "p-4 m-2 border rounded",
+ },
+ {
+ name: "resolves text alignment conflicts",
+ styles: []string{"text-left p-4", "text-center m-2"},
+ expected: "p-4 m-2 text-center",
+ },
+ {
+ name: "resolves font weight conflicts",
+ styles: []string{"font-normal text-lg", "font-bold text-sm"},
+ expected: "font-bold text-sm",
+ },
+ {
+ name: "handles multiple property conflicts",
+ styles: []string{"text-left font-normal bg-gray-100", "text-center font-bold bg-white"},
+ expected: "text-center font-bold bg-white",
+ },
+ {
+ name: "preserves uncategorized classes",
+ styles: []string{"custom-class-1", "custom-class-2"},
+ expected: "custom-class-1 custom-class-2",
+ },
+ {
+ name: "removes duplicate classes",
+ styles: []string{"p-4 text-center", "p-4 font-bold"},
+ expected: "p-4 text-center font-bold",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := merger.MergeStyles(tt.styles...)
+ if result != tt.expected {
+ t.Errorf("MergeStyles() = %q, want %q", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestDefaultStyleMerger(t *testing.T) {
+ // Test that the default style merger is properly initialized
+ result := MergeStyles("text-left", "text-center")
+ expected := "text-center"
+
+ if result != expected {
+ t.Errorf("Default MergeStyles() = %q, want %q", result, expected)
+ }
+}
+
+func TestEmptyAndWhitespaceHandling(t *testing.T) {
+ tests := []struct {
+ name string
+ styles []string
+ expected string
+ }{
+ {
+ name: "all empty strings",
+ styles: []string{"", "", ""},
+ expected: "",
+ },
+ {
+ name: "whitespace only",
+ styles: []string{" ", "\t", "\n"},
+ expected: "",
+ },
+ {
+ name: "mixed empty and valid",
+ styles: []string{"", "text-center", " ", "font-bold", ""},
+ expected: "text-center font-bold",
+ },
+ {
+ name: "extra whitespace in classes",
+ styles: []string{" text-center font-bold ", " bg-gray-100 "},
+ expected: "text-center font-bold bg-gray-100",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := MergeStyles(tt.styles...)
+ if result != tt.expected {
+ t.Errorf("MergeStyles() = %q, want %q", result, tt.expected)
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/api/tailwind/parsers_test.go b/api/tailwind/parsers_test.go
new file mode 100644
index 00000000..2e405c4d
--- /dev/null
+++ b/api/tailwind/parsers_test.go
@@ -0,0 +1,350 @@
+package tailwind
+
+import (
+ "testing"
+
+ "github.com/flanksource/maroto/v2/pkg/consts/align"
+)
+
+// TestRegexBasedSpacingParser tests the refactored spacing parser using regex
+func TestRegexBasedSpacingParser(t *testing.T) {
+ tests := []struct {
+ name string
+ class string
+ expectedTop *float64
+ expectedRight *float64
+ expectedBottom *float64
+ expectedLeft *float64
+ }{
+ {
+ name: "p-4 using regex",
+ class: "p-4",
+ expectedTop: floatPtr(1.0),
+ expectedRight: floatPtr(1.0),
+ expectedBottom: floatPtr(1.0),
+ expectedLeft: floatPtr(1.0),
+ },
+ {
+ name: "px-8 using regex",
+ class: "px-8",
+ expectedTop: nil,
+ expectedRight: floatPtr(2.0),
+ expectedBottom: nil,
+ expectedLeft: floatPtr(2.0),
+ },
+ {
+ name: "py-2 using regex",
+ class: "py-2",
+ expectedTop: floatPtr(0.5),
+ expectedRight: nil,
+ expectedBottom: floatPtr(0.5),
+ expectedLeft: nil,
+ },
+ {
+ name: "invalid class",
+ class: "not-padding",
+ expectedTop: nil,
+ expectedRight: nil,
+ expectedBottom: nil,
+ expectedLeft: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ top, right, bottom, left := ParsePadding(tt.class)
+
+ if !floatsEqual(top, tt.expectedTop) {
+ t.Errorf("ParsePadding() top = %v, want %v", formatFloat(top), formatFloat(tt.expectedTop))
+ }
+ if !floatsEqual(right, tt.expectedRight) {
+ t.Errorf("ParsePadding() right = %v, want %v", formatFloat(right), formatFloat(tt.expectedRight))
+ }
+ if !floatsEqual(bottom, tt.expectedBottom) {
+ t.Errorf("ParsePadding() bottom = %v, want %v", formatFloat(bottom), formatFloat(tt.expectedBottom))
+ }
+ if !floatsEqual(left, tt.expectedLeft) {
+ t.Errorf("ParsePadding() left = %v, want %v", formatFloat(left), formatFloat(tt.expectedLeft))
+ }
+ })
+ }
+}
+
+// TestRegexBasedAlignmentParser tests the refactored alignment parser using regex
+func TestRegexBasedAlignmentParser(t *testing.T) {
+ tests := []struct {
+ name string
+ class string
+ expectedHorizontal align.Type
+ expectedVertical VerticalAlign
+ }{
+ {
+ name: "text-left using regex",
+ class: "text-left",
+ expectedHorizontal: align.Left,
+ expectedVertical: VerticalMiddle,
+ },
+ {
+ name: "text-center using regex",
+ class: "text-center",
+ expectedHorizontal: align.Center,
+ expectedVertical: VerticalMiddle,
+ },
+ {
+ name: "text-right-top using regex",
+ class: "text-right-top",
+ expectedHorizontal: align.Right,
+ expectedVertical: VerticalTop,
+ },
+ {
+ name: "text-center-bottom using regex",
+ class: "text-center-bottom",
+ expectedHorizontal: align.Center,
+ expectedVertical: VerticalBottom,
+ },
+ {
+ name: "align-middle using regex",
+ class: "align-middle",
+ expectedHorizontal: align.Left,
+ expectedVertical: VerticalMiddle,
+ },
+ }
+
+ parser := NewAlignmentParser()
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := parser.parseAlignmentClass(tt.class)
+
+ if result == nil {
+ t.Fatalf("parseAlignmentClass() returned nil for class %s", tt.class)
+ }
+
+ if result.Horizontal != tt.expectedHorizontal {
+ t.Errorf("parseAlignmentClass() horizontal = %v, want %v", result.Horizontal, tt.expectedHorizontal)
+ }
+ if result.Vertical != tt.expectedVertical {
+ t.Errorf("parseAlignmentClass() vertical = %v, want %v", result.Vertical, tt.expectedVertical)
+ }
+ })
+ }
+}
+
+// TestSliceBasedFontWeightDetection tests the refactored font weight detection using slices
+func TestSliceBasedFontWeightDetection(t *testing.T) {
+ tests := []struct {
+ name string
+ styleString string
+ expectedBold bool
+ expectedFaint bool
+ }{
+ {
+ name: "bold detection using slices",
+ styleString: "bold text-red-500",
+ expectedBold: true,
+ expectedFaint: false,
+ },
+ {
+ name: "font-semibold detection using slices",
+ styleString: "font-semibold bg-blue-200",
+ expectedBold: true,
+ expectedFaint: false,
+ },
+ {
+ name: "font-light detection using slices",
+ styleString: "font-light text-gray-600",
+ expectedBold: false,
+ expectedFaint: true,
+ },
+ {
+ name: "font-thin detection using slices",
+ styleString: "font-thin underline",
+ expectedBold: false,
+ expectedFaint: true,
+ },
+ {
+ name: "no font weight",
+ styleString: "text-center bg-white",
+ expectedBold: false,
+ expectedFaint: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ style := ParseStyle(tt.styleString)
+
+ if style.Bold != tt.expectedBold {
+ t.Errorf("ParseStyle() bold = %v, want %v", style.Bold, tt.expectedBold)
+ }
+ if style.Faint != tt.expectedFaint {
+ t.Errorf("ParseStyle() faint = %v, want %v", style.Faint, tt.expectedFaint)
+ }
+ })
+ }
+}
+
+// TestMapBasedBorderParsing tests the refactored border parsing using maps
+func TestMapBasedBorderParsing(t *testing.T) {
+ tests := []struct {
+ name string
+ suffix string
+ expectedWidth float64
+ expectedValid bool
+ }{
+ {
+ name: "border width 0 using map",
+ suffix: "0",
+ expectedWidth: 0,
+ expectedValid: true,
+ },
+ {
+ name: "border width 2 using map",
+ suffix: "2",
+ expectedWidth: 2,
+ expectedValid: true,
+ },
+ {
+ name: "border width 8 using map",
+ suffix: "8",
+ expectedWidth: 8,
+ expectedValid: true,
+ },
+ {
+ name: "invalid border width",
+ suffix: "invalid",
+ expectedWidth: 0,
+ expectedValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ width, valid := parseBorderWidth(tt.suffix)
+
+ if valid != tt.expectedValid {
+ t.Errorf("parseBorderWidth() valid = %v, want %v", valid, tt.expectedValid)
+ }
+ if tt.expectedValid && width != tt.expectedWidth {
+ t.Errorf("parseBorderWidth() width = %v, want %v", width, tt.expectedWidth)
+ }
+ })
+ }
+
+ // Test border styles
+ styleTests := []struct {
+ name string
+ suffix string
+ expectedStyle LineStyle
+ expectedValid bool
+ }{
+ {
+ name: "solid style using map",
+ suffix: "solid",
+ expectedStyle: Solid,
+ expectedValid: true,
+ },
+ {
+ name: "dashed style using map",
+ suffix: "dashed",
+ expectedStyle: Dashed,
+ expectedValid: true,
+ },
+ {
+ name: "invalid style",
+ suffix: "invalid",
+ expectedStyle: Solid,
+ expectedValid: false,
+ },
+ }
+
+ for _, tt := range styleTests {
+ t.Run(tt.name, func(t *testing.T) {
+ style, valid := parseBorderStyle(tt.suffix)
+
+ if valid != tt.expectedValid {
+ t.Errorf("parseBorderStyle() valid = %v, want %v", valid, tt.expectedValid)
+ }
+ if tt.expectedValid && style != tt.expectedStyle {
+ t.Errorf("parseBorderStyle() style = %v, want %v", style, tt.expectedStyle)
+ }
+ })
+ }
+}
+
+// TestOptimizedPropertyDetection tests the refactored property type detection
+func TestOptimizedPropertyDetection(t *testing.T) {
+ tests := []struct {
+ name string
+ class string
+ expectedProperty string
+ }{
+ {
+ name: "width detection using helper",
+ class: "w-full",
+ expectedProperty: "width",
+ },
+ {
+ name: "min-width detection using helper",
+ class: "min-w-0",
+ expectedProperty: "width",
+ },
+ {
+ name: "font-weight detection using slice",
+ class: "font-bold",
+ expectedProperty: "font-weight",
+ },
+ {
+ name: "font-size detection using slice",
+ class: "text-xl",
+ expectedProperty: "font-size",
+ },
+ {
+ name: "display detection using slice",
+ class: "flex",
+ expectedProperty: "display",
+ },
+ {
+ name: "position detection using slice",
+ class: "absolute",
+ expectedProperty: "position",
+ },
+ {
+ name: "padding detection using helper",
+ class: "p-4",
+ expectedProperty: "unique",
+ },
+ }
+
+ merger := &StyleMerger{}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ property := merger.getPropertyType(tt.class)
+
+ if property != tt.expectedProperty {
+ t.Errorf("getPropertyType() = %v, want %v", property, tt.expectedProperty)
+ }
+ })
+ }
+}
+
+// Helper functions
+func floatPtr(f float64) *float64 {
+ return &f
+}
+
+func floatsEqual(a, b *float64) bool {
+ if a == nil && b == nil {
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+ return *a == *b
+}
+
+func formatFloat(f *float64) string {
+ if f == nil {
+ return "nil"
+ }
+ return "float64(" + string(rune(int(*f))) + ")"
+}
\ No newline at end of file
diff --git a/api/tailwind/spacing.go b/api/tailwind/spacing.go
index 9125607d..00385698 100644
--- a/api/tailwind/spacing.go
+++ b/api/tailwind/spacing.go
@@ -1,10 +1,20 @@
package tailwind
import (
+ "regexp"
"strconv"
"strings"
)
+// Compiled regex patterns for efficient parsing
+var (
+ // paddingPrefixRegex matches padding classes: p-, px-, py-, pt-, pr-, pb-, pl-
+ paddingPrefixRegex = regexp.MustCompile(`^(p|px|py|pt|pr|pb|pl)-(.+)$`)
+
+ // fontSizeRegex matches text size classes: text-xs, text-sm, text-base, etc.
+ fontSizeRegex = regexp.MustCompile(`^text-(.+)$`)
+)
+
// TailwindSpacing defines the Tailwind CSS spacing scale in rem units
var TailwindSpacing = map[string]float64{
"0": 0,
@@ -71,41 +81,14 @@ type PaddingValue struct {
// Returns padding values in rem units for each side (top, right, bottom, left)
// Returns nil values for sides that are not set
func ParsePadding(class string) (top, right, bottom, left *float64) {
- if !strings.Contains(class, "p-") && !strings.Contains(class, "px-") &&
- !strings.Contains(class, "py-") && !strings.Contains(class, "pt-") &&
- !strings.Contains(class, "pr-") && !strings.Contains(class, "pb-") &&
- !strings.Contains(class, "pl-") {
+ // Use regex to match padding classes and extract prefix and value
+ matches := paddingPrefixRegex.FindStringSubmatch(class)
+ if matches == nil {
return nil, nil, nil, nil
}
- // Extract the value part
- var prefix string
- var valueStr string
-
- if strings.HasPrefix(class, "p-") {
- prefix = "p"
- valueStr = strings.TrimPrefix(class, "p-")
- } else if strings.HasPrefix(class, "px-") {
- prefix = "px"
- valueStr = strings.TrimPrefix(class, "px-")
- } else if strings.HasPrefix(class, "py-") {
- prefix = "py"
- valueStr = strings.TrimPrefix(class, "py-")
- } else if strings.HasPrefix(class, "pt-") {
- prefix = "pt"
- valueStr = strings.TrimPrefix(class, "pt-")
- } else if strings.HasPrefix(class, "pr-") {
- prefix = "pr"
- valueStr = strings.TrimPrefix(class, "pr-")
- } else if strings.HasPrefix(class, "pb-") {
- prefix = "pb"
- valueStr = strings.TrimPrefix(class, "pb-")
- } else if strings.HasPrefix(class, "pl-") {
- prefix = "pl"
- valueStr = strings.TrimPrefix(class, "pl-")
- } else {
- return nil, nil, nil, nil
- }
+ prefix := matches[1] // p, px, py, pt, pr, pb, pl
+ valueStr := matches[2] // the value part after the prefix
// Parse the value
value, exists := TailwindSpacing[valueStr]
@@ -154,11 +137,12 @@ func ParsePadding(class string) (top, right, bottom, left *float64) {
// ParseFontSize parses a Tailwind font size utility class
// Returns the font size in rem units, or 0 if not a font size class
func ParseFontSize(class string) float64 {
- if !strings.HasPrefix(class, "text-") {
+ matches := fontSizeRegex.FindStringSubmatch(class)
+ if matches == nil {
return 0
}
- sizeStr := strings.TrimPrefix(class, "text-")
+ sizeStr := matches[1] // the size part after "text-"
// Check if it's a standard font size
if size, exists := TailwindFontSizes[sizeStr]; exists {
diff --git a/api/tailwind/tailwind.go b/api/tailwind/tailwind.go
index c7ff2136..6d39d5c1 100644
--- a/api/tailwind/tailwind.go
+++ b/api/tailwind/tailwind.go
@@ -3,6 +3,7 @@ package tailwind
import (
"fmt"
"math"
+ "slices"
"strconv"
"strings"
"sync"
@@ -10,6 +11,36 @@ import (
"github.com/muesli/termenv"
)
+// Slice-based lookups for efficient parsing (sorted for binary search if needed)
+var (
+ // Font weight classes that make text bold
+ boldWeights = []string{"bold", "font-bold", "font-semibold", "font-medium"}
+
+ // Font weight classes that make text light/faint
+ faintWeights = []string{"font-light", "font-thin", "font-extralight"}
+
+ // Font style classes for italic
+ italicStyles = []string{"italic", "font-italic"}
+
+ // Text decoration classes
+ underlineClasses = []string{"underline"}
+ strikethroughClasses = []string{"line-through", "strikethrough"}
+
+ // Text transform classes
+ uppercaseClasses = []string{"uppercase"}
+ lowercaseClasses = []string{"lowercase"}
+ capitalizeClasses = []string{"capitalize"}
+
+ // Opacity classes that make text faint
+ faintOpacities = []string{"opacity-50", "opacity-75", "opacity-25"}
+
+ // Visibility classes
+ invisibleClasses = []string{"invisible"}
+
+ // Text utility classes that affect appearance
+ truncateClasses = []string{"truncate", "text-ellipsis", "text-clip"}
+)
+
// ParseTailwindColor parses a Tailwind color class and returns the hex color value
// with adaptive color adjustment for terminal background.
// Supports formats like:
@@ -248,74 +279,63 @@ func ParseStyle(styleStr string) Style {
style.Background = Color(class)
}
- // Font weight classes
- switch class {
- case "bold", "font-bold":
- style.Bold = true
- case "font-semibold", "font-medium":
- // Use bold as fallback for semibold/medium
+ // Font weight classes using slice lookup
+ if slices.Contains(boldWeights, class) {
style.Bold = true
- case "font-light", "font-thin", "font-extralight":
- // Use faint for light weights
+ } else if slices.Contains(faintWeights, class) {
style.Faint = true
- case "font-normal":
+ } else if class == "font-normal" {
// Reset bold and faint
style.Bold = false
style.Faint = false
}
- // Font style classes
- switch class {
- case "italic", "font-italic":
+ // Font style classes using slice lookup
+ if slices.Contains(italicStyles, class) {
style.Italic = true
- case "not-italic":
+ } else if class == "not-italic" {
style.Italic = false
}
- // Text decoration classes
- switch class {
- case "underline":
+ // Text decoration classes using slice lookup
+ if slices.Contains(underlineClasses, class) {
style.Underline = true
- case "line-through", "strikethrough":
+ } else if slices.Contains(strikethroughClasses, class) {
style.Strikethrough = true
- case "no-underline":
+ } else if class == "no-underline" {
style.Underline = false
- case "overline":
+ } else if class == "overline" {
// Use underline as fallback for overline
style.Underline = true
}
- // Text transform classes
- switch class {
- case "uppercase":
+ // Text transform classes using slice lookup
+ if slices.Contains(uppercaseClasses, class) {
style.TextTransform = "uppercase"
- case "lowercase":
+ } else if slices.Contains(lowercaseClasses, class) {
style.TextTransform = "lowercase"
- case "capitalize":
+ } else if slices.Contains(capitalizeClasses, class) {
style.TextTransform = "capitalize"
- case "normal-case":
+ } else if class == "normal-case" {
style.TextTransform = ""
}
- // Additional text utilities
- switch class {
- case "truncate", "text-ellipsis", "text-clip":
+ // Additional text utilities using slice lookup
+ if slices.Contains(truncateClasses, class) {
style.MaxWidth = 50 // Example max width
}
- // Visibility utilities
- switch class {
- case "invisible":
+ // Visibility utilities using slice lookup
+ if slices.Contains(invisibleClasses, class) {
style.Faint = true
- case "visible":
+ } else if class == "visible" {
style.Faint = false
}
- // Opacity utilities (using Faint as approximation)
- switch class {
- case "opacity-50", "opacity-75", "opacity-25":
+ // Opacity utilities using slice lookup
+ if slices.Contains(faintOpacities, class) {
style.Faint = true
- case "opacity-100":
+ } else if class == "opacity-100" {
style.Faint = false
}
}
@@ -686,3 +706,31 @@ func adaptColorForBackground(hexColor string, isDark bool) string {
// No adaptation needed
return hexColor
}
+
+// ResolveLayoutFromStyle extracts width and alignment from a style string
+// This avoids import cycles by not depending on api.Class
+func ResolveLayoutFromStyle(style string) (*WidthSpec, ParsedAlignment) {
+ // Parse width using our new parser
+ width := ParseWidthFromStyle(style)
+
+ // Parse alignment using our new parser
+ align := ParseAlignment(style)
+
+ return width, align
+}
+
+// ExtractProperty extracts a specific property type from a style string
+// Returns the last occurrence of that property type (last wins)
+func ExtractProperty(style, property string) string {
+ classes := strings.Fields(style)
+ merger := DefaultStyleMerger
+
+ var lastMatch string
+ for _, class := range classes {
+ if merger.getPropertyType(class) == property {
+ lastMatch = class
+ }
+ }
+
+ return lastMatch
+}
diff --git a/api/tailwind/width.go b/api/tailwind/width.go
new file mode 100644
index 00000000..1df94b1f
--- /dev/null
+++ b/api/tailwind/width.go
@@ -0,0 +1,206 @@
+package tailwind
+
+import (
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// WidthType represents different width specification types
+type WidthType int
+
+const (
+ WidthAuto WidthType = iota
+ WidthPercentage
+ WidthCharacter
+ WidthPixel
+ WidthRem
+ WidthFraction
+ WidthFixed
+)
+
+// WidthSpec represents a parsed width specification
+type WidthSpec struct {
+ Type WidthType
+ Value float64
+ Unit string
+ IsMin bool // min-w-[...]
+ IsMax bool // max-w-[...]
+ Original string // Original class for debugging
+}
+
+// WidthParser handles parsing of Tailwind width classes
+type WidthParser struct {
+ // Compiled regex patterns for performance
+ arbitraryWidthRegex *regexp.Regexp // w-[10ch], min-w-[20px], max-w-[50%]
+ fractionWidthRegex *regexp.Regexp // w-1/2, w-1/3, w-2/5
+ namedWidthRegex *regexp.Regexp // w-auto, w-full, w-fit
+ numericWidthRegex *regexp.Regexp // w-12, w-64 (Tailwind spacing scale)
+}
+
+// NewWidthParser creates a new width parser with compiled regex patterns
+func NewWidthParser() *WidthParser {
+ return &WidthParser{
+ // Arbitrary values: w-[10ch], min-w-[20px], max-w-[50%]
+ arbitraryWidthRegex: regexp.MustCompile(`^(min-w|max-w|w)-\[([0-9.]+)(ch|px|rem|%|em|vh|vw)\]$`),
+
+ // Fractions: w-1/2, w-1/3, w-2/5, etc.
+ fractionWidthRegex: regexp.MustCompile(`^(min-w|max-w|w)-([0-9]+)\/([0-9]+)$`),
+
+ // Named widths: w-auto, w-full, w-fit, w-screen
+ namedWidthRegex: regexp.MustCompile(`^(min-w|max-w|w)-(auto|full|fit|screen|min|max)$`),
+
+ // Numeric scale: w-0, w-1, w-12, w-64, etc. (Tailwind spacing)
+ numericWidthRegex: regexp.MustCompile(`^(min-w|max-w|w)-([0-9]+(?:\.[0-9]+)?)$`),
+ }
+}
+
+// ParseWidth parses a single width class and returns the specification
+func (wp *WidthParser) ParseWidth(class string) (*WidthSpec, bool) {
+ // Try arbitrary values first: w-[10ch], min-w-[20px], max-w-[50%]
+ if matches := wp.arbitraryWidthRegex.FindStringSubmatch(class); matches != nil {
+ prefix := matches[1] // "w", "min-w", "max-w"
+ value := matches[2] // "10", "20", "50"
+ unit := matches[3] // "ch", "px", "%"
+
+ val, err := strconv.ParseFloat(value, 64)
+ if err != nil {
+ return nil, false
+ }
+
+ widthType := wp.getWidthTypeFromUnit(unit)
+ return &WidthSpec{
+ Type: widthType,
+ Value: val,
+ Unit: unit,
+ IsMin: prefix == "min-w",
+ IsMax: prefix == "max-w",
+ Original: class,
+ }, true
+ }
+
+ // Try fractions: w-1/2, w-1/3, w-2/5
+ if matches := wp.fractionWidthRegex.FindStringSubmatch(class); matches != nil {
+ prefix := matches[1] // "w", "min-w", "max-w"
+ numerator := matches[2] // "1", "2"
+ denominator := matches[3] // "2", "3", "5"
+
+ num, err1 := strconv.ParseFloat(numerator, 64)
+ den, err2 := strconv.ParseFloat(denominator, 64)
+ if err1 != nil || err2 != nil || den == 0 {
+ return nil, false
+ }
+
+ fraction := num / den
+ return &WidthSpec{
+ Type: WidthFraction,
+ Value: fraction,
+ Unit: "fraction",
+ IsMin: prefix == "min-w",
+ IsMax: prefix == "max-w",
+ Original: class,
+ }, true
+ }
+
+ // Try named widths: w-auto, w-full, w-fit
+ if matches := wp.namedWidthRegex.FindStringSubmatch(class); matches != nil {
+ prefix := matches[1] // "w", "min-w", "max-w"
+ name := matches[2] // "auto", "full", "fit"
+
+ var widthType WidthType
+ var value float64
+
+ switch name {
+ case "auto", "fit":
+ widthType = WidthAuto
+ value = 0
+ case "full":
+ widthType = WidthPercentage
+ value = 100
+ case "screen":
+ widthType = WidthPercentage
+ value = 100 // 100vw equivalent
+ default:
+ return nil, false
+ }
+
+ return &WidthSpec{
+ Type: widthType,
+ Value: value,
+ Unit: name,
+ IsMin: prefix == "min-w",
+ IsMax: prefix == "max-w",
+ Original: class,
+ }, true
+ }
+
+ // Try numeric scale: w-12, w-64 (Tailwind spacing scale)
+ if matches := wp.numericWidthRegex.FindStringSubmatch(class); matches != nil {
+ prefix := matches[1] // "w", "min-w", "max-w"
+ value := matches[2] // "12", "64"
+
+ val, err := strconv.ParseFloat(value, 64)
+ if err != nil {
+ return nil, false
+ }
+
+ // Convert Tailwind spacing scale to rem (scale * 0.25rem)
+ remValue := val * 0.25
+ return &WidthSpec{
+ Type: WidthRem,
+ Value: remValue,
+ Unit: "rem",
+ IsMin: prefix == "min-w",
+ IsMax: prefix == "max-w",
+ Original: class,
+ }, true
+ }
+
+ return nil, false
+}
+
+// ParseWidthFromStyle extracts the last width specification from a style string
+func (wp *WidthParser) ParseWidthFromStyle(style string) *WidthSpec {
+ classes := strings.Fields(style)
+ var lastWidth *WidthSpec
+
+ // Parse all width classes, last one wins
+ for _, class := range classes {
+ if widthSpec, found := wp.ParseWidth(class); found {
+ lastWidth = widthSpec
+ }
+ }
+
+ return lastWidth
+}
+
+// getWidthTypeFromUnit determines the width type from the unit
+func (wp *WidthParser) getWidthTypeFromUnit(unit string) WidthType {
+ switch unit {
+ case "ch":
+ return WidthCharacter
+ case "px":
+ return WidthPixel
+ case "rem", "em":
+ return WidthRem
+ case "%":
+ return WidthPercentage
+ case "vh", "vw":
+ return WidthPercentage // Treat as viewport percentage
+ default:
+ return WidthFixed
+ }
+}
+
+// Global parser instance for public API
+var DefaultWidthParser = NewWidthParser()
+
+// ParseWidth is the public API for parsing width classes
+func ParseWidth(class string) (*WidthSpec, bool) {
+ return DefaultWidthParser.ParseWidth(class)
+}
+
+// ParseWidthFromStyle is the public API for extracting width from style strings
+func ParseWidthFromStyle(style string) *WidthSpec {
+ return DefaultWidthParser.ParseWidthFromStyle(style)
+}
diff --git a/api/tailwind/width_test.go b/api/tailwind/width_test.go
new file mode 100644
index 00000000..d6c0828f
--- /dev/null
+++ b/api/tailwind/width_test.go
@@ -0,0 +1,201 @@
+package tailwind
+
+import (
+ "testing"
+)
+
+func TestParseWidth(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected *WidthSpec
+ wantErr bool
+ }{
+ // Character-based widths
+ {
+ name: "character width",
+ input: "w-[10ch]",
+ expected: &WidthSpec{
+ Type: WidthCharacter,
+ Value: 10,
+ Unit: "ch",
+ Original: "w-[10ch]",
+ },
+ },
+ {
+ name: "min character width",
+ input: "min-w-[5ch]",
+ expected: &WidthSpec{
+ Type: WidthCharacter,
+ Value: 5,
+ Unit: "ch",
+ IsMin: true,
+ Original: "min-w-[5ch]",
+ },
+ },
+
+ // Percentage widths
+ {
+ name: "percentage width",
+ input: "w-[25%]",
+ expected: &WidthSpec{
+ Type: WidthPercentage,
+ Value: 25,
+ Unit: "%",
+ Original: "w-[25%]",
+ },
+ },
+
+ // Fractional widths
+ {
+ name: "half width",
+ input: "w-1/2",
+ expected: &WidthSpec{
+ Type: WidthFraction,
+ Value: 0.5,
+ Unit: "fraction",
+ Original: "w-1/2",
+ },
+ },
+
+ // Auto width
+ {
+ name: "auto width",
+ input: "w-auto",
+ expected: &WidthSpec{
+ Type: WidthAuto,
+ Value: 0,
+ Unit: "auto",
+ Original: "w-auto",
+ },
+ },
+
+ // Error cases
+ {
+ name: "invalid format",
+ input: "invalid-width",
+ wantErr: true,
+ },
+ {
+ name: "empty string",
+ input: "",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, ok := ParseWidth(tt.input)
+
+ if tt.wantErr {
+ if ok {
+ t.Errorf("ParseWidth() expected no match, got %v", result)
+ }
+ return
+ }
+
+ if !ok {
+ t.Errorf("ParseWidth() expected match, got false")
+ return
+ }
+
+ if result == nil {
+ t.Errorf("ParseWidth() returned nil result")
+ return
+ }
+
+ if result.Type != tt.expected.Type {
+ t.Errorf("ParseWidth() Type = %v, want %v", result.Type, tt.expected.Type)
+ }
+
+ if result.Value != tt.expected.Value {
+ t.Errorf("ParseWidth() Value = %v, want %v", result.Value, tt.expected.Value)
+ }
+
+ if result.Unit != tt.expected.Unit {
+ t.Errorf("ParseWidth() Unit = %v, want %v", result.Unit, tt.expected.Unit)
+ }
+
+ if result.IsMin != tt.expected.IsMin {
+ t.Errorf("ParseWidth() IsMin = %v, want %v", result.IsMin, tt.expected.IsMin)
+ }
+
+ if result.IsMax != tt.expected.IsMax {
+ t.Errorf("ParseWidth() IsMax = %v, want %v", result.IsMax, tt.expected.IsMax)
+ }
+
+ if result.Original != tt.expected.Original {
+ t.Errorf("ParseWidth() Original = %v, want %v", result.Original, tt.expected.Original)
+ }
+ })
+ }
+}
+
+func TestParseWidthFromStyle(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected *WidthSpec
+ }{
+ {
+ name: "width in middle of style",
+ input: "text-center w-[20ch] font-bold",
+ expected: &WidthSpec{
+ Type: WidthCharacter,
+ Value: 20,
+ Unit: "ch",
+ Original: "w-[20ch]",
+ },
+ },
+ {
+ name: "min width at start",
+ input: "min-w-[30%] text-left",
+ expected: &WidthSpec{
+ Type: WidthPercentage,
+ Value: 30,
+ Unit: "%",
+ IsMin: true,
+ Original: "min-w-[30%]",
+ },
+ },
+ {
+ name: "no width specification",
+ input: "text-center font-bold bg-blue-500",
+ expected: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := ParseWidthFromStyle(tt.input)
+
+ if tt.expected == nil {
+ if result != nil {
+ t.Errorf("ParseWidthFromStyle() = %v, want nil", result)
+ }
+ return
+ }
+
+ if result == nil {
+ t.Errorf("ParseWidthFromStyle() = nil, want %v", tt.expected)
+ return
+ }
+
+ if result.Type != tt.expected.Type {
+ t.Errorf("ParseWidthFromStyle() Type = %v, want %v", result.Type, tt.expected.Type)
+ }
+
+ if result.Value != tt.expected.Value {
+ t.Errorf("ParseWidthFromStyle() Value = %v, want %v", result.Value, tt.expected.Value)
+ }
+
+ if result.Unit != tt.expected.Unit {
+ t.Errorf("ParseWidthFromStyle() Unit = %v, want %v", result.Unit, tt.expected.Unit)
+ }
+
+ if result.Original != tt.expected.Original {
+ t.Errorf("ParseWidthFromStyle() Original = %v, want %v", result.Original, tt.expected.Original)
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/api/text.go b/api/text.go
index 53f2042d..70d16b7b 100644
--- a/api/text.go
+++ b/api/text.go
@@ -18,6 +18,15 @@ var (
styleCacheLock sync.RWMutex
)
+// Textable interface defines the standard text rendering methods
+// for any type that can be rendered to multiple output formats
+type Textable interface {
+ String() string // Plain text representation
+ ANSI() string // ANSI colored terminal output
+ HTML() string // HTML formatted output
+ Markdown() string // Markdown formatted output
+}
+
// Text represents styled content that can be rendered to multiple output formats.
// It supports hierarchical structure through Children, CSS-compatible styling,
// and format-specific rendering (ANSI, HTML, Markdown).
@@ -25,10 +34,10 @@ type Text struct {
Content string
Class Class
Style string
- Children []Text
+ Children []Textable
}
-func (t Text) Add(child Text) Text {
+func (t Text) Add(child Textable) Text {
t.Children = append(t.Children, child)
return t
}
@@ -48,6 +57,16 @@ func (t Text) Text(text string, styles ...string) Text {
return t.Add(Text{Content: text, Style: strings.Join(styles, " ")})
}
+// AddText convenience method for adding Text content as a child
+func (t Text) AddText(content string, styles ...string) Text {
+ return t.Add(Text{Content: content, Style: strings.Join(styles, " ")})
+}
+
+// AddIcon convenience method for adding icons as children
+func (t Text) AddIcon(icon Textable, styles ...string) Text {
+ return t.Add(icon)
+}
+
func (t Text) Styles(classes ...string) Text {
if t.Style != "" {
// Append new classes to existing style
@@ -81,7 +100,11 @@ func (t Text) Indent(spaces int) Text {
indentation := strings.Repeat(" ", spaces)
t.Content = indentation + strings.ReplaceAll(t.Content, "\n", "\n"+indentation)
for i := range t.Children {
- t.Children[i] = t.Children[i].Indent(spaces + 2)
+ // Only indent if the child is a Text type that supports Indent
+ if textChild, ok := t.Children[i].(Text); ok {
+ t.Children[i] = textChild.Indent(spaces + 2)
+ }
+ // Icons and other Textable types don't need indentation
}
return t
}
@@ -110,8 +133,16 @@ func (t Text) IsEmpty() bool {
return false
}
for _, child := range t.Children {
- if !child.IsEmpty() {
- return false
+ // Check if child is Text type and not empty
+ if textChild, ok := child.(Text); ok {
+ if !textChild.IsEmpty() {
+ return false
+ }
+ } else {
+ // For non-Text Textable types (like icons), check if they have content
+ if child.String() != "" {
+ return false
+ }
}
}
return true
@@ -327,6 +358,28 @@ func ResolveStyles(styles ...string) Class {
resolved.Font = &Font{}
}
+ // Apply font family
+ if strings.HasPrefix(class, "font-family-") {
+ fontName := strings.TrimPrefix(class, "font-family-")
+ switch strings.ToLower(fontName) {
+ case "arial":
+ resolved.Font.Name = "Arial"
+ case "times":
+ resolved.Font.Name = "Times"
+ case "helvetica":
+ resolved.Font.Name = "Helvetica"
+ case "courier":
+ resolved.Font.Name = "Courier"
+ case "georgia":
+ resolved.Font.Name = "Georgia"
+ case "verdana":
+ resolved.Font.Name = "Verdana"
+ default:
+ // Allow custom font names (case-sensitive for exact match)
+ resolved.Font.Name = fontName
+ }
+ }
+
// Apply font weight
switch class {
case "bold", "font-bold", "font-semibold", "font-medium":
@@ -375,18 +428,18 @@ func ResolveStyles(styles ...string) Class {
resolved.Padding = &Padding{}
}
- // Apply non-nil values
+ // Apply non-nil values, converting to Point type
if top != nil {
- resolved.Padding.Top = *top
+ resolved.Padding.Top = NewPoint(*top)
}
if right != nil {
- resolved.Padding.Right = *right
+ resolved.Padding.Right = NewPoint(*right)
}
if bottom != nil {
- resolved.Padding.Bottom = *bottom
+ resolved.Padding.Bottom = NewPoint(*bottom)
}
if left != nil {
- resolved.Padding.Left = *left
+ resolved.Padding.Left = NewPoint(*left)
}
}
diff --git a/api/text_test.go b/api/text_test.go
index f9245e00..63a59a85 100644
--- a/api/text_test.go
+++ b/api/text_test.go
@@ -24,7 +24,7 @@ func TestText(t *testing.T) {
},
{
name: "Text with children",
- input: Text{Content: "Hello, ", Children: []Text{{Content: "world!", Style: "font-bold"}}},
+ input: Text{Content: "Hello, ", Children: []Textable{Text{Content: "world!", Style: "font-bold"}}},
plain: "Hello, world!",
markdown: "Hello, **world!**",
html: "Hello, world!",
diff --git a/api/themes.go b/api/themes.go
index 0358d405..07dcfd37 100644
--- a/api/themes.go
+++ b/api/themes.go
@@ -61,12 +61,34 @@ type Circle struct {
Diameter float64
}
-// Padding defines spacing around content in CSS box model format.
+// Padding defines spacing around content in CSS box model format using Point units.
type Padding struct {
- Top float64
- Right float64
- Bottom float64
- Left float64
+ Top Point
+ Right Point
+ Bottom Point
+ Left Point
+}
+
+// Helper methods for conversion to MM (for layout calculations)
+
+// TopMM returns the top padding converted to millimeters
+func (p *Padding) TopMM() float64 {
+ return p.Top.ToMM()
+}
+
+// RightMM returns the right padding converted to millimeters
+func (p *Padding) RightMM() float64 {
+ return p.Right.ToMM()
+}
+
+// BottomMM returns the bottom padding converted to millimeters
+func (p *Padding) BottomMM() float64 {
+ return p.Bottom.ToMM()
+}
+
+// LeftMM returns the left padding converted to millimeters
+func (p *Padding) LeftMM() float64 {
+ return p.Left.ToMM()
}
// Box represents a styled rectangular container with fill, borders, and padding.
diff --git a/api/types.go b/api/types.go
index fefc6bdf..0d6ae66c 100644
--- a/api/types.go
+++ b/api/types.go
@@ -21,6 +21,13 @@ type Pretty interface {
Pretty() Text
}
+// PrettyRow enables structs to provide custom table row representation with fine-grained control
+// over columns and cell formatting based on output format options.
+// The opts parameter should be of type formatters.FormatOptions.
+type PrettyRow interface {
+ PrettyRow(opts interface{}) map[string]Text
+}
+
// RenderFunc provides custom rendering logic for field values.
// It receives the raw value, field configuration, and current theme,
// allowing complete control over how a field is displayed.
diff --git a/api/units.go b/api/units.go
new file mode 100644
index 00000000..f3adde7a
--- /dev/null
+++ b/api/units.go
@@ -0,0 +1,34 @@
+package api
+
+import "fmt"
+
+// Unit conversion constants
+const (
+ // 1 point = 25.4/72 mm (exact: 1/72 inch = 25.4mm/72)
+ PointsToMM = 25.4 / 72.0
+ // 1 mm = 72/25.4 points (exact reciprocal)
+ MMToPoints = 72.0 / 25.4
+)
+
+// Point represents a measurement in typographic points
+type Point float64
+
+// ToMM converts points to millimeters
+func (p Point) ToMM() float64 {
+ return float64(p) * PointsToMM
+}
+
+// Float64 returns the point value as a float64
+func (p Point) Float64() float64 {
+ return float64(p)
+}
+
+// String returns a formatted string representation
+func (p Point) String() string {
+ return fmt.Sprintf("%.2fpt", float64(p))
+}
+
+// NewPoint creates a new Point value
+func NewPoint(value float64) Point {
+ return Point(value)
+}
\ No newline at end of file
diff --git a/args_parser.go b/args_parser.go
index 773d8451..059f264b 100644
--- a/args_parser.go
+++ b/args_parser.go
@@ -162,7 +162,6 @@ func parseArgumentEnhanced(rawArg string) (string, any, string, error) {
return "", nil, "", fmt.Errorf("invalid argument format, expected key=value, key:=json, key@file, Header:value, or key:=@file")
}
-
// Helper functions
// readFileAsString reads a file and returns its content as a string
@@ -191,7 +190,6 @@ func readFileAsStringOrBase64(filepath string) (string, error) {
return base64.StdEncoding.EncodeToString(content), nil
}
-
// isQueryParameter checks if an argument is a query parameter (key==value) and not escaped
func isQueryParameter(arg string) bool {
return strings.Contains(arg, "==") && !isEscaped(arg, "==")
diff --git a/flags.go b/flags.go
index 192a753f..8206908e 100644
--- a/flags.go
+++ b/flags.go
@@ -75,7 +75,7 @@ func (a *AllFlags) String() string {
func (a *AllFlags) UseFlags() {
logger.Configure(a.Flags)
- logger.Debugf("Using logger flags: %s", a)
+ logger.V(4).Infof("Using logger flags: %s", a)
a.Apply()
UseFormatter(a.FormatOptions)
}
diff --git a/formatters/excel.go b/formatters/excel.go
index 6813b0a8..ca48df77 100644
--- a/formatters/excel.go
+++ b/formatters/excel.go
@@ -124,15 +124,15 @@ func (f *ExcelFormatter) FormatPrettyDataToFile(data *api.PrettyData, filename s
// Add table title if we had regular fields above
if len(regularFields) > 0 {
if err := file.SetCellValue(sheetName, fmt.Sprintf("A%d", currentRow), tableField.Name); err != nil {
- return fmt.Errorf("failed to set table title: %w", err)
- }
+ return fmt.Errorf("failed to set table title: %w", err)
+ }
titleStyle, err := f.createTitleStyle(file)
if err != nil {
return fmt.Errorf("failed to create title style: %w", err)
}
if err := file.SetCellStyle(sheetName, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("A%d", currentRow), titleStyle); err != nil {
- return fmt.Errorf("failed to set title style: %w", err)
- }
+ return fmt.Errorf("failed to set title style: %w", err)
+ }
currentRow++
}
@@ -154,8 +154,8 @@ func (f *ExcelFormatter) FormatPrettyDataToFile(data *api.PrettyData, filename s
for i, header := range headers {
cellRef := f.getCellReference(i+1, currentRow)
if err := file.SetCellValue(sheetName, cellRef, header); err != nil {
- return fmt.Errorf("failed to set header value: %w", err)
- }
+ return fmt.Errorf("failed to set header value: %w", err)
+ }
}
// Apply header styling
@@ -168,8 +168,8 @@ func (f *ExcelFormatter) FormatPrettyDataToFile(data *api.PrettyData, filename s
startCell := f.getCellReference(1, currentRow)
endCell := f.getCellReference(len(headers), currentRow)
if err := file.SetCellStyle(sheetName, startCell, endCell, headerStyle); err != nil {
- return fmt.Errorf("failed to set header style: %w", err)
- }
+ return fmt.Errorf("failed to set header style: %w", err)
+ }
}
currentRow++
@@ -180,8 +180,8 @@ func (f *ExcelFormatter) FormatPrettyDataToFile(data *api.PrettyData, filename s
if fieldValue, exists := row[fieldName]; exists {
// Use Plain() to get the formatted text representation
if err := file.SetCellValue(sheetName, cellRef, fieldValue.Plain()); err != nil {
- return fmt.Errorf("failed to set cell value: %w", err)
- }
+ return fmt.Errorf("failed to set cell value: %w", err)
+ }
}
}
currentRow++
@@ -192,8 +192,8 @@ func (f *ExcelFormatter) FormatPrettyDataToFile(data *api.PrettyData, filename s
startCol := f.getColumnName(1)
endCol := f.getColumnName(len(headers))
if err := file.SetColWidth(sheetName, startCol, endCol, 15); err != nil {
- return fmt.Errorf("failed to set column width: %w", err)
- }
+ return fmt.Errorf("failed to set column width: %w", err)
+ }
}
}
}
diff --git a/formatters/html_formatter.go b/formatters/html_formatter.go
index 6d8b9084..62c447cb 100644
--- a/formatters/html_formatter.go
+++ b/formatters/html_formatter.go
@@ -26,7 +26,7 @@ func NewHTMLFormatter() *HTMLFormatter {
// ToPrettyData converts various input types to PrettyData
func (f *HTMLFormatter) ToPrettyData(data interface{}) (*api.PrettyData, error) {
- return ToPrettyData(data)
+ return ToPrettyDataWithOptions(data, FormatOptions{Format: "html"})
}
// getCSS returns Tailwind CSS CDN and custom styling
diff --git a/formatters/manager.go b/formatters/manager.go
index 6d4a5889..2c0ea618 100644
--- a/formatters/manager.go
+++ b/formatters/manager.go
@@ -40,9 +40,9 @@ func (f FormatManager) ToPrettyData(data interface{}) (*api.PrettyData, error) {
return ToPrettyData(data)
}
-// ToPrettyDataWithFormatHint converts data to PrettyData with a format hint for slices
-func (f FormatManager) ToPrettyDataWithFormatHint(data interface{}, formatHint string) (*api.PrettyData, error) {
- return ToPrettyDataWithFormatHint(data, formatHint)
+// ToPrettyDataWithOptions converts data to PrettyData using format options
+func (f FormatManager) ToPrettyDataWithOptions(data interface{}, opts FormatOptions) (*api.PrettyData, error) {
+ return ToPrettyDataWithOptions(data, opts)
}
// Pretty implements api.FormatManager.
@@ -172,7 +172,7 @@ func (f FormatManager) FormatWithOptions(options FormatOptions, data interface{}
}
f.markdownFormatter.NoColor = options.NoColor
// Convert to PrettyData first to handle pretty tags like tree
- prettyData, err := f.ToPrettyData(data)
+ prettyData, err := f.ToPrettyDataWithOptions(data, options)
if err != nil {
// Fallback to direct formatting if PrettyData conversion fails
return f.markdownFormatter.Format(data)
@@ -193,8 +193,8 @@ func (f FormatManager) FormatWithOptions(options FormatOptions, data interface{}
f.prettyFormatter = NewPrettyFormatter()
}
f.prettyFormatter.NoColor = options.NoColor
- // Force table formatting by setting format hint
- prettyData, err := f.ToPrettyDataWithFormatHint(data, "table")
+ // Force table formatting by setting format option
+ prettyData, err := ToPrettyDataWithOptions(data, FormatOptions{Format: "table"})
if err != nil {
// Fallback to direct formatting if PrettyData conversion fails
return f.prettyFormatter.Format(data)
@@ -219,7 +219,7 @@ func (f FormatManager) FormatWithOptions(options FormatOptions, data interface{}
}
f.prettyFormatter.NoColor = options.NoColor
// Convert to PrettyData first to handle pretty tags, default slices to table
- prettyData, err := f.ToPrettyDataWithFormatHint(data, "table")
+ prettyData, err := ToPrettyDataWithOptions(data, FormatOptions{Format: "table"})
if err != nil {
// Fallback to direct formatting if PrettyData conversion fails
return f.prettyFormatter.Format(data)
diff --git a/formatters/options.go b/formatters/options.go
index 6c24271d..74be4376 100644
--- a/formatters/options.go
+++ b/formatters/options.go
@@ -123,7 +123,7 @@ func BindPFlags(flags *pflag.FlagSet, options *FormatOptions) {
// ResolveFormat resolves the output format from format-specific flags
func (options *FormatOptions) ResolveFormat() string {
- logger.Debugf("%+v", *options)
+ logger.V(4).Infof("%+v", *options)
// Count how many format flags are set
selectedFormat := []string{}
@@ -152,7 +152,5 @@ func (options *FormatOptions) ResolveFormat() string {
options.Format = "pretty" // Default format
}
- logger.Tracef("Using format: %s", options.Format)
-
return options.Format
}
diff --git a/formatters/parser.go b/formatters/parser.go
index d20dff42..53bbc8de 100644
--- a/formatters/parser.go
+++ b/formatters/parser.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/flanksource/clicky/api"
+ "github.com/flanksource/commons/logger"
)
// NewStructParser creates a new struct parser
@@ -230,8 +231,8 @@ func processFieldValue(fieldVal reflect.Value) interface{} {
return parser.ProcessFieldValue(fieldVal)
}
-// ToPrettyDataWithFormatHint converts various input types to PrettyData with a format hint for slices
-func ToPrettyDataWithFormatHint(data interface{}, formatHint string) (*api.PrettyData, error) {
+// ToPrettyDataWithOptions converts various input types to PrettyData using format options
+func ToPrettyDataWithOptions(data interface{}, opts FormatOptions) (*api.PrettyData, error) {
// Handle nil data at root level
if data == nil {
return &api.PrettyData{
@@ -242,45 +243,216 @@ func ToPrettyDataWithFormatHint(data interface{}, formatHint string) (*api.Prett
}, nil
}
- // Check if already PrettyData
- if pd, ok := data.(*api.PrettyData); ok {
- return pd, nil
+ // Check if data implements Pretty interface first
+ if pretty, ok := data.(api.Pretty); ok {
+ // For Pretty objects, create a simple field value
+ text := pretty.Pretty()
+ return &api.PrettyData{
+ Schema: &api.PrettyObject{Fields: []api.PrettyField{{Name: "content", Type: "string"}}},
+ Values: map[string]api.FieldValue{
+ "content": {
+ Value: text.Content,
+ Text: &text,
+ Field: api.PrettyField{Name: "content", Type: "string"},
+ },
+ },
+ Tables: make(map[string][]api.PrettyDataRow),
+ Original: data,
+ }, nil
}
+ // Get reflect value
val := reflect.ValueOf(data)
- // Handle nil pointer at root level
- if val.Kind() == reflect.Ptr && val.IsNil() {
+ // Check if it's a slice/array
+ if val.Kind() == reflect.Slice || val.Kind() == reflect.Array {
+ return parseSliceDataWithOptions(val, opts)
+ }
+
+ // For single objects (struct or map)
+ return parseStructDataWithOptions(val, opts)
+}
+
+// parseSliceDataWithOptions handles slice/array data with format options
+func parseSliceDataWithOptions(val reflect.Value, opts FormatOptions) (*api.PrettyData, error) {
+ // Safely dereference root level pointer
+ val, _ = safeDerefPointer(val)
+
+ // Handle slices/arrays - default to table format unless items have tree structure
+ if val.Kind() == reflect.Slice || val.Kind() == reflect.Array {
+ if hasTreeStructure(val) {
+ return convertSliceToTreeData(val)
+ }
+ return convertSliceToPrettyDataWithOptions(val, opts)
+ }
+
+ // Not a slice, delegate to struct parser
+ return parseStructDataWithOptions(val, opts)
+}
+
+// parseStructDataWithOptions handles struct/map data with format options
+func parseStructDataWithOptions(val reflect.Value, opts FormatOptions) (*api.PrettyData, error) {
+ // Safely dereference root level pointer
+ val, _ = safeDerefPointer(val)
+
+ // Check dereferenced value for Pretty interface
+ if val.CanInterface() {
+ if pretty, ok := val.Interface().(api.Pretty); ok {
+ // For Pretty objects, create a simple field value
+ text := pretty.Pretty()
+ return &api.PrettyData{
+ Schema: &api.PrettyObject{
+ Fields: []api.PrettyField{{
+ Name: "content",
+ Format: "pretty",
+ Label: "Content",
+ }},
+ },
+ Values: map[string]api.FieldValue{
+ "content": {
+ Value: val.Interface(), // Store the dereferenced Pretty object
+ Text: &text, // Store the pretty text
+ Field: api.PrettyField{
+ Name: "content",
+ Format: "pretty",
+ Label: "Content",
+ },
+ },
+ },
+ Tables: make(map[string][]api.PrettyDataRow),
+ Original: val.Interface(),
+ }, nil
+ }
+ }
+
+ // Create the schema from struct tags
+ schema, err := ParseStructSchema(val)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse struct schema: %w", err)
+ }
+
+ // Parse the struct data with options
+ return parseStructDataWithOptionsAndSchema(val, schema, opts)
+}
+
+// convertSliceToPrettyDataWithOptions converts a slice to PrettyData using format options
+func convertSliceToPrettyDataWithOptions(val reflect.Value, opts FormatOptions) (*api.PrettyData, error) {
+ // Store the original interface value
+ originalData := val.Interface()
+
+ if val.Len() == 0 {
+ // Empty slice - return empty PrettyData
return &api.PrettyData{
Schema: &api.PrettyObject{Fields: []api.PrettyField{}},
Values: make(map[string]api.FieldValue),
Tables: make(map[string][]api.PrettyDataRow),
- Original: data,
+ Original: originalData,
}, nil
}
- // Safely dereference root level pointer
- val, _ = safeDerefPointer(val)
+ // Get the first element to check the type
+ firstElem := val.Index(0)
+ firstElem, _ = safeDerefPointer(firstElem)
- // Handle slices/arrays - force format hint
- if val.Kind() == reflect.Slice || val.Kind() == reflect.Array {
- switch formatHint {
- case "table":
- result, err := convertSliceToPrettyData(val)
- return result, err
- case "tree":
- // Check if items have tree structure, otherwise convert to table
- if hasTreeStructure(val) {
- return convertSliceToTreeData(val)
+ // We only handle slices of structs
+ if firstElem.Kind() != reflect.Struct {
+ return nil, fmt.Errorf("can only convert slice of structs to PrettyData, got slice of %s", firstElem.Kind())
+ }
+
+ // Convert all elements to rows using options-aware method
+ var rows []api.PrettyDataRow
+ var tableFields []api.PrettyField
+ parser := api.NewStructParser()
+
+ // Statistics for PrettyRow usage
+ prettyRowCount := 0
+ reflectionCount := 0
+
+ for i := 0; i < val.Len(); i++ {
+ elem := val.Index(i)
+ elem, isNil := safeDerefPointer(elem)
+ if isNil {
+ continue // Skip nil elements
+ }
+
+ // Check if this element implements PrettyRow for statistics
+ if elem.CanInterface() {
+ if _, ok := elem.Interface().(api.PrettyRow); ok {
+ prettyRowCount++
} else {
- return convertSliceToPrettyData(val)
+ reflectionCount++
}
+ } else {
+ reflectionCount++
}
- return convertSliceToPrettyData(val)
+
+ // Use StructToRowWithOptions to check for PrettyRow interface
+ row, err := parser.StructToRowWithOptions(elem, opts)
+ if err != nil {
+ logger.V(4).Infof("Failed to convert element at index %d: %v", i, err)
+ continue // Skip elements that can't be converted
+ }
+
+ // Extract table schema from the first successful row
+ // This ensures schema matches PrettyRow columns
+ if i == 0 && len(tableFields) == 0 {
+ for columnName := range row {
+ tableFields = append(tableFields, api.PrettyField{
+ Name: columnName,
+ Label: columnName,
+ Type: "string", // Default type, could be enhanced
+ })
+ }
+ }
+
+ rows = append(rows, row)
}
- // For non-slices, delegate to the regular function
- return ToPrettyData(data)
+ // Fallback to struct reflection if no rows were generated
+ if len(rows) == 0 || len(tableFields) == 0 {
+ var err error
+ tableFields, err = GetTableFields(firstElem)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get table fields: %w", err)
+ }
+ }
+
+ // Sort rows based on sort tags in the struct
+ if len(rows) > 0 {
+ sortFields := ExtractSortFields(firstElem.Type())
+ if len(sortFields) > 0 {
+ SortRows(rows, sortFields)
+ }
+ }
+
+ return &api.PrettyData{
+ Schema: &api.PrettyObject{
+ Fields: []api.PrettyField{
+ {
+ Name: "table",
+ Format: api.FormatTable,
+ TableOptions: api.PrettyTable{Fields: tableFields},
+ },
+ },
+ },
+ Values: make(map[string]api.FieldValue),
+ Tables: map[string][]api.PrettyDataRow{
+ "table": rows,
+ },
+ Original: originalData,
+ }, nil
+}
+
+// parseStructDataWithOptionsAndSchema parses struct data with schema and options
+func parseStructDataWithOptionsAndSchema(val reflect.Value, schema *api.PrettyObject, opts FormatOptions) (*api.PrettyData, error) {
+ // This would be similar to existing struct parsing but with options
+ // For now, delegate to existing parsing since most struct parsing doesn't need special option handling
+ parser := api.NewStructParser()
+ prettyData, err := parser.ParseDataWithSchema(val.Interface(), schema)
+ if err != nil {
+ return nil, err
+ }
+ return prettyData, nil
}
// ToPrettyData converts various input types to PrettyData
diff --git a/formatters/pdf/fpdf_interface.go b/formatters/pdf/fpdf_interface.go
new file mode 100644
index 00000000..bdfc2edd
--- /dev/null
+++ b/formatters/pdf/fpdf_interface.go
@@ -0,0 +1,43 @@
+package pdf
+
+// FpdfInterface defines the interface we need for table rendering
+// This is our own copy focused on the methods we actually use
+//
+// Unit Conventions:
+// - Font sizes: Points (pt) - standard typography unit
+// - Layout dimensions: Millimeters (mm) - PDF page layout unit
+// - Margins and positioning: Millimeters (mm)
+type FpdfInterface interface {
+ // Font and text methods
+ SetFont(familyStr, styleStr string, size float64) // size in points
+ SetTextColor(r, g, b int)
+ GetFontSize() (ptSize, unitSize float64) // ptSize in points, unitSize in current unit (typically mm)
+ GetStringWidth(s string) float64 // returns width in current unit (typically mm)
+ Text(x, y float64, txtStr string) // x, y in current unit (typically mm)
+ CellFormat(w, h float64, txtStr, borderStr string, ln int, alignStr string, fill bool, link int, linkStr string) // w, h in current unit (typically mm)
+
+ // Drawing methods
+ SetDrawColor(r, g, b int)
+ SetFillColor(r, g, b int)
+ Line(x1, y1, x2, y2 float64)
+ Rect(x, y, w, h float64, styleStr string)
+ SetDashPattern(dashArray []float64, dashPhase float64)
+
+ // Position and margin methods
+ GetMargins() (left, top, right, bottom float64) // returns margins in current unit (typically mm)
+ GetPageSize() (width, height float64) // returns page size in current unit (typically mm)
+ SetXY(x, y float64) // x, y in current unit (typically mm)
+ GetX() float64 // returns X position in current unit (typically mm)
+ GetY() float64 // returns Y position in current unit (typically mm)
+
+ // Basic PDF methods we might need
+ AddPage()
+ SetAutoPageBreak(auto bool, margin float64) // margin in current unit (typically mm)
+}
+
+// WrapFpdf wraps any gofpdf-compatible interface into our FpdfInterface
+func WrapFpdf(fpdf interface{}) FpdfInterface {
+ // Type assert to our interface - this will panic if the interface doesn't match
+ // but that's acceptable since it indicates a programming error
+ return fpdf.(FpdfInterface)
+}
diff --git a/formatters/pdf/style.go b/formatters/pdf/style.go
index bdc2fb06..c6f757d7 100644
--- a/formatters/pdf/style.go
+++ b/formatters/pdf/style.go
@@ -31,7 +31,10 @@ func (s *StyleConverter) ConvertToTextProps(class api.Class) *props.Text {
if class.Font != nil {
// Font size
if class.Font.Size > 0 {
- textProps.Size = class.Font.Size * 12 // Convert rem to points (assuming 1rem = 12pt)
+ // Convert rem to font size (points)
+ remSize := NewRem(class.Font.Size)
+ fontSize := remSize.ToFontSize()
+ textProps.Size = fontSize.Float64()
}
// Font style
@@ -84,19 +87,24 @@ func (s *StyleConverter) ConvertBackgroundColor(color api.Color) *props.Color {
// CalculateTextHeight calculates appropriate row height for text based on font size
func (s *StyleConverter) CalculateTextHeight(class api.Class) float64 {
- baseHeight := 6.0 // Default row height in mm
+ baseHeightMM := NewMM(6.0) // Default row height in mm
if class.Font != nil && class.Font.Size > 0 {
- // Scale height based on font size
- baseHeight = class.Font.Size * 6 // Convert rem to mm (approximately)
+ // Convert rem font size to mm
+ remSize := NewRem(class.Font.Size)
+ fontHeightMM := remSize.ToMM()
+ // Use font height with some line spacing
+ baseHeightMM = fontHeightMM.Multiply(1.4) // 40% line spacing
}
// Add padding if specified
if class.Padding != nil {
- baseHeight += (class.Padding.Top + class.Padding.Bottom) * 4 // Convert rem to mm
+ paddingTop := NewRem(class.Padding.Top).ToMM()
+ paddingBottom := NewRem(class.Padding.Bottom).ToMM()
+ baseHeightMM = baseHeightMM.Add(paddingTop).Add(paddingBottom)
}
- return baseHeight
+ return baseHeightMM.Float64()
}
// CalculatePadding calculates padding for a cell
@@ -105,13 +113,13 @@ func (s *StyleConverter) CalculatePadding(padding *api.Padding) (left, top, righ
return 0, 0, 0, 0
}
- // Convert rem to mm (1rem ≈ 4mm for PDF)
- left = padding.Left * 4
- top = padding.Top * 4
- right = padding.Right * 4
- bottom = padding.Bottom * 4
+ // Convert rem to mm using proper unit conversion
+ leftMM := NewRem(padding.Left).ToMM()
+ topMM := NewRem(padding.Top).ToMM()
+ rightMM := NewRem(padding.Right).ToMM()
+ bottomMM := NewRem(padding.Bottom).ToMM()
- return
+ return leftMM.Float64(), topMM.Float64(), rightMM.Float64(), bottomMM.Float64()
}
// ParseTailwindToProps parses Tailwind classes and returns Maroto text properties
diff --git a/formatters/pdf/table.go b/formatters/pdf/table.go
new file mode 100644
index 00000000..fd308a48
--- /dev/null
+++ b/formatters/pdf/table.go
@@ -0,0 +1,927 @@
+package pdf
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "math"
+ "strings"
+
+ "github.com/flanksource/maroto/v2/pkg/components/col"
+ "github.com/flanksource/maroto/v2/pkg/components/line"
+ "github.com/flanksource/maroto/v2/pkg/components/row"
+ "github.com/flanksource/maroto/v2/pkg/components/text"
+ "github.com/flanksource/maroto/v2/pkg/consts/align"
+ "github.com/flanksource/maroto/v2/pkg/consts/fontstyle"
+ "github.com/flanksource/maroto/v2/pkg/core"
+ "github.com/flanksource/maroto/v2/pkg/core/entity"
+ "github.com/flanksource/maroto/v2/pkg/fpdf"
+ "github.com/flanksource/maroto/v2/pkg/props"
+ "github.com/johnfercher/go-tree/node"
+
+ "github.com/flanksource/clicky/api"
+ "github.com/flanksource/clicky/api/tailwind"
+)
+
+// Component provides a base struct for components that need direct FPDF access
+type Component struct {
+ Fpdf FpdfInterface
+ RenderFunc func(*entity.Cell) // Component-specific render function
+}
+
+// Render implements core.Component interface
+func (c *Component) Render(provider core.Provider, cell *entity.Cell) {
+ // Initialize FPDF interface once
+ c.initializeFpdf(provider)
+
+ // Call component-specific render function
+ if c.RenderFunc != nil {
+ c.RenderFunc(cell)
+ }
+}
+
+// initializeFpdf initializes the FPDF interface from the provider
+func (c *Component) initializeFpdf(provider core.Provider) {
+ if c.Fpdf != nil {
+ return // Already initialized
+ }
+
+ drawingHelper := fpdf.NewDrawingHelper(provider)
+ fpdfInterface := drawingHelper.GetFpdf()
+ c.Fpdf = WrapFpdf(fpdfInterface)
+}
+
+// SetConfig implements core.Node interface
+func (c *Component) SetConfig(config *entity.Config) {
+ // Base components don't need config handling
+}
+
+// GetStructure implements core.Node interface
+func (c *Component) GetStructure() *node.Node[core.Structure] {
+ // Base components don't need structure handling
+ return nil
+}
+
+// Column represents a table column with comprehensive Tailwind styling
+type Column struct {
+ Label string `json:"label"` // Column header text
+ Style string `json:"style,omitempty"` // All-in-one Tailwind: "w-[10ch] text-center align-middle font-bold"
+ DataKey string `json:"data_key,omitempty"` // Key for data extraction
+
+ // Private resolved fields (internal use only)
+ resolvedStyle api.Class `json:"-"`
+ resolvedWidth *tailwind.WidthSpec `json:"-"`
+ resolvedAlign tailwind.ParsedAlignment `json:"-"`
+}
+
+// BaseTable contains the shared table implementation using public Tailwind utilities
+type BaseTable struct {
+ Columns []Column `json:"columns"` // Column definitions
+ Rows [][]any `json:"rows,omitempty"` // Data rows
+
+ // Tailwind-only styling
+ HeaderStyle string `json:"header_style,omitempty"` // "font-bold bg-blue-200 text-center align-middle"
+ RowStyle string `json:"row_style,omitempty"` // "text-gray-800 align-middle"
+ AlternateRowStyle string `json:"alternate_row_style,omitempty"` // "bg-gray-50" (merged with RowStyle)
+
+ // Table options
+ ShowBorders bool `json:"show_borders,omitempty"`
+ CompactMode bool `json:"compact_mode,omitempty"`
+
+ // Private resolved styles (internal)
+ resolvedHeaderStyle api.Class `json:"-"`
+ resolvedRowStyle api.Class `json:"-"`
+ resolvedAlternateStyle api.Class `json:"-"` // RowStyle + AlternateRowStyle merged
+ resolvedColumns []Column `json:"-"` // Columns with resolved private fields
+ calculatedWidths []float64 `json:"-"` // Calculated column widths in points/pixels
+}
+
+// FontMetrics provides font measurement capabilities for width calculations
+type FontMetrics struct {
+ CharWidth float64 // Average character width in points
+ RemSize float64 // 1rem in points (typically 16px = 12pt)
+}
+
+// DefaultFontMetrics provides reasonable defaults for PDF font metrics
+var DefaultFontMetrics = FontMetrics{
+ CharWidth: 5.5, // Average character width for typical PDF fonts
+ RemSize: 12.0, // 1rem = 12pt for PDF
+}
+
+// resolveStyles resolves all Tailwind styles and caches the results
+func (bt *BaseTable) resolveStyles() {
+ // Resolve header style
+ if bt.HeaderStyle != "" {
+ bt.resolvedHeaderStyle = api.ResolveStyles(bt.HeaderStyle)
+ }
+
+ // Resolve row styles
+ if bt.RowStyle != "" {
+ bt.resolvedRowStyle = api.ResolveStyles(bt.RowStyle)
+ }
+
+ // Merge alternate row style with base row style
+ if bt.AlternateRowStyle != "" {
+ alternateStyle := tailwind.MergeStyles(bt.RowStyle, bt.AlternateRowStyle)
+ bt.resolvedAlternateStyle = api.ResolveStyles(alternateStyle)
+ } else {
+ bt.resolvedAlternateStyle = bt.resolvedRowStyle
+ }
+
+ // Resolve column styles
+ bt.resolvedColumns = make([]Column, len(bt.Columns))
+ for i, column := range bt.Columns {
+ bt.resolvedColumns[i] = column
+ if column.Style != "" {
+ // Resolve main style using existing api
+ bt.resolvedColumns[i].resolvedStyle = api.ResolveStyles(column.Style)
+
+ // Extract layout information using our new utilities
+ colWidth, colAlign := tailwind.ResolveLayoutFromStyle(column.Style)
+ bt.resolvedColumns[i].resolvedWidth = colWidth
+ bt.resolvedColumns[i].resolvedAlign = colAlign
+ }
+ }
+}
+
+// calculateColumnWidths calculates actual column widths based on available space
+func (bt *BaseTable) calculateColumnWidths(availableWidth float64) []float64 {
+ return bt.calculateColumnWidthsWithMargins(availableWidth, 0, 0, false, false)
+}
+
+// calculateColumnWidthsWithMargins calculates column widths considering page margins
+func (bt *BaseTable) calculateColumnWidthsWithMargins(availableWidth, leftMargin, rightMargin float64, useMargins, debug bool) []float64 {
+ if len(bt.resolvedColumns) == 0 {
+ return []float64{}
+ }
+
+ // Adjust available width for margins if requested
+ adjustedWidth := availableWidth
+ if useMargins {
+ adjustedWidth = availableWidth - leftMargin - rightMargin
+ if adjustedWidth < 0 {
+ adjustedWidth = availableWidth * 0.1 // Fallback to 10% if margins are too large
+ }
+
+ // Add debug logging if requested
+ if debug {
+ log.Printf("DEBUG: TableComponent.calculateColumnWidthsWithMargins: available width adjusted from %.2f to %.2f (margins L=%.2f R=%.2f)",
+ availableWidth, adjustedWidth, leftMargin, rightMargin)
+ }
+ }
+
+ numColumns := len(bt.resolvedColumns)
+ widths := make([]float64, numColumns)
+ metrics := DefaultFontMetrics
+
+ // Phase 1: Calculate base widths for columns with explicit specifications
+ totalExplicitWidth := 0.0
+ autoColumns := []int{}
+
+ for i, column := range bt.resolvedColumns {
+ if column.resolvedWidth != nil {
+ width := bt.calculateActualWidth(column.resolvedWidth, adjustedWidth, metrics)
+ widths[i] = width
+ totalExplicitWidth += width
+ } else {
+ autoColumns = append(autoColumns, i)
+ }
+ }
+
+ // Phase 2: Distribute remaining width among auto columns
+ remainingWidth := adjustedWidth - totalExplicitWidth
+ if len(autoColumns) > 0 && remainingWidth > 0 {
+ autoWidth := remainingWidth / float64(len(autoColumns))
+ for _, i := range autoColumns {
+ widths[i] = autoWidth
+ }
+ } else if len(autoColumns) > 0 {
+ // Fallback: equal distribution of total width
+ equalWidth := adjustedWidth / float64(numColumns)
+ for _, i := range autoColumns {
+ widths[i] = equalWidth
+ }
+ }
+
+ // Phase 3: Handle overflow by proportionally scaling down
+ totalWidth := 0.0
+ for _, w := range widths {
+ totalWidth += w
+ }
+
+ if totalWidth > adjustedWidth {
+ scaleFactor := adjustedWidth / totalWidth
+ for i := range widths {
+ widths[i] *= scaleFactor
+ }
+ }
+
+ bt.calculatedWidths = widths
+ return widths
+}
+
+// calculateActualWidth converts a WidthSpec to actual width in points
+func (bt *BaseTable) calculateActualWidth(spec *tailwind.WidthSpec, availableWidth float64, metrics FontMetrics) float64 {
+ var baseWidth float64
+
+ switch spec.Type {
+ case tailwind.WidthAuto:
+ // Estimate based on content (simplified - could be enhanced)
+ baseWidth = metrics.CharWidth * 15 // Default to ~15 characters
+
+ case tailwind.WidthPercentage:
+ baseWidth = availableWidth * (spec.Value / 100)
+
+ case tailwind.WidthCharacter:
+ baseWidth = metrics.CharWidth * spec.Value
+
+ case tailwind.WidthPixel:
+ // Convert pixels to points (1px ≈ 0.75pt for PDF)
+ baseWidth = spec.Value * 0.75
+
+ case tailwind.WidthRem:
+ baseWidth = spec.Value * metrics.RemSize
+
+ default:
+ baseWidth = availableWidth / float64(len(bt.Columns)) // Fallback
+ }
+
+ // Apply min/max constraints
+ if spec.IsMin {
+ return math.Max(baseWidth, spec.Value*metrics.CharWidth) // Assume constraint is in character units for simplicity
+ }
+ if spec.IsMax {
+ return math.Min(baseWidth, spec.Value*metrics.CharWidth)
+ }
+
+ return baseWidth
+}
+
+// Table implements the Widget interface for document flow usage
+type Table struct {
+ BaseTable
+}
+
+// Draw implements the Widget interface
+func (t Table) Draw(b *Builder) error {
+ // Resolve styles if not already done
+ t.resolveStyles()
+
+ // Calculate available width (12-column grid system)
+ availableWidth := 12.0 // Maroto grid units
+ columnWidths := t.calculateColumnWidths(availableWidth)
+
+ // Convert float widths to integer grid units
+ gridWidths := make([]int, len(columnWidths))
+ totalGridWidth := 0
+ for i, width := range columnWidths {
+ gridWidths[i] = int(math.Round(width))
+ totalGridWidth += gridWidths[i]
+ }
+
+ // Adjust to ensure total is exactly 12
+ if totalGridWidth != 12 && len(gridWidths) > 0 {
+ diff := 12 - totalGridWidth
+ gridWidths[len(gridWidths)-1] += diff // Adjust last column
+ }
+
+ return t.renderAsWidget(b, gridWidths)
+}
+
+// renderAsWidget renders the table using Maroto's widget system
+func (t Table) renderAsWidget(b *Builder, colWidths []int) error {
+ if len(t.Columns) == 0 && len(t.Rows) == 0 {
+ return nil
+ }
+
+ // Calculate row height
+ baseHeight := 8.0 // Default row height in mm
+ if t.CompactMode {
+ baseHeight = 6.0
+ }
+
+ // Draw top border if enabled
+ if t.ShowBorders {
+ t.drawHorizontalLine(b, 0.5, 200, sumArray(colWidths))
+ }
+
+ // Draw headers if present
+ if len(t.Columns) > 0 {
+ t.drawHeaderRow(b, colWidths, baseHeight)
+
+ // Draw separator line after headers
+ if t.ShowBorders {
+ t.drawHorizontalLine(b, 0.5, 200, sumArray(colWidths))
+ } else {
+ t.drawHorizontalLine(b, 0.3, 230, sumArray(colWidths))
+ }
+ }
+
+ // Draw data rows
+ t.drawDataRows(b, colWidths, baseHeight)
+
+ // Draw bottom border if enabled
+ if t.ShowBorders || len(t.Rows) > 0 {
+ t.drawHorizontalLine(b, 0.5, 200, sumArray(colWidths))
+ }
+
+ // Add spacing after table
+ b.maroto.AddRows(row.New(2))
+
+ return nil
+}
+
+// drawHeaderRow draws the header row using resolved styles
+func (t Table) drawHeaderRow(b *Builder, colWidths []int, baseHeight float64) {
+ // Convert resolved header style to text props
+ headerTextProps := b.style.ConvertToTextProps(t.resolvedHeaderStyle)
+
+ // Create columns for headers
+ cols := make([]core.Col, 0, len(t.Columns))
+ totalColWidth := 0
+
+ for i, column := range t.resolvedColumns {
+ if i >= len(colWidths) {
+ break
+ }
+
+ // Apply alignment from resolved column styles
+ textProps := *headerTextProps
+ textProps.Align = t.convertAlignment(column.resolvedAlign.Horizontal)
+
+ // Create column with header text
+ headerCol := col.New(colWidths[i]).Add(
+ text.New(column.Label, textProps),
+ )
+
+ // Add background if specified in header style
+ if t.resolvedHeaderStyle.Background != nil {
+ bgColor := b.style.ConvertBackgroundColor(*t.resolvedHeaderStyle.Background)
+ headerCol = headerCol.WithStyle(&props.Cell{
+ BackgroundColor: bgColor,
+ })
+ }
+
+ cols = append(cols, headerCol)
+ totalColWidth += colWidths[i]
+ }
+
+ // Add empty column to fill remaining space if needed
+ if totalColWidth < 12 {
+ cols = append(cols, col.New(12-totalColWidth))
+ }
+
+ // Add the header row
+ b.maroto.AddRow(baseHeight, cols...)
+}
+
+// drawDataRows draws all data rows with alternating styles
+func (t Table) drawDataRows(b *Builder, colWidths []int, baseHeight float64) {
+ for rowIndex, dataRow := range t.Rows {
+ // Determine which style to use (row or alternate)
+ var rowStyle api.Class
+ if rowIndex%2 == 1 && t.AlternateRowStyle != "" {
+ rowStyle = t.resolvedAlternateStyle
+ } else {
+ rowStyle = t.resolvedRowStyle
+ }
+
+ rowTextProps := b.style.ConvertToTextProps(rowStyle)
+
+ cols := make([]core.Col, 0, len(dataRow))
+ totalColWidth := 0
+
+ for colIndex := 0; colIndex < len(colWidths) && colIndex < len(dataRow); colIndex++ {
+ cellText := fmt.Sprintf("%v", dataRow[colIndex])
+
+ // Apply column alignment if available
+ textProps := *rowTextProps
+ if colIndex < len(t.resolvedColumns) {
+ textProps.Align = t.convertAlignment(t.resolvedColumns[colIndex].resolvedAlign.Horizontal)
+ }
+
+ cellCol := col.New(colWidths[colIndex]).Add(
+ text.New(cellText, textProps),
+ )
+
+ // Add background if specified in row style
+ if rowStyle.Background != nil {
+ bgColor := b.style.ConvertBackgroundColor(*rowStyle.Background)
+ cellCol = cellCol.WithStyle(&props.Cell{
+ BackgroundColor: bgColor,
+ })
+ }
+
+ cols = append(cols, cellCol)
+ totalColWidth += colWidths[colIndex]
+ }
+
+ // Add empty column to fill remaining space if needed
+ if totalColWidth < 12 {
+ cols = append(cols, col.New(12-totalColWidth))
+ }
+
+ // Add the data row
+ b.maroto.AddRow(baseHeight, cols...)
+
+ // Add row separator if borders are enabled
+ if t.ShowBorders && rowIndex < len(t.Rows)-1 {
+ t.drawHorizontalLine(b, 0.2, 240, sumArray(colWidths))
+ }
+ }
+}
+
+// drawHorizontalLine draws a horizontal line across the table
+func (t Table) drawHorizontalLine(b *Builder, thickness float64, grayLevel, totalColumns int) {
+ b.maroto.AddRow(0.5, col.New(totalColumns).Add(line.New(props.Line{
+ Color: &props.Color{Red: grayLevel, Green: grayLevel, Blue: grayLevel},
+ Thickness: thickness,
+ })))
+}
+
+// convertAlignment converts tailwind alignment to Maroto alignment
+func (t Table) convertAlignment(alignment align.Type) align.Type {
+ return alignment // Direct mapping as they use the same enum
+}
+
+// sumArray sums all values in an integer array
+func sumArray(arr []int) int {
+ sum := 0
+ for _, v := range arr {
+ sum += v
+ }
+ return sum
+}
+
+// TableComponent implements the Component interface for positioned usage
+type TableComponent struct {
+ Component // Embedded base component with FPDF access
+ BaseTable
+ TopAlign bool // Force top alignment within cell
+ styleConverter *StyleConverter // For proper Unicode font handling
+ Debug bool // Enable debug mode for detailed positioning logs
+}
+
+
+// NewTableComponent creates a new table component with default Tailwind styling
+func NewTableComponent(headers []string, rows [][]string) *TableComponent {
+ columns := make([]Column, len(headers))
+
+ // Calculate equal column widths based on number of columns
+ numCols := len(headers)
+ if numCols == 0 {
+ numCols = 1 // Prevent division by zero
+ }
+ equalWidthPercent := 100.0 / float64(numCols)
+
+ for i, header := range headers {
+ columns[i] = Column{
+ Label: header,
+ Style: fmt.Sprintf("w-[%.1f%%] text-sm text-gray-800 text-left align-middle", equalWidthPercent),
+ }
+ }
+
+ // Convert rows to [][]any
+ convertedRows := make([][]any, len(rows))
+ for i, row := range rows {
+ convertedRows[i] = make([]any, len(row))
+ for j, cell := range row {
+ convertedRows[i][j] = cell
+ }
+ }
+
+ tc := &TableComponent{
+ BaseTable: BaseTable{
+ Columns: columns,
+ Rows: convertedRows,
+ HeaderStyle: "font-bold text-white bg-blue-600 text-center text-sm",
+ RowStyle: "text-sm text-gray-800",
+ AlternateRowStyle: "bg-gray-50",
+ ShowBorders: true,
+ },
+ TopAlign: true,
+ styleConverter: NewStyleConverter(), // Initialize StyleConverter for proper Unicode support
+ }
+
+ // Set the component's render function to point to our method
+ tc.Component.RenderFunc = tc.renderComponent
+
+ return tc
+}
+
+// renderComponent is the component-specific render function
+func (tc *TableComponent) renderComponent(cell *entity.Cell) {
+ // Resolve styles if not already done
+ tc.resolveStyles()
+
+ // Fix margin positioning
+ adjustedCell := tc.adjustForMargins(cell)
+
+ // Calculate column widths and render the table
+ columnWidths := tc.calculateColumnWidths(adjustedCell.Width)
+ tc.renderAsComponent(adjustedCell, columnWidths)
+}
+
+// adjustForMargins adjusts cell coordinates for page margins
+func (tc *TableComponent) adjustForMargins(cell *entity.Cell) *entity.Cell {
+ if tc.Fpdf == nil {
+ return cell // No adjustment if FPDF not available
+ }
+
+ leftMargin, topMargin, _, _ := tc.Fpdf.GetMargins()
+
+ adjustedCell := *cell
+ // Investigation: Test if we need to adjust for margins or if Maroto handles this
+
+ if tc.Debug {
+ log.Printf("DEBUG: TableComponent.adjustForMargins: original=(%.2f,%.2f) margins=(%.2f,%.2f)",
+ cell.X, cell.Y, leftMargin, topMargin)
+ }
+
+ return &adjustedCell
+}
+
+
+// renderAsComponent renders the table as a positioned component
+func (tc *TableComponent) renderAsComponent(cell *entity.Cell, colWidths []float64) {
+ if len(tc.Columns) == 0 && len(tc.Rows) == 0 {
+ if tc.Debug {
+ log.Printf("DEBUG: TableComponent.renderAsComponent: empty table, skipping render")
+ }
+ return
+ }
+
+ // Force standard text rendering for TableComponent to avoid empty cells
+ // Advanced drawing mode has incomplete drawCellText implementation
+ useAdvancedDrawing := true // Disabled to ensure text content is visible
+
+ // Calculate dimensions
+ numCols := len(tc.Columns)
+ if numCols == 0 {
+ return
+ }
+
+ rowHeight := tc.getRowHeight()
+
+ // Start positioning from the top of the cell
+ currentY := cell.Y
+
+ if tc.Debug {
+ log.Printf("DEBUG: TableComponent.renderAsComponent: rendering table with %d columns, %d rows", numCols, len(tc.Rows))
+ log.Printf("DEBUG: TableComponent.renderAsComponent: starting Y position=%.2f, row height=%.2f", currentY, rowHeight)
+ totalHeight := float64(len(tc.Rows)+1) * rowHeight // +1 for header
+ log.Printf("DEBUG: TableComponent.renderAsComponent: estimated total height=%.2f", totalHeight)
+ }
+
+ // Render header if present
+ if len(tc.Columns) > 0 {
+ if tc.Debug {
+ log.Printf("DEBUG: TableComponent.renderAsComponent: rendering header at Y=%.2f", currentY)
+ }
+ tc.renderHeaderRowComponent(cell.X, currentY, colWidths, rowHeight, useAdvancedDrawing)
+ currentY += rowHeight
+ }
+
+ // Render data rows
+ for i, row := range tc.Rows {
+ isAltRow := i%2 == 1
+ if tc.Debug {
+ log.Printf("DEBUG: TableComponent.renderAsComponent: rendering row %d at Y=%.2f", i, currentY)
+ }
+ tc.renderDataRowComponent(cell.X, currentY, colWidths, rowHeight, row, isAltRow, useAdvancedDrawing)
+ currentY += rowHeight
+ }
+
+ if tc.Debug {
+ log.Printf("DEBUG: TableComponent.renderAsComponent: final Y position=%.2f", currentY)
+ }
+}
+
+// getRowHeight calculates the height needed for each row using font metrics
+func (tc *TableComponent) getRowHeight() float64 {
+ // Use actual font metrics if FPDF is available
+ if tc.Fpdf != nil {
+ fontSizePoints, _ := tc.Fpdf.GetFontSize()
+ fontSize := NewFontSize(fontSizePoints)
+
+ // Get style padding (convert Tailwind padding to mm)
+ paddingMM := NewMM(tc.extractPaddingFromStyle(tc.resolvedRowStyle))
+
+ // Calculate height: font size + line spacing + padding
+ lineHeightMM := fontSize.LineHeight(1.2) // 20% line spacing
+ baseHeightMM := lineHeightMM.Add(paddingMM.Multiply(2)) // Top + bottom padding
+
+ if tc.CompactMode {
+ baseHeightMM = baseHeightMM.Multiply(0.8) // 20% reduction for compact mode
+ }
+
+ if tc.Debug {
+ log.Printf("DEBUG: TableComponent.getRowHeight: font=%s, calculated=%s (compact=%v)",
+ fontSize.String(), baseHeightMM.String(), tc.CompactMode)
+ }
+
+ return baseHeightMM.Float64()
+ }
+
+ // Fallback to hardcoded values if FPDF not available
+ baseHeight := 8.0 // 8mm default row height
+ if tc.CompactMode {
+ baseHeight = 6.0
+ }
+
+ if tc.Debug {
+ log.Printf("DEBUG: TableComponent.getRowHeight: fallback height=%.2fmm (compact=%v)", baseHeight, tc.CompactMode)
+ }
+
+ return baseHeight
+}
+
+// extractPaddingFromStyle extracts padding from Tailwind style classes
+func (tc *TableComponent) extractPaddingFromStyle(style api.Class) float64 {
+ // Extract padding from Tailwind classes (p-1, p-2, etc.)
+ // For now, return a reasonable default
+ // TODO: Parse actual Tailwind padding values
+ return 2.0 // Default 2mm padding
+}
+
+// renderHeaderRowComponent renders the header row for component interface
+func (tc *TableComponent) renderHeaderRowComponent(x, y float64, colWidths []float64, rowHeight float64, useAdvancedDrawing bool) {
+ if tc.Debug {
+ log.Printf("DEBUG: TableComponent.renderHeaderRowComponent: rendering header row at x=%.2f, y=%.2f, rowHeight=%.2f", x, y, rowHeight)
+ }
+
+ for i, column := range tc.resolvedColumns {
+ if i >= len(colWidths) {
+ break
+ }
+
+ cellX := x + tc.sumWidths(colWidths[:i])
+
+ // Create cell for this header
+ headerCell := &entity.Cell{
+ X: cellX,
+ Y: y,
+ Width: colWidths[i],
+ Height: rowHeight,
+ }
+
+ if tc.Debug {
+ log.Printf("DEBUG: TableComponent.renderHeaderRowComponent: header cell[%d] '%s' at (%.2f,%.2f) size(%.2f×%.2f)",
+ i, column.Label, cellX, y, colWidths[i], rowHeight)
+ }
+
+ if useAdvancedDrawing {
+ // Use advanced drawing with backgrounds and borders
+ if err := tc.renderCellWithStyle(column.Label, headerCell, tc.resolvedHeaderStyle, column.resolvedAlign); err != nil {
+ log.Printf("ERROR: Failed to render header cell for column %d (%s): %v", i, column.Label, err)
+ // Continue with next column instead of failing completely
+ }
+ } else {
+ // Fallback to standard text rendering using our direct FPDF access
+ headerTextProps := tc.convertStyleToTextProps(tc.resolvedHeaderStyle, column.resolvedAlign)
+ if err := tc.drawCellText(column.Label, headerCell, headerTextProps); err != nil {
+ log.Printf("ERROR: Failed to render header text for column %d (%s): %v", i, column.Label, err)
+ }
+ }
+ }
+}
+
+// renderDataRowComponent renders a data row for component interface
+func (tc *TableComponent) renderDataRowComponent(x, y float64, colWidths []float64, rowHeight float64, row []any, isAltRow, useAdvancedDrawing bool) {
+ // Determine which style to use
+ var rowStyle api.Class
+ if isAltRow && tc.AlternateRowStyle != "" {
+ rowStyle = tc.resolvedAlternateStyle
+ } else {
+ rowStyle = tc.resolvedRowStyle
+ }
+
+ for i, cellData := range row {
+ if i >= len(colWidths) || i >= len(tc.resolvedColumns) {
+ break
+ }
+
+ cellX := x + tc.sumWidths(colWidths[:i])
+ cellText := fmt.Sprintf("%v", cellData)
+
+ // Create cell for this data
+ dataCell := &entity.Cell{
+ X: cellX,
+ Y: y,
+ Width: colWidths[i],
+ Height: rowHeight,
+ }
+
+ // Use advanced drawing with backgrounds and borders
+ if err := tc.renderCellWithStyle(cellText, dataCell, rowStyle, tc.resolvedColumns[i].resolvedAlign); err != nil {
+ log.Printf("ERROR: Failed to render data cell for row %d, column %d (%s): %v", len(tc.Rows), i, cellText, err)
+ // Continue with next cell instead of failing completely
+ }
+ }
+}
+
+// renderCellWithStyle renders a cell with full styling support
+func (tc *TableComponent) renderCellWithStyle(text string, cell *entity.Cell, style api.Class, alignment tailwind.ParsedAlignment) error {
+ // Draw background
+ if style.Background != nil {
+ tc.drawCellBackground(cell, tc.convertToPropsColor(*style.Background))
+ }
+
+ // Draw borders
+ if tc.ShowBorders {
+ tc.drawCellBorders(cell)
+ }
+
+ // Draw text
+ textProps := tc.convertStyleToTextProps(style, alignment)
+ if err := tc.drawCellText(text, cell, textProps); err != nil {
+ return fmt.Errorf("failed to draw cell text: %w", err)
+ }
+
+ return nil
+}
+
+// Helper methods for component rendering
+func (tc *TableComponent) sumWidths(widths []float64) float64 {
+ sum := 0.0
+ for _, w := range widths {
+ sum += w
+ }
+ return sum
+}
+
+func (tc *TableComponent) convertToPropsColor(color api.Color) *props.Color {
+ // Parse hex color
+ if strings.HasPrefix(color.Hex, "#") && len(color.Hex) == 7 {
+ // Simple hex parsing - could be enhanced
+ return &props.Color{Red: 128, Green: 128, Blue: 128} // Placeholder
+ }
+ return &props.Color{Red: 128, Green: 128, Blue: 128}
+}
+
+func (tc *TableComponent) convertStyleToTextProps(style api.Class, alignment tailwind.ParsedAlignment) props.Text {
+ // Use StyleConverter for proper font handling including Unicode support
+ textProps := tc.styleConverter.ConvertToTextProps(style)
+
+ // Apply the specific alignment from Tailwind parsing
+ textProps.Align = alignment.Horizontal
+
+ return *textProps
+}
+
+func (tc *TableComponent) drawCellBackground(cell *entity.Cell, color *props.Color) {
+ if tc.Fpdf == nil || color == nil {
+ return
+ }
+ tc.Fpdf.SetFillColor(color.Red, color.Green, color.Blue)
+ tc.Fpdf.Rect(cell.X, cell.Y, cell.Width, cell.Height, "F")
+}
+
+func (tc *TableComponent) drawCellBorders(cell *entity.Cell) {
+ if tc.Fpdf == nil {
+ return
+ }
+ tc.Fpdf.SetDrawColor(128, 128, 128) // Gray
+ tc.Fpdf.Rect(cell.X, cell.Y, cell.Width, cell.Height, "D")
+}
+
+// getMarginInfo safely extracts margin and page information from the FPDF interface
+func (tc *TableComponent) getMarginInfo() (leftMargin, topMargin, rightMargin, bottomMargin, pageWidth, pageHeight float64, err error) {
+ if tc.Fpdf == nil {
+ err = errors.New("FPDF interface is nil")
+ return
+ }
+
+ // Get margin information directly from our FPDF interface
+ leftMargin, topMargin, rightMargin, bottomMargin = tc.Fpdf.GetMargins()
+ pageWidth, pageHeight = tc.Fpdf.GetPageSize()
+
+ if tc.Debug {
+ log.Printf("DEBUG: TableComponent.getMarginInfo: FPDF margins L=%.2f R=%.2f T=%.2f B=%.2f",
+ leftMargin, rightMargin, topMargin, bottomMargin)
+ usableWidth := pageWidth - leftMargin - rightMargin
+ usableHeight := pageHeight - topMargin - bottomMargin
+ log.Printf("DEBUG: TableComponent.getMarginInfo: FPDF page size %.2f×%.2f, usable area %.2f×%.2f",
+ pageWidth, pageHeight, usableWidth, usableHeight)
+ }
+
+ return leftMargin, topMargin, rightMargin, bottomMargin, pageWidth, pageHeight, nil
+}
+
+func (tc *TableComponent) drawCellText(text string, cell *entity.Cell, style props.Text) error {
+ if text == "" {
+ return nil // Empty text is not an error, just return success
+ }
+
+ if tc.Fpdf == nil {
+ err := errors.New("FPDF interface is nil")
+ log.Printf("ERROR: TableComponent.drawCellText: %v", err)
+ return err
+ }
+
+ if cell == nil {
+ err := errors.New("cell is nil")
+ log.Printf("ERROR: TableComponent.drawCellText: %v", err)
+ return err
+ }
+
+ // Configure font with Unicode support
+ fontFamily := style.Family
+ if fontFamily == "" {
+ fontFamily = "Arial" // Default font with Unicode support
+ }
+
+ // Convert fontstyle enum to string
+ fontStyleStr := ""
+ switch style.Style {
+ case fontstyle.Bold:
+ fontStyleStr = "B"
+ case fontstyle.Italic:
+ fontStyleStr = "I"
+ case fontstyle.BoldItalic:
+ fontStyleStr = "BI"
+ default:
+ fontStyleStr = ""
+ }
+
+ // Validate and set font size
+ fontSize := style.Size
+ if fontSize <= 0 {
+ fontSize = 8.0 // Default size
+ }
+
+ // Set font properties
+ tc.Fpdf.SetFont(fontFamily, fontStyleStr, fontSize)
+
+ // Set text color
+ if style.Color != nil {
+ tc.Fpdf.SetTextColor(style.Color.Red, style.Color.Green, style.Color.Blue)
+ } else {
+ tc.Fpdf.SetTextColor(0, 0, 0) // Default to black
+ }
+
+ // Calculate alignment string for CellFormat
+ var alignStr string
+ switch style.Align {
+ case align.Center:
+ alignStr = "C"
+ case align.Right:
+ alignStr = "R"
+ case align.Justify:
+ alignStr = "J"
+ default: // Left align (default)
+ alignStr = "L"
+ }
+
+ // Add vertical alignment modifier
+ if tc.TopAlign {
+ alignStr = "T" + alignStr // Top-aligned: TL, TC, TR, TJ
+ } else {
+ alignStr = "M" + alignStr // Middle-aligned: ML, MC, MR, MJ
+ }
+
+ // Validate cell dimensions
+ if cell.Width <= 0 || cell.Height <= 0 {
+ err := fmt.Errorf("invalid cell dimensions (w=%.2f, h=%.2f)", cell.Width, cell.Height)
+ log.Printf("WARNING: TableComponent.drawCellText: %v", err)
+ return err
+ }
+
+ // Position and render text
+ tc.Fpdf.SetXY(cell.X, cell.Y)
+
+ // Use CellFormat for proper text positioning and rendering
+ // Parameters: width, height, text, border, line break, alignment, fill, link, linkStr
+ tc.Fpdf.CellFormat(cell.Width, cell.Height, text, "", 0, alignStr, false, 0, "")
+
+ return nil
+}
+
+// GetHeight implements core.Component interface
+func (tc *TableComponent) GetHeight(provider core.Provider, cell *entity.Cell) float64 {
+ numRows := len(tc.Rows)
+ if len(tc.Columns) > 0 {
+ numRows++ // Add header row
+ }
+
+ return float64(numRows) * tc.getRowHeight()
+}
+
+// SetConfig implements core.Node interface
+func (tc *TableComponent) SetConfig(config *entity.Config) {
+ // Store config if needed
+}
+
+// GetStructure implements core.Node interface
+func (tc *TableComponent) GetStructure() *node.Node[core.Structure] {
+ str := core.Structure{
+ Type: "unified_table_component",
+ Value: "Unified table component with Tailwind styling",
+ Details: map[string]interface{}{
+ "columns": len(tc.Columns),
+ "rows": len(tc.Rows),
+ },
+ }
+
+ return node.New(str)
+}
diff --git a/formatters/pdf/table_test.go b/formatters/pdf/table_test.go
new file mode 100644
index 00000000..c244896a
--- /dev/null
+++ b/formatters/pdf/table_test.go
@@ -0,0 +1,1047 @@
+package pdf
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/flanksource/maroto/v2/pkg/components/col"
+ marotoimagecomponent "github.com/flanksource/maroto/v2/pkg/components/image"
+ "github.com/flanksource/maroto/v2/pkg/consts/align"
+ "github.com/flanksource/maroto/v2/pkg/consts/fontstyle"
+ "github.com/flanksource/maroto/v2/pkg/core/entity"
+ "github.com/flanksource/maroto/v2/pkg/fpdf"
+ "github.com/flanksource/maroto/v2/pkg/props"
+
+ "github.com/flanksource/clicky/api"
+ "github.com/flanksource/clicky/api/tailwind"
+)
+
+// createTestDrawingHelper creates a proper DrawingHelper for testing
+func createTestDrawingHelper(t *testing.T) *fpdf.DrawingHelper {
+ // Create a real PDF builder to get a valid provider
+ builder := NewBuilder()
+ if builder == nil {
+ t.Fatal("Failed to create PDF builder")
+ }
+
+ // Get the provider from the maroto instance
+ maroto := builder.GetMaroto()
+ if maroto == nil {
+ t.Fatal("Failed to get maroto instance from builder")
+ }
+
+ provider := maroto.GetProvider()
+ if provider == nil {
+ t.Fatal("Failed to get provider from maroto")
+ }
+
+ // Create the DrawingHelper from the valid provider
+ drawingHelper := fpdf.NewDrawingHelper(provider)
+ if drawingHelper == nil {
+ t.Fatal("Failed to create DrawingHelper from valid provider - this indicates a deeper infrastructure issue")
+ }
+
+ return drawingHelper
+}
+
+// saveTestPDF saves test PDF to the out directory
+func saveTestPDF(t *testing.T, name string, pdfData []byte) {
+ // Create output directory
+ outDir := "out"
+ if err := os.MkdirAll(outDir, 0o755); err != nil {
+ t.Logf("Warning: Could not create output directory: %v", err)
+ return
+ }
+
+ // Save PDF
+ filename := fmt.Sprintf("%s.pdf", name)
+ filepath := filepath.Join(outDir, filename)
+ if err := os.WriteFile(filepath, pdfData, 0o644); err != nil {
+ t.Logf("Warning: Could not save PDF to %s: %v", filepath, err)
+ } else {
+ t.Logf("Test PDF saved to: %s", filepath)
+ }
+}
+
+func TestTableCreation(t *testing.T) {
+ table := Table{
+ BaseTable: BaseTable{
+ Columns: []Column{
+ {Label: "Name", Style: "w-[50%] text-left align-middle"},
+ {Label: "Age", Style: "w-[25%] text-center align-middle"},
+ {Label: "City", Style: "w-[25%] text-right align-middle"},
+ },
+ Rows: [][]any{
+ {"Alice Johnson", 28, "New York"},
+ {"Bob Smith", 35, "Los Angeles"},
+ },
+ HeaderStyle: "font-bold bg-gray-100 text-center align-middle",
+ RowStyle: "text-gray-700 align-middle",
+ AlternateRowStyle: "bg-gray-50",
+ ShowBorders: true,
+ },
+ }
+
+ // Test that the table was created properly
+ if len(table.Columns) != 3 {
+ t.Errorf("Expected 3 columns, got %d", len(table.Columns))
+ }
+
+ if len(table.Rows) != 2 {
+ t.Errorf("Expected 2 rows, got %d", len(table.Rows))
+ }
+
+ if !table.ShowBorders {
+ t.Error("Expected ShowBorders to be true")
+ }
+}
+
+func TestTableComponent(t *testing.T) {
+ table := Table{
+ BaseTable: BaseTable{
+ Columns: []Column{
+ {Label: "Product", Style: "w-[40%] text-left align-middle"},
+ {Label: "Price", Style: "w-[30%] text-right align-middle font-mono"},
+ {Label: "Stock", Style: "w-[30%] text-center align-middle"},
+ },
+ Rows: [][]any{
+ {"Laptop", "$1,299", 15},
+ {"Mouse", "$29.99", 150},
+ {"Wireless Keyboard", "$79.99", 45},
+ {"USB-C Hub", "$49.99", 80},
+ },
+ HeaderStyle: "font-bold text-white bg-blue-600 text-center text-sm",
+ RowStyle: "text-sm text-gray-800 align-middle",
+ AlternateRowStyle: "bg-gray-50",
+ ShowBorders: true,
+ },
+ }
+
+ // Test that the table was created properly
+ if len(table.Columns) != 3 {
+ t.Errorf("Expected 3 columns, got %d", len(table.Columns))
+ }
+
+ if len(table.Rows) != 4 {
+ t.Errorf("Expected 4 rows, got %d", len(table.Rows))
+ }
+
+ // Generate PDF with the table
+ builder := NewBuilder()
+
+ // Add a title
+ titleText := Text{
+ Text: api.Text{
+ Content: "Table Test - Product Inventory",
+ Class: api.ResolveStyles("text-2xl font-bold text-center text-blue-800 mb-6"),
+ },
+ }
+ titleText.Draw(builder)
+
+ // Draw the table
+ err := table.Draw(builder)
+ if err != nil {
+ t.Fatalf("Failed to draw table: %v", err)
+ }
+
+ // Generate the PDF
+ pdfData, err := builder.Build()
+ if err != nil {
+ t.Fatalf("Failed to generate PDF: %v", err)
+ }
+
+ // Validate PDF was generated
+ if len(pdfData) == 0 {
+ t.Fatal("Generated PDF is empty")
+ }
+
+ // Save the PDF to out directory
+ saveTestPDF(t, "table_component_test", pdfData)
+
+ t.Logf("✓ Table PDF generated successfully (%d bytes)", len(pdfData))
+}
+
+func TestColumnWidthParsing(t *testing.T) {
+ tests := []struct {
+ name string
+ style string
+ expectedWidth string
+ }{
+ {
+ name: "percentage width",
+ style: "w-[25%] text-center align-middle",
+ expectedWidth: "25%",
+ },
+ {
+ name: "character width",
+ style: "w-[20ch] text-left font-mono",
+ expectedWidth: "20ch",
+ },
+ {
+ name: "pixel width",
+ style: "w-[200px] text-right",
+ expectedWidth: "200px",
+ },
+ {
+ name: "fractional width",
+ style: "w-1/3 text-center",
+ expectedWidth: "1/3",
+ },
+ {
+ name: "no width specified",
+ style: "text-center font-bold",
+ expectedWidth: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ column := Column{
+ Label: "Test",
+ Style: tt.style,
+ }
+
+ // Extract width from style using the Tailwind utilities
+ widthSpec := tailwind.ParseWidthFromStyle(column.Style)
+
+ var actualWidth string
+ if widthSpec != nil {
+ // Handle different width formats correctly
+ if strings.Contains(widthSpec.Original, "[") && strings.Contains(widthSpec.Original, "]") {
+ // Arbitrary values like w-[25%], w-[20ch]
+ start := strings.Index(widthSpec.Original, "[") + 1
+ end := strings.Index(widthSpec.Original, "]")
+ actualWidth = widthSpec.Original[start:end]
+ } else {
+ // Other formats like w-1/3, w-auto - remove the "w-" prefix
+ actualWidth = widthSpec.Original[2:]
+ }
+ }
+
+ if tt.expectedWidth == "" {
+ if actualWidth != "" {
+ t.Errorf("Expected no width, got %q", actualWidth)
+ }
+ } else {
+ if !strings.Contains(actualWidth, strings.Trim(tt.expectedWidth, "[]")) {
+ t.Errorf("Expected width containing %q, got %q", tt.expectedWidth, actualWidth)
+ }
+ }
+ })
+ }
+}
+
+func TestAlignmentParsing(t *testing.T) {
+ tests := []struct {
+ name string
+ style string
+ expectedHorizontal string
+ expectedVertical string
+ }{
+ {
+ name: "left-top alignment",
+ style: "text-left-top font-bold",
+ expectedHorizontal: "left",
+ expectedVertical: "top",
+ },
+ {
+ name: "center-middle alignment",
+ style: "text-center-middle bg-gray-100",
+ expectedHorizontal: "center",
+ expectedVertical: "middle",
+ },
+ {
+ name: "right-bottom alignment",
+ style: "text-right-bottom font-mono",
+ expectedHorizontal: "right",
+ expectedVertical: "bottom",
+ },
+ {
+ name: "separate alignment classes",
+ style: "text-center align-top font-bold",
+ expectedHorizontal: "center",
+ expectedVertical: "top",
+ },
+ {
+ name: "default alignment",
+ style: "font-bold bg-gray-100",
+ expectedHorizontal: "left",
+ expectedVertical: "middle",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ alignment := tailwind.ParseAlignment(tt.style)
+
+ // Convert alignment enums to strings for comparison
+ var horizontalStr string
+ switch alignment.Horizontal {
+ case "L": // align.Left
+ horizontalStr = "left"
+ case "C": // align.Center
+ horizontalStr = "center"
+ case "R": // align.Right
+ horizontalStr = "right"
+ case "J": // align.Justify
+ horizontalStr = "justify"
+ default:
+ horizontalStr = "left"
+ }
+
+ var verticalStr string
+ switch alignment.Vertical {
+ case tailwind.VerticalTop:
+ verticalStr = "top"
+ case tailwind.VerticalMiddle:
+ verticalStr = "middle"
+ case tailwind.VerticalBottom:
+ verticalStr = "bottom"
+ default:
+ verticalStr = "middle"
+ }
+
+ if horizontalStr != tt.expectedHorizontal {
+ t.Errorf("Expected horizontal alignment %q, got %q", tt.expectedHorizontal, horizontalStr)
+ }
+
+ if verticalStr != tt.expectedVertical {
+ t.Errorf("Expected vertical alignment %q, got %q", tt.expectedVertical, verticalStr)
+ }
+ })
+ }
+}
+
+func TestStyleMerging(t *testing.T) {
+ tests := []struct {
+ name string
+ base string
+ override string
+ expected string
+ }{
+ {
+ name: "simple merge",
+ base: "text-left font-normal",
+ override: "text-center",
+ expected: "font-normal text-center",
+ },
+ {
+ name: "color override",
+ base: "text-gray-500 bg-white",
+ override: "text-blue-600",
+ expected: "bg-white text-blue-600",
+ },
+ {
+ name: "multiple property override",
+ base: "text-left font-normal bg-gray-100",
+ override: "text-center font-bold",
+ expected: "bg-gray-100 text-center font-bold",
+ },
+ {
+ name: "no conflicts",
+ base: "font-bold",
+ override: "bg-gray-100",
+ expected: "font-bold bg-gray-100",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tailwind.MergeStyles(tt.base, tt.override)
+
+ // Split both results into words and sort for comparison
+ expectedWords := strings.Fields(tt.expected)
+ resultWords := strings.Fields(result)
+
+ if len(resultWords) != len(expectedWords) {
+ t.Errorf("Expected %d classes, got %d. Result: %q, Expected: %q",
+ len(expectedWords), len(resultWords), result, tt.expected)
+ return
+ }
+
+ // Check that all expected words are present (order may vary)
+ for _, expectedWord := range expectedWords {
+ found := false
+ for _, resultWord := range resultWords {
+ if resultWord == expectedWord {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("Expected class %q not found in result %q", expectedWord, result)
+ }
+ }
+ })
+ }
+}
+
+func TestTableWithComplexStyling(t *testing.T) {
+ table := Table{
+ BaseTable: BaseTable{
+ Columns: []Column{
+ {
+ Label: "Item",
+ Style: "w-[20ch] text-left-top font-medium text-gray-800",
+ },
+ {
+ Label: "Description",
+ Style: "w-[40ch] text-left-middle font-normal text-gray-700",
+ },
+ {
+ Label: "Price",
+ Style: "w-[10ch] text-right-middle font-mono text-green-600",
+ },
+ {
+ Label: "Status",
+ Style: "w-[8ch] text-center-bottom font-bold text-blue-600",
+ },
+ },
+ Rows: [][]any{
+ {"PRD-001", "High-performance laptop", "$1,299.00", "Active"},
+ {"PRD-002", "Wireless mouse", "$29.99", "Active"},
+ {"PRD-003", "USB keyboard", "$79.99", "Inactive"},
+ },
+ HeaderStyle: "font-bold bg-gray-800 text-white text-center-middle",
+ RowStyle: "text-gray-700 align-middle hover:bg-gray-50",
+ AlternateRowStyle: "bg-gray-25",
+ ShowBorders: true,
+ CompactMode: false,
+ },
+ }
+
+ // Test style parsing for all columns
+ for i, column := range table.Columns {
+ t.Run(column.Label, func(t *testing.T) {
+ // Parse width
+ widthSpec := tailwind.ParseWidthFromStyle(column.Style)
+ if widthSpec == nil {
+ t.Errorf("Column %d (%s): Expected width specification in style %q", i, column.Label, column.Style)
+ return
+ }
+
+ // Parse alignment
+ alignment := tailwind.ParseAlignment(column.Style)
+ // Just verify that alignment parsing doesn't fail
+ if alignment.Horizontal == "" {
+ t.Errorf("Column %d (%s): Invalid horizontal alignment in style %q", i, column.Label, column.Style)
+ }
+ if alignment.Vertical < 0 {
+ t.Errorf("Column %d (%s): Invalid vertical alignment in style %q", i, column.Label, column.Style)
+ }
+ })
+ }
+
+ // Test that all expected data is present
+ if len(table.Columns) != 4 {
+ t.Errorf("Expected 4 columns, got %d", len(table.Columns))
+ }
+
+ if len(table.Rows) != 3 {
+ t.Errorf("Expected 3 rows, got %d", len(table.Rows))
+ }
+
+ // Test header style parsing
+ headerAlignment := tailwind.ParseAlignment(table.HeaderStyle)
+ if headerAlignment.Horizontal != "C" { // align.Center
+ t.Errorf("Expected header horizontal alignment center (C), got %v", headerAlignment.Horizontal)
+ }
+ if headerAlignment.Vertical != tailwind.VerticalMiddle {
+ t.Errorf("Expected header vertical alignment middle, got %v", headerAlignment.Vertical)
+ }
+}
+
+func TestTableDataValidation(t *testing.T) {
+ tests := []struct {
+ name string
+ table Table
+ wantErr bool
+ }{
+ {
+ name: "valid table",
+ table: Table{
+ BaseTable: BaseTable{
+ Columns: []Column{
+ {Label: "Col1", Style: "w-[50%] text-left"},
+ {Label: "Col2", Style: "w-[50%] text-right"},
+ },
+ Rows: [][]any{
+ {"Data1", "Data2"},
+ {"Data3", "Data4"},
+ },
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "mismatched row data length",
+ table: Table{
+ BaseTable: BaseTable{
+ Columns: []Column{
+ {Label: "Col1", Style: "w-[50%] text-left"},
+ {Label: "Col2", Style: "w-[50%] text-right"},
+ },
+ Rows: [][]any{
+ {"Data1", "Data2"},
+ {"Data3"}, // Missing second column data
+ },
+ },
+ },
+ wantErr: false, // Should not error, just truncate or pad
+ },
+ {
+ name: "empty columns",
+ table: Table{
+ BaseTable: BaseTable{
+ Columns: []Column{},
+ Rows: [][]any{
+ {"Data1", "Data2"},
+ },
+ },
+ },
+ wantErr: false, // Should handle gracefully
+ },
+ {
+ name: "empty rows",
+ table: Table{
+ BaseTable: BaseTable{
+ Columns: []Column{
+ {Label: "Col1", Style: "w-[100%] text-center"},
+ },
+ Rows: [][]any{},
+ },
+ },
+ wantErr: false, // Should handle gracefully
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Test that table creation doesn't panic or error inappropriately
+ defer func() {
+ if r := recover(); r != nil && !tt.wantErr {
+ t.Errorf("Table creation panicked: %v", r)
+ }
+ }()
+
+ // Basic validation - ensure data is accessible
+ columnCount := len(tt.table.Columns)
+ rowCount := len(tt.table.Rows)
+
+ if columnCount < 0 {
+ t.Error("Negative column count")
+ }
+ if rowCount < 0 {
+ t.Error("Negative row count")
+ }
+
+ // Test that we can iterate through the data without panic
+ for i, row := range tt.table.Rows {
+ for j := range tt.table.Columns {
+ if j < len(row) {
+ // Data is accessible
+ _ = row[j]
+ }
+ // Don't fail if row is shorter than columns - this is handled gracefully
+ }
+ _ = i // Use the row index
+ }
+ })
+ }
+}
+
+func TestTableComponent_drawCellText(t *testing.T) {
+ // Create a mock TableComponent
+ tc := &TableComponent{
+ TopAlign: false,
+ styleConverter: NewStyleConverter(),
+ }
+
+ // Test cases for drawCellText
+ // NOTE: All tests with nil drawingHelper indicate a critical setup failure
+ tests := []struct {
+ name string
+ text string
+ cell *entity.Cell
+ style props.Text
+ expectError bool
+ errorSubstring string
+ setupFailure bool // Indicates this test expects a setup failure
+ }{
+ {
+ name: "nil cell with empty text",
+ text: "",
+ cell: nil,
+ style: props.Text{Size: 12},
+ expectError: false, // Empty text returns early before checking cell
+ errorSubstring: "",
+ },
+ {
+ name: "nil cell with text",
+ text: "test",
+ cell: nil,
+ style: props.Text{Size: 12},
+ expectError: true,
+ errorSubstring: "cell is nil",
+ },
+ {
+ name: "empty text with valid cell",
+ text: "",
+ cell: &entity.Cell{X: 0, Y: 0, Width: 100, Height: 20},
+ style: props.Text{Size: 12},
+ expectError: false, // Empty text should return success
+ },
+ {
+ name: "invalid cell dimensions",
+ text: "test",
+ cell: &entity.Cell{X: 0, Y: 0, Width: 0, Height: 20}, // Invalid width
+ style: props.Text{Size: 12},
+ expectError: true,
+ errorSubstring: "invalid cell dimensions",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create a proper DrawingHelper for testing
+ drawingHelper := createTestDrawingHelper(t)
+
+ // Set up FPDF interface for testing
+ tc.Fpdf = WrapFpdf(drawingHelper.GetFpdf())
+
+ // Test the method with valid infrastructure
+ err := tc.drawCellText(tt.text, tt.cell, tt.style)
+
+ // Test normal error handling with proper infrastructure
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error for case '%s', but got nil", tt.name)
+ } else if tt.errorSubstring != "" && !strings.Contains(err.Error(), tt.errorSubstring) {
+ t.Errorf("Expected error containing '%s', got: %v", tt.errorSubstring, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Expected no error for case '%s', but got: %v", tt.name, err)
+ }
+ }
+ })
+ }
+}
+
+func TestTableComponent_drawCellText_StyleHandling(t *testing.T) {
+ tc := &TableComponent{
+ TopAlign: true, // Test top alignment
+ styleConverter: NewStyleConverter(),
+ }
+
+
+ // Test different style configurations
+ styleTests := []struct {
+ name string
+ style props.Text
+ expectedLog string // What we expect in debug logs
+ }{
+ {
+ name: "bold font",
+ style: props.Text{
+ Family: "Arial",
+ Style: fontstyle.Bold,
+ Size: 12,
+ Align: align.Left,
+ },
+ },
+ {
+ name: "italic font",
+ style: props.Text{
+ Family: "Times",
+ Style: fontstyle.Italic,
+ Size: 14,
+ Align: align.Center,
+ },
+ },
+ {
+ name: "right aligned",
+ style: props.Text{
+ Size: 10,
+ Align: align.Right,
+ },
+ },
+ {
+ name: "default values",
+ style: props.Text{}, // Test all defaults
+ },
+ }
+
+ cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 20}
+
+ for _, tt := range styleTests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Test that style handling doesn't panic with various configurations
+ defer func() {
+ if r := recover(); r != nil {
+ t.Errorf("drawCellText panicked with style %+v: %v", tt.style, r)
+ }
+ }()
+
+ // Test with proper DrawingHelper infrastructure
+ err := tc.drawCellText("test", cell, tt.style)
+ if err != nil {
+ t.Errorf("drawCellText failed with style %+v: %v", tt.style, err)
+ }
+ })
+ }
+}
+
+func TestTableComponent_drawCellText_AlignmentCalculation(t *testing.T) {
+
+ tests := []struct {
+ name string
+ topAlign bool
+ horizontalAlign align.Type
+ expectedPrefix string
+ }{
+ {
+ name: "top left",
+ topAlign: true,
+ horizontalAlign: align.Left,
+ expectedPrefix: "TL",
+ },
+ {
+ name: "top center",
+ topAlign: true,
+ horizontalAlign: align.Center,
+ expectedPrefix: "TC",
+ },
+ {
+ name: "top right",
+ topAlign: true,
+ horizontalAlign: align.Right,
+ expectedPrefix: "TR",
+ },
+ {
+ name: "middle left",
+ topAlign: false,
+ horizontalAlign: align.Left,
+ expectedPrefix: "ML",
+ },
+ {
+ name: "middle center",
+ topAlign: false,
+ horizontalAlign: align.Center,
+ expectedPrefix: "MC",
+ },
+ {
+ name: "middle right",
+ topAlign: false,
+ horizontalAlign: align.Right,
+ expectedPrefix: "MR",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tc := &TableComponent{
+ TopAlign: tt.topAlign,
+ styleConverter: NewStyleConverter(),
+ }
+
+ style := props.Text{
+ Align: tt.horizontalAlign,
+ Size: 10,
+ }
+ cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 20}
+
+ // Test that alignment calculation works correctly
+ // Since we can't access the internal alignment string directly,
+ // we test that the method doesn't panic and handles alignment properly
+ defer func() {
+ if r := recover(); r != nil {
+ t.Errorf("drawCellText panicked with alignment %v: %v", tt.horizontalAlign, r)
+ }
+ }()
+
+ err := tc.drawCellText("test", cell, style)
+ if err != nil {
+ t.Errorf("drawCellText failed with alignment %v: %v", tt.horizontalAlign, err)
+ }
+ })
+ }
+}
+
+func TestTableComponent_drawCellText_EdgeCases(t *testing.T) {
+ tc := &TableComponent{
+ TopAlign: false,
+ styleConverter: NewStyleConverter(),
+ }
+
+
+ edgeCases := []struct {
+ name string
+ cell *entity.Cell
+ text string
+ expectError bool
+ errorSubtext string
+ }{
+ {
+ name: "zero width cell",
+ cell: &entity.Cell{X: 0, Y: 0, Width: 0, Height: 20},
+ text: "test",
+ expectError: true,
+ errorSubtext: "invalid cell dimensions",
+ },
+ {
+ name: "zero height cell",
+ cell: &entity.Cell{X: 0, Y: 0, Width: 100, Height: 0},
+ text: "test",
+ expectError: true,
+ errorSubtext: "invalid cell dimensions",
+ },
+ {
+ name: "negative dimensions",
+ cell: &entity.Cell{X: 0, Y: 0, Width: -10, Height: -5},
+ text: "test",
+ expectError: true,
+ errorSubtext: "invalid cell dimensions",
+ },
+ {
+ name: "very long text",
+ cell: &entity.Cell{X: 0, Y: 0, Width: 50, Height: 20},
+ text: "This is a very long text that might not fit in the cell and could cause issues",
+ expectError: false, // Should handle gracefully
+ },
+ {
+ name: "special characters",
+ cell: &entity.Cell{X: 0, Y: 0, Width: 100, Height: 20},
+ text: "Special chars: @#$%^&*()[]{}|\\:;\"'<>?/.,`~",
+ expectError: false, // Should handle gracefully
+ },
+ {
+ name: "empty text",
+ cell: &entity.Cell{X: 0, Y: 0, Width: 100, Height: 20},
+ text: "",
+ expectError: false, // Empty text should succeed
+ },
+ }
+
+ for _, tt := range edgeCases {
+ t.Run(tt.name, func(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Errorf("drawCellText panicked with edge case '%s': %v", tt.name, r)
+ }
+ }()
+
+ style := props.Text{Size: 10}
+ err := tc.drawCellText(tt.text, tt.cell, style)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error for edge case '%s', but got none", tt.name)
+ } else if tt.errorSubtext != "" && !strings.Contains(err.Error(), tt.errorSubtext) {
+ t.Errorf("Expected error containing '%s' for edge case '%s', got: %v", tt.errorSubtext, tt.name, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Expected no error for edge case '%s', but got: %v", tt.name, err)
+ }
+ }
+ })
+ }
+}
+
+// TestTwoColumnSVGTableLayout tests the two-column layout with SVG (60%) + Table (40%)
+func TestTwoColumnSVGTableLayout(t *testing.T) {
+ // Enable debug mode for detailed positioning logs
+ debugMode := true
+ if debugMode {
+ t.Logf("DEBUG: TestTwoColumnSVGTableLayout: debug mode enabled")
+ }
+
+ // Create builder
+ builder := NewBuilder(WithDebug(true))
+
+ // Create a demo SVG box for the image column (extracted from showcase)
+ demoSVGBox := SVGBox{
+ Box: api.Box{
+ Rectangle: api.Rectangle{Width: 300, Height: 200},
+ Fill: api.Color{Hex: "#e3f2fd"},
+ Border: api.Borders{
+ Top: api.Line{Width: 3, Color: api.Color{Hex: "#1976d2"}},
+ Right: api.Line{Width: 3, Color: api.Color{Hex: "#1976d2"}},
+ Bottom: api.Line{Width: 3, Color: api.Color{Hex: "#1976d2"}},
+ Left: api.Line{Width: 3, Color: api.Color{Hex: "#1976d2"}},
+ },
+ },
+ Circles: []CircleShape{
+ {X: 75, Y: 50, Diameter: 25, Label: "A"},
+ {X: 225, Y: 50, Diameter: 25, Label: "B"},
+ {X: 150, Y: 150, Diameter: 30, Label: "C"},
+ },
+ Labels: []Label{
+ {
+ Positionable: Positionable{
+ Position: &LabelPosition{Vertical: VerticalTop, Horizontal: HorizontalCenter},
+ },
+ Text: api.Text{
+ Content: "Technical Diagram",
+ Class: api.Class{Font: &api.Font{Bold: true, Size: 1.1}},
+ },
+ },
+ },
+ ShowDimensions: true,
+ ActualWidth: 300,
+ ActualHeight: 200,
+ DimensionUnit: "mm",
+ }
+
+ // Generate temporary SVG file
+ svgBytes, err := demoSVGBox.GenerateSVG()
+ if err != nil {
+ t.Fatalf("Failed to generate demo SVG: %v", err)
+ }
+
+ if debugMode {
+ t.Logf("DEBUG: TestTwoColumnSVGTableLayout: generated SVG with %d bytes", len(svgBytes))
+ }
+
+ tempFile, err := os.CreateTemp("", "two_column_layout_test_*.svg")
+ if err != nil {
+ t.Fatalf("Failed to create temp SVG file: %v", err)
+ }
+ defer os.Remove(tempFile.Name())
+
+ if debugMode {
+ t.Logf("DEBUG: TestTwoColumnSVGTableLayout: created temp SVG file: %s", tempFile.Name())
+ }
+
+ if _, err := tempFile.Write(svgBytes); err != nil {
+ tempFile.Close()
+ t.Fatalf("Failed to write SVG data: %v", err)
+ }
+ tempFile.Close()
+
+ // Convert SVG to PNG using the existing conversion system
+ ctx := context.Background()
+ pngPath := "out/two_column_layout_test.png"
+
+ convertOptions := &ConvertOptions{
+ Format: "png",
+ DPI: 288, // High resolution
+ }
+
+ // Convert SVG to PNG
+ if debugMode {
+ t.Logf("DEBUG: TestTwoColumnSVGTableLayout: converting SVG to PNG at %s with DPI=%d", pngPath, convertOptions.DPI)
+ }
+ if err := ConvertWithFallback(ctx, tempFile.Name(), pngPath, convertOptions); err != nil {
+ t.Fatalf("SVG conversion failed: %v", err)
+ }
+
+ // Verify the PNG file was created and is valid
+ if err := ValidatePNGFile(pngPath); err != nil {
+ t.Fatalf("Converted PNG is invalid: %v", err)
+ }
+
+ if debugMode {
+ pngStat, err := os.Stat(pngPath)
+ if err == nil {
+ t.Logf("DEBUG: TestTwoColumnSVGTableLayout: PNG conversion successful, file size: %.1f KB", float64(pngStat.Size())/1024)
+ }
+ }
+
+ // Create the side-by-side row using maroto's column system
+ // 60% = 7.2 columns ≈ 7 columns (7/12 = 58.33%)
+ // 40% = 4.8 columns ≈ 5 columns (5/12 = 41.67%)
+ leftColWidth := 7
+ rightColWidth := 5
+ if debugMode {
+ t.Logf("DEBUG: TestTwoColumnSVGTableLayout: layout ratios - left=%d/12 (%.2f%%), right=%d/12 (%.2f%%)",
+ leftColWidth, float64(leftColWidth)/12*100, rightColWidth, float64(rightColWidth)/12*100)
+ }
+
+ // Left column: Image component (7 columns ≈ 58.33%)
+ imageComponent := marotoimagecomponent.NewFromFile(pngPath, props.Rect{
+ Top: 0, // Align to top of cell
+ Left: 0, // Align to left of cell
+ Percent: 95, // Use 95% of available space
+ Center: false, // Don't center, use Top/Left positioning
+ })
+ leftCol := col.New(leftColWidth).Add(imageComponent)
+
+ // Right column: Table component (5 columns ≈ 41.67%)
+ tableComponent := NewTableComponent(
+ []string{"Property", "Value"},
+ [][]string{
+ {"Width", "300mm"},
+ {"Height", "200mm"},
+ {"Circles", "3 (A, B, C)"},
+ {"Area", "600cm²"},
+ {"Border", "3px Blue"},
+ {"Aspect Ratio", "3:2"},
+ {"Scale", "1:1"},
+ {"Format", "SVG→PNG"},
+ {"Resolution", "288 DPI"},
+ {"Status", "✓ Valid"},
+ },
+ )
+
+ // Enable debug mode for the table component
+ tableComponent.Debug = debugMode
+
+ if debugMode {
+ t.Logf("DEBUG: TestTwoColumnSVGTableLayout: created table component with %d columns, %d rows, debug=%v",
+ len(tableComponent.Columns), len(tableComponent.Rows), tableComponent.Debug)
+ }
+
+ rightCol := col.New(rightColWidth).Add(tableComponent)
+
+ // Add the side-by-side row to the builder
+ rowHeight := 80.0 // Height in mm to accommodate content
+
+ if debugMode {
+ t.Logf("DEBUG: TestTwoColumnSVGTableLayout: adding side-by-side row with height=%.1fmm", rowHeight)
+ }
+
+ builder.GetMaroto().AddRow(rowHeight, leftCol, rightCol)
+
+ // Generate the PDF
+ if debugMode {
+ t.Logf("DEBUG: TestTwoColumnSVGTableLayout: generating PDF...")
+ }
+
+ pdfData, err := builder.Build()
+ if err != nil {
+ t.Fatalf("Failed to generate PDF: %v", err)
+ }
+
+ if debugMode {
+ t.Logf("DEBUG: TestTwoColumnSVGTableLayout: PDF generated successfully, size=%d bytes", len(pdfData))
+ }
+
+ // Validate PDF was generated
+ if len(pdfData) == 0 {
+ t.Fatal("Generated PDF is empty")
+ }
+
+ // Save the PDF to out directory
+ saveTestPDF(t, "two_column_layout_test", pdfData)
+
+ // Get file size information
+ pngStat, pngErr := os.Stat(pngPath)
+ var pngSize string
+ if pngErr == nil {
+ pngSize = fmt.Sprintf("%.1f KB", float64(pngStat.Size())/1024)
+ } else {
+ pngSize = "Unknown"
+ }
+
+ t.Logf("✓ Two-column layout PDF generated successfully")
+ t.Logf(" PDF size: %d bytes", len(pdfData))
+ t.Logf(" PNG size: %s", pngSize)
+ t.Logf(" Layout: 7:5 column ratio (58.33%%:41.67%%)")
+ t.Logf(" Files: out/two_column_layout_test.pdf, %s", pngPath)
+}
diff --git a/formatters/pdf/units.go b/formatters/pdf/units.go
new file mode 100644
index 00000000..59b96612
--- /dev/null
+++ b/formatters/pdf/units.go
@@ -0,0 +1,152 @@
+package pdf
+
+import "fmt"
+
+// Unit conversion constants
+const (
+ // 1 point = 25.4/72 mm (exact: 1/72 inch = 25.4mm/72)
+ PointsToMM = 25.4 / 72.0
+ // 1 mm = 72/25.4 points (exact reciprocal)
+ MMToPoints = 72.0 / 25.4
+ // 1 rem = 12 points (common web default)
+ RemToPoints = 12.0
+ // 1 rem = 4.233333... mm (12pt converted to mm)
+ RemToMM = RemToPoints * PointsToMM
+)
+
+// Point represents a measurement in typographic points
+type Point float64
+
+// MM represents a measurement in millimeters
+type MM float64
+
+// ToMM converts points to millimeters
+func (p Point) ToMM() MM {
+ return MM(float64(p) * PointsToMM)
+}
+
+// ToPoints converts millimeters to points
+func (m MM) ToPoints() Point {
+ return Point(float64(m) * MMToPoints)
+}
+
+// Float64 returns the point value as a float64
+func (p Point) Float64() float64 {
+ return float64(p)
+}
+
+// Float64 returns the mm value as a float64
+func (m MM) Float64() float64 {
+ return float64(m)
+}
+
+// String returns a formatted string representation
+func (p Point) String() string {
+ return fmt.Sprintf("%.2fpt", float64(p))
+}
+
+// String returns a formatted string representation
+func (m MM) String() string {
+ return fmt.Sprintf("%.2fmm", float64(m))
+}
+
+// Add adds two Point values
+func (p Point) Add(other Point) Point {
+ return p + other
+}
+
+// Add adds two MM values
+func (m MM) Add(other MM) MM {
+ return m + other
+}
+
+// Multiply multiplies a Point by a scalar
+func (p Point) Multiply(factor float64) Point {
+ return Point(float64(p) * factor)
+}
+
+// Multiply multiplies an MM by a scalar
+func (m MM) Multiply(factor float64) MM {
+ return MM(float64(m) * factor)
+}
+
+// NewPoint creates a new Point value
+func NewPoint(value float64) Point {
+ return Point(value)
+}
+
+// NewMM creates a new MM value
+func NewMM(value float64) MM {
+ return MM(value)
+}
+
+// FontSize represents a font size in points (standard unit for typography)
+type FontSize Point
+
+// ToMM converts font size to millimeters for layout calculations
+func (f FontSize) ToMM() MM {
+ return Point(f).ToMM()
+}
+
+// Float64 returns the font size as a float64 in points
+func (f FontSize) Float64() float64 {
+ return float64(f)
+}
+
+// String returns a formatted string representation
+func (f FontSize) String() string {
+ return fmt.Sprintf("%.1fpt", float64(f))
+}
+
+// LineHeight calculates line height with a given multiplier
+func (f FontSize) LineHeight(multiplier float64) MM {
+ return f.ToMM().Multiply(multiplier)
+}
+
+// NewFontSize creates a new FontSize value
+func NewFontSize(value float64) FontSize {
+ return FontSize(value)
+}
+
+// Rem represents a measurement in CSS rem units
+type Rem float64
+
+// ToPoints converts rem to points
+func (r Rem) ToPoints() Point {
+ return Point(float64(r) * RemToPoints)
+}
+
+// ToMM converts rem to millimeters
+func (r Rem) ToMM() MM {
+ return MM(float64(r) * RemToMM)
+}
+
+// ToFontSize converts rem to FontSize (assuming rem is used for font sizing)
+func (r Rem) ToFontSize() FontSize {
+ return FontSize(r.ToPoints())
+}
+
+// Float64 returns the rem value as a float64
+func (r Rem) Float64() float64 {
+ return float64(r)
+}
+
+// String returns a formatted string representation
+func (r Rem) String() string {
+ return fmt.Sprintf("%.2frem", float64(r))
+}
+
+// Add adds two Rem values
+func (r Rem) Add(other Rem) Rem {
+ return r + other
+}
+
+// Multiply multiplies a Rem by a scalar
+func (r Rem) Multiply(factor float64) Rem {
+ return Rem(float64(r) * factor)
+}
+
+// NewRem creates a new Rem value
+func NewRem(value float64) Rem {
+ return Rem(value)
+}
\ No newline at end of file
diff --git a/formatters/pdf/units_test.go b/formatters/pdf/units_test.go
new file mode 100644
index 00000000..34eb4419
--- /dev/null
+++ b/formatters/pdf/units_test.go
@@ -0,0 +1,259 @@
+package pdf
+
+import (
+ "math"
+ "testing"
+)
+
+func TestPointToMM(t *testing.T) {
+ tests := []struct {
+ name string
+ points Point
+ expected MM
+ }{
+ {"12pt standard font", NewPoint(12), MM(12 * 25.4 / 72.0)}, // 4.233333...
+ {"8pt small font", NewPoint(8), MM(8 * 25.4 / 72.0)}, // 2.822222...
+ {"16pt large font", NewPoint(16), MM(16 * 25.4 / 72.0)}, // 5.644444...
+ {"0pt zero", NewPoint(0), MM(0)},
+ {"72pt one inch", NewPoint(72), MM(25.4)}, // 1 inch = 72pt = 25.4mm
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.points.ToMM()
+ if math.Abs(float64(result-tt.expected)) > 0.000001 {
+ t.Errorf("Point(%v).ToMM() = %v, want %v", tt.points, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestMMToPoint(t *testing.T) {
+ tests := []struct {
+ name string
+ mm MM
+ expected Point
+ }{
+ {"25.4mm one inch", NewMM(25.4), Point(72)}, // 1 inch = 25.4mm = 72pt
+ {"10mm", NewMM(10), Point(28.3465)},
+ {"5mm", NewMM(5), Point(14.17325)},
+ {"0mm zero", NewMM(0), Point(0)},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.mm.ToPoints()
+ if math.Abs(float64(result-tt.expected)) > 0.0001 {
+ t.Errorf("MM(%v).ToPoints() = %v, want %v", tt.mm, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestRoundTripConversion(t *testing.T) {
+ tests := []float64{12, 8, 16, 24, 72, 0, 1}
+
+ t.Run("Point->MM->Point", func(t *testing.T) {
+ for _, value := range tests {
+ original := NewPoint(value)
+ converted := original.ToMM().ToPoints()
+ if math.Abs(float64(original-converted)) > 0.000001 {
+ t.Errorf("Round trip Point(%v) -> MM -> Point = %v, lost precision", original, converted)
+ }
+ }
+ })
+
+ t.Run("MM->Point->MM", func(t *testing.T) {
+ for _, value := range tests {
+ original := NewMM(value)
+ converted := original.ToPoints().ToMM()
+ if math.Abs(float64(original-converted)) > 0.000001 {
+ t.Errorf("Round trip MM(%v) -> Point -> MM = %v, lost precision", original, converted)
+ }
+ }
+ })
+}
+
+func TestFontSizeOperations(t *testing.T) {
+ fontSize := NewFontSize(12)
+
+ t.Run("FontSize to MM", func(t *testing.T) {
+ result := fontSize.ToMM()
+ expected := MM(12 * 25.4 / 72.0) // 12pt = 4.233333...mm
+ if math.Abs(float64(result-expected)) > 0.000001 {
+ t.Errorf("FontSize(12).ToMM() = %v, want %v", result, expected)
+ }
+ })
+
+ t.Run("FontSize line height", func(t *testing.T) {
+ result := fontSize.LineHeight(1.2)
+ expected := fontSize.ToMM().Multiply(1.2)
+ if math.Abs(float64(result-expected)) > 0.000001 {
+ t.Errorf("FontSize(12).LineHeight(1.2) = %v, want %v", result, expected)
+ }
+ })
+
+ t.Run("FontSize Float64", func(t *testing.T) {
+ result := fontSize.Float64()
+ expected := 12.0
+ if result != expected {
+ t.Errorf("FontSize(12).Float64() = %v, want %v", result, expected)
+ }
+ })
+}
+
+func TestUnitArithmetic(t *testing.T) {
+ t.Run("Point arithmetic", func(t *testing.T) {
+ p1 := NewPoint(10)
+ p2 := NewPoint(5)
+
+ sum := p1.Add(p2)
+ if sum != NewPoint(15) {
+ t.Errorf("Point(10).Add(Point(5)) = %v, want Point(15)", sum)
+ }
+
+ multiplied := p1.Multiply(2)
+ if multiplied != NewPoint(20) {
+ t.Errorf("Point(10).Multiply(2) = %v, want Point(20)", multiplied)
+ }
+ })
+
+ t.Run("MM arithmetic", func(t *testing.T) {
+ m1 := NewMM(10)
+ m2 := NewMM(5)
+
+ sum := m1.Add(m2)
+ if sum != NewMM(15) {
+ t.Errorf("MM(10).Add(MM(5)) = %v, want MM(15)", sum)
+ }
+
+ multiplied := m1.Multiply(2)
+ if multiplied != NewMM(20) {
+ t.Errorf("MM(10).Multiply(2) = %v, want MM(20)", multiplied)
+ }
+ })
+}
+
+func TestStringRepresentation(t *testing.T) {
+ tests := []struct {
+ name string
+ value interface{ String() string }
+ expected string
+ }{
+ {"Point formatting", NewPoint(12.5), "12.50pt"},
+ {"MM formatting", NewMM(10.75), "10.75mm"},
+ {"FontSize formatting", NewFontSize(14), "14.0pt"},
+ {"Rem formatting", NewRem(1.5), "1.50rem"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.value.String()
+ if result != tt.expected {
+ t.Errorf("%T.String() = %v, want %v", tt.value, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestConversionConstants(t *testing.T) {
+ // Test that our constants are correct
+ // 1 inch = 72 points = 25.4 mm
+
+ t.Run("PointsToMM constant", func(t *testing.T) {
+ // 72 points should equal 25.4 mm
+ result := 72 * PointsToMM
+ expected := 25.4
+ if math.Abs(result-expected) > 0.0001 {
+ t.Errorf("72 * PointsToMM = %v, want %v", result, expected)
+ }
+ })
+
+ t.Run("MMToPoints constant", func(t *testing.T) {
+ // 25.4 mm should equal 72 points
+ result := 25.4 * MMToPoints
+ expected := 72.0
+ if math.Abs(result-expected) > 0.0001 {
+ t.Errorf("25.4 * MMToPoints = %v, want %v", result, expected)
+ }
+ })
+
+ t.Run("Constants are reciprocals", func(t *testing.T) {
+ product := PointsToMM * MMToPoints
+ expected := 1.0
+ if math.Abs(product-expected) > 0.0001 {
+ t.Errorf("PointsToMM * MMToPoints = %v, want %v", product, expected)
+ }
+ })
+
+ t.Run("Rem conversion constants", func(t *testing.T) {
+ // 1 rem = 12 points
+ if RemToPoints != 12.0 {
+ t.Errorf("RemToPoints = %v, want 12.0", RemToPoints)
+ }
+
+ // 1 rem = 4.233336 mm (12pt * 0.352778)
+ expected := 12.0 * PointsToMM
+ if math.Abs(RemToMM-expected) > 0.0001 {
+ t.Errorf("RemToMM = %v, want %v", RemToMM, expected)
+ }
+ })
+}
+
+func TestRemConversions(t *testing.T) {
+ tests := []struct {
+ name string
+ rem Rem
+ expPoints Point
+ expMM MM
+ }{
+ {"1rem standard", NewRem(1), Point(12), MM(12 * 25.4 / 72.0)}, // 4.233333...
+ {"2rem large", NewRem(2), Point(24), MM(24 * 25.4 / 72.0)}, // 8.466666...
+ {"0.5rem small", NewRem(0.5), Point(6), MM(6 * 25.4 / 72.0)}, // 2.116666...
+ {"0rem zero", NewRem(0), Point(0), MM(0)},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Test rem to points
+ resultPoints := tt.rem.ToPoints()
+ if math.Abs(float64(resultPoints-tt.expPoints)) > 0.0001 {
+ t.Errorf("Rem(%v).ToPoints() = %v, want %v", tt.rem, resultPoints, tt.expPoints)
+ }
+
+ // Test rem to mm
+ resultMM := tt.rem.ToMM()
+ if math.Abs(float64(resultMM-tt.expMM)) > 0.000001 {
+ t.Errorf("Rem(%v).ToMM() = %v, want %v", tt.rem, resultMM, tt.expMM)
+ }
+
+ // Test rem to font size
+ fontSize := tt.rem.ToFontSize()
+ expectedFontSize := FontSize(tt.expPoints)
+ if fontSize != expectedFontSize {
+ t.Errorf("Rem(%v).ToFontSize() = %v, want %v", tt.rem, fontSize, expectedFontSize)
+ }
+ })
+ }
+}
+
+func TestRemArithmetic(t *testing.T) {
+ r1 := NewRem(1.5)
+ r2 := NewRem(0.5)
+
+ t.Run("Rem addition", func(t *testing.T) {
+ sum := r1.Add(r2)
+ expected := NewRem(2.0)
+ if sum != expected {
+ t.Errorf("Rem(1.5).Add(Rem(0.5)) = %v, want %v", sum, expected)
+ }
+ })
+
+ t.Run("Rem multiplication", func(t *testing.T) {
+ multiplied := r1.Multiply(2)
+ expected := NewRem(3.0)
+ if multiplied != expected {
+ t.Errorf("Rem(1.5).Multiply(2) = %v, want %v", multiplied, expected)
+ }
+ })
+}
\ No newline at end of file
diff --git a/formatters/pdf/utils_test.go b/formatters/pdf/utils_test.go
index a3b0a44b..fcd4b6ad 100644
--- a/formatters/pdf/utils_test.go
+++ b/formatters/pdf/utils_test.go
@@ -92,7 +92,6 @@ func ExtractTextFromPage(pdfData []byte, pageNum int) (string, error) {
return textContent, nil
}
-
// assertPDFTextOrder verifies that text appears in the PDF in the expected order
func assertPDFTextOrder(t *testing.T, pdfData []byte, orderedTexts []string) {
t.Helper()
@@ -123,8 +122,6 @@ func assertPDFTextOrder(t *testing.T, pdfData []byte, orderedTexts []string) {
}
}
-
-
// GetPDFInfo returns basic information about a PDF
func GetPDFInfo(pdfData []byte) (pages, size int, err error) {
reader := bytes.NewReader(pdfData)
@@ -147,7 +144,6 @@ func createTestSVG() string {
`
}
-
// createComplexTestSVG creates a more complex test SVG with various elements
func createComplexTestSVG() string {
return `