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 ` @@ -323,7 +319,6 @@ func assertNoSVGRenderingErrors(t *testing.T, pdfData []byte) { // Helper functions - // maxInt returns the larger of two integers func maxInt(a, b int) int { if a > b { diff --git a/formatters/pretty_formatter.go b/formatters/pretty_formatter.go index 1e64255a..cd3d4471 100644 --- a/formatters/pretty_formatter.go +++ b/formatters/pretty_formatter.go @@ -591,7 +591,6 @@ func (p *PrettyFormatter) formatDefaultWithVisited(val reflect.Value, visited ma } } - // formatMapWithVisited formats a map value with circular reference detection func (p *PrettyFormatter) formatMapWithVisited(val reflect.Value, visited map[uintptr]bool) string { if val.IsNil() || val.Len() == 0 { @@ -625,7 +624,6 @@ func (p *PrettyFormatter) formatMapWithVisited(val reflect.Value, visited map[ui return fmt.Sprintf("map[%s]", strings.Join(parts, " ")) } - // formatSliceWithVisited formats a slice value with circular reference detection func (p *PrettyFormatter) formatSliceWithVisited(val reflect.Value, visited map[uintptr]bool) string { if val.IsNil() || val.Len() == 0 { diff --git a/formatters/pretty_row_integration_test.go b/formatters/pretty_row_integration_test.go new file mode 100644 index 00000000..af240e47 --- /dev/null +++ b/formatters/pretty_row_integration_test.go @@ -0,0 +1,130 @@ +package formatters + +import ( + "strings" + "testing" + + "github.com/flanksource/clicky/api" + "github.com/stretchr/testify/assert" +) + +// SampleUser demonstrates a struct implementing PrettyRow for custom table formatting +type SampleUser struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Active bool `json:"active"` +} + +// PrettyRow implements the PrettyRow interface for custom table formatting +func (u SampleUser) PrettyRow(opts interface{}) map[string]api.Text { + result := make(map[string]api.Text) + + // ID with blue styling + result["ID"] = api.Text{Content: string(rune(u.ID + '0')), Style: "text-blue-600 font-mono"} + + // Username with bold styling + result["Username"] = api.Text{Content: u.Username, Style: "font-bold"} + + // Email with subtle styling + result["Email"] = api.Text{Content: u.Email, Style: "text-gray-600"} + + // Active status with conditional coloring + statusText := "Active" + statusStyle := "text-green-600 font-medium" + if !u.Active { + statusText = "Inactive" + statusStyle = "text-red-600 font-medium" + } + + // Check for NoColor option + if opts != nil { + if noColorOpts, ok := opts.(FormatOptions); ok && noColorOpts.NoColor { + // Remove colors when NoColor is set + result["ID"] = api.Text{Content: string(rune(u.ID + '0')), Style: "font-mono"} + result["Username"] = api.Text{Content: u.Username, Style: "font-bold"} + result["Email"] = api.Text{Content: u.Email, Style: ""} + statusStyle = "font-medium" + } + } + + result["Status"] = api.Text{Content: statusText, Style: statusStyle} + + return result +} + +func TestPrettyRowIntegrationWithMarkdown(t *testing.T) { + users := []SampleUser{ + {ID: 1, Username: "alice", Email: "alice@example.com", Active: true}, + {ID: 2, Username: "bob", Email: "bob@example.com", Active: false}, + {ID: 3, Username: "charlie", Email: "charlie@example.com", Active: true}, + } + + // Format with markdown using the new PrettyRow interface + manager := NewFormatManager() + opts := FormatOptions{Format: "markdown", NoColor: false} + + result, err := manager.FormatWithOptions(opts, users) + assert.NoError(t, err) + assert.NotEmpty(t, result) + + // Verify that the output contains the expected table structure + assert.True(t, strings.Contains(result, "| ID |")) + assert.True(t, strings.Contains(result, "| Username |")) + assert.True(t, strings.Contains(result, "| Email |")) + assert.True(t, strings.Contains(result, "| Status |")) + + // Verify that custom content appears (usernames and emails) + assert.True(t, strings.Contains(result, "alice")) + assert.True(t, strings.Contains(result, "bob")) + assert.True(t, strings.Contains(result, "charlie")) + assert.True(t, strings.Contains(result, "alice@example.com")) + + // Verify status formatting + assert.True(t, strings.Contains(result, "Active")) + assert.True(t, strings.Contains(result, "Inactive")) +} + +func TestPrettyRowIntegrationWithNoColor(t *testing.T) { + users := []SampleUser{ + {ID: 1, Username: "test", Email: "test@example.com", Active: false}, + } + + manager := NewFormatManager() + opts := FormatOptions{Format: "markdown", NoColor: true} + + result, err := manager.FormatWithOptions(opts, users) + assert.NoError(t, err) + assert.NotEmpty(t, result) + + // The output should still contain the data but with NoColor applied + assert.True(t, strings.Contains(result, "test")) + assert.True(t, strings.Contains(result, "test@example.com")) + assert.True(t, strings.Contains(result, "Inactive")) +} + +func TestRegularStructWithoutPrettyRowInterface(t *testing.T) { + // Regular struct without PrettyRow interface should still work + type RegularStruct struct { + Name string `json:"name"` + Value int `json:"value"` + } + + data := []RegularStruct{ + {Name: "item1", Value: 100}, + {Name: "item2", Value: 200}, + } + + manager := NewFormatManager() + opts := FormatOptions{Format: "markdown"} + + result, err := manager.FormatWithOptions(opts, data) + assert.NoError(t, err) + assert.NotEmpty(t, result) + + // Should still generate a table with reflection-based approach + assert.True(t, strings.Contains(result, "item1")) + assert.True(t, strings.Contains(result, "item2")) + assert.True(t, strings.Contains(result, "100")) + assert.True(t, strings.Contains(result, "200")) +} \ No newline at end of file diff --git a/formatters/tree_formatter.go b/formatters/tree_formatter.go index def3f0d9..dafec7b1 100644 --- a/formatters/tree_formatter.go +++ b/formatters/tree_formatter.go @@ -170,7 +170,6 @@ func (f *TreeFormatter) FormatTreeFromRoot(root api.TreeNode) string { return f.FormatTree(root, 0, "", true) } - // FormatInlineTree formats a tree structure for inline display func (f *TreeFormatter) FormatInlineTree(nodes []api.TreeNode, separator string) string { if len(nodes) == 0 { diff --git a/go.mod b/go.mod index f8f4f110..a3987b6c 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,10 @@ require ( github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b github.com/charmbracelet/lipgloss v0.13.1 github.com/flanksource/commons v1.42.0 + github.com/flanksource/gomplate/v3 v3.24.60 github.com/flanksource/maroto/v2 v2.4.2 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/cel-go v0.22.1 github.com/johnfercher/go-tree v1.0.5 github.com/jung-kurt/gofpdf v1.16.2 github.com/labstack/echo/v4 v4.13.4 @@ -22,6 +25,7 @@ require ( github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.10.0 github.com/xuri/excelize/v2 v2.9.1 + golang.org/x/crypto v0.41.0 golang.org/x/sync v0.16.0 golang.org/x/term v0.34.0 golang.org/x/text v0.28.0 @@ -47,7 +51,6 @@ require ( github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect github.com/f-amaral/go-async v0.3.0 // indirect github.com/fatih/color v1.18.0 // indirect - github.com/flanksource/gomplate/v3 v3.24.60 // indirect github.com/flanksource/is-healthy v1.0.59 // indirect github.com/flanksource/kubectl-neat v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -62,8 +65,6 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect - github.com/google/cel-go v0.22.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect @@ -144,7 +145,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.41.0 // indirect golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect golang.org/x/image v0.27.0 // indirect golang.org/x/net v0.43.0 // indirect @@ -169,5 +169,3 @@ require ( sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) - -//replace github.com/flanksource/commons v1.41.1 => /Users/moshe/go/src/github.com/flanksource/commons diff --git a/go.sum b/go.sum index da527c88..0bb46055 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= +github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -124,6 +126,7 @@ github.com/jeremywohl/flatten v0.0.0-20180923035001-588fe0d4c603 h1:gSech9iGLFCo github.com/jeremywohl/flatten v0.0.0-20180923035001-588fe0d4c603/go.mod h1:4AmD/VxjWcI5SRB0n6szE2A6s2fsNHDLO0nAlMHgfLQ= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/johnfercher/go-tree v1.0.5 h1:zpgVhJsChavzhKdxhQiCJJzcSY3VCT9oal2JoA2ZevY= github.com/johnfercher/go-tree v1.0.5/go.mod h1:DUO6QkXIFh1K7jeGBIkLCZaeUgnkdQAsB64FDSoHswg= @@ -426,6 +429,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= diff --git a/mcp/command.go b/mcp/command.go index 5ef3b526..a66b085f 100644 --- a/mcp/command.go +++ b/mcp/command.go @@ -402,8 +402,8 @@ Examples: // Create tool registry to get available tools toolRegistry := NewToolRegistry(config) if err := toolRegistry.RegisterCommandTree(rootCmd); err != nil { - fmt.Printf("Warning: failed to register command tree: %v\n", err) - } + fmt.Printf("Warning: failed to register command tree: %v\n", err) + } tools := toolRegistry.GetTools() fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14")). diff --git a/middleware/auth.go b/middleware/auth.go index 4dba3517..9abbdb95 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -244,4 +244,4 @@ func (us *UserStore) RemoveUser(username string) { // Clear removes all users from the store func (us *UserStore) Clear() { us.users = make(map[string]*User) -} \ No newline at end of file +} diff --git a/middleware/cel.go b/middleware/cel.go index 76bd072a..2f2ae0fe 100644 --- a/middleware/cel.go +++ b/middleware/cel.go @@ -308,4 +308,4 @@ func CreateJWTVariables(c echo.Context, token *jwt.Token, claims jwt.MapClaims) vars["token"] = token vars["claims"] = claims return vars -} \ No newline at end of file +} diff --git a/middleware/config.go b/middleware/config.go index c3a7060e..4d2c54fe 100644 --- a/middleware/config.go +++ b/middleware/config.go @@ -16,9 +16,9 @@ import ( // through either programmatic configuration or YAML/JSON file loading. type MiddlewareConfig struct { // Core middleware - Essential middleware for most applications - CORS *CORSConfig `json:"cors,omitempty" yaml:"cors,omitempty"` // Cross-Origin Resource Sharing - Logger *LoggerConfig `json:"logger,omitempty" yaml:"logger,omitempty"` // Request logging - Recover *RecoverConfig `json:"recover,omitempty" yaml:"recover,omitempty"` // Panic recovery + CORS *CORSConfig `json:"cors,omitempty" yaml:"cors,omitempty"` // Cross-Origin Resource Sharing + Logger *LoggerConfig `json:"logger,omitempty" yaml:"logger,omitempty"` // Request logging + Recover *RecoverConfig `json:"recover,omitempty" yaml:"recover,omitempty"` // Panic recovery RequestID *RequestIDConfig `json:"request_id,omitempty" yaml:"request_id,omitempty"` // Request ID generation // Security middleware - Authentication and authorization @@ -32,10 +32,10 @@ type MiddlewareConfig struct { Interceptors []*InterceptorConfig `json:"interceptors,omitempty" yaml:"interceptors,omitempty"` // Generic request/response interceptors // Request/Response processing middleware - Content handling - BodyDump *BodyDumpConfig `json:"body_dump,omitempty" yaml:"body_dump,omitempty"` // Request/response body logging - BodyLimit *BodyLimitConfig `json:"body_limit,omitempty" yaml:"body_limit,omitempty"` // Request body size limiting - Gzip *GzipConfig `json:"gzip,omitempty" yaml:"gzip,omitempty"` // Response compression - Decompress *DecompressConfig `json:"decompress,omitempty" yaml:"decompress,omitempty"` // Request decompression + BodyDump *BodyDumpConfig `json:"body_dump,omitempty" yaml:"body_dump,omitempty"` // Request/response body logging + BodyLimit *BodyLimitConfig `json:"body_limit,omitempty" yaml:"body_limit,omitempty"` // Request body size limiting + Gzip *GzipConfig `json:"gzip,omitempty" yaml:"gzip,omitempty"` // Response compression + Decompress *DecompressConfig `json:"decompress,omitempty" yaml:"decompress,omitempty"` // Request decompression // Routing and static middleware - URL and file handling Static *StaticConfig `json:"static,omitempty" yaml:"static,omitempty"` // Static file serving @@ -44,8 +44,8 @@ type MiddlewareConfig struct { Rewrite *RewriteConfig `json:"rewrite,omitempty" yaml:"rewrite,omitempty"` // URL rewriting // Redirect middleware - URL redirection - HTTPSRedirect *RedirectConfig `json:"https_redirect,omitempty" yaml:"https_redirect,omitempty"` // HTTP to HTTPS redirect - WWWRedirect *RedirectConfig `json:"www_redirect,omitempty" yaml:"www_redirect,omitempty"` // WWW subdomain redirect + HTTPSRedirect *RedirectConfig `json:"https_redirect,omitempty" yaml:"https_redirect,omitempty"` // HTTP to HTTPS redirect + WWWRedirect *RedirectConfig `json:"www_redirect,omitempty" yaml:"www_redirect,omitempty"` // WWW subdomain redirect HTTPSWWWRedirect *RedirectConfig `json:"https_www_redirect,omitempty" yaml:"https_www_redirect,omitempty"` // Combined HTTPS+WWW redirect // URL normalization middleware - Trailing slash handling @@ -447,12 +447,12 @@ type DecompressConfig struct { // StaticConfig configures Static middleware type StaticConfig struct { - Root string `json:"root,omitempty"` // Root directory for static files - Index string `json:"index,omitempty"` // Index file name - Browse bool `json:"browse,omitempty"` // Enable directory browsing - HTML5 bool `json:"html5,omitempty"` // Enable HTML5 mode - IgnoreBase bool `json:"ignore_base,omitempty"` // Ignore base of URL path - Filesystem http.FileSystem `json:"-"` // Custom filesystem, not serializable + Root string `json:"root,omitempty"` // Root directory for static files + Index string `json:"index,omitempty"` // Index file name + Browse bool `json:"browse,omitempty"` // Enable directory browsing + HTML5 bool `json:"html5,omitempty"` // Enable HTML5 mode + IgnoreBase bool `json:"ignore_base,omitempty"` // Ignore base of URL path + Filesystem http.FileSystem `json:"-"` // Custom filesystem, not serializable } // MethodOverrideConfig configures MethodOverride middleware @@ -462,31 +462,31 @@ type MethodOverrideConfig struct { // RequestLoggerConfig configures RequestLogger middleware type RequestLoggerConfig struct { - LogStatus bool `json:"log_status,omitempty"` - LogURI bool `json:"log_uri,omitempty"` - LogError bool `json:"log_error,omitempty"` - LogLatency bool `json:"log_latency,omitempty"` - LogProtocol bool `json:"log_protocol,omitempty"` - LogRemoteIP bool `json:"log_remote_ip,omitempty"` - LogHost bool `json:"log_host,omitempty"` - LogMethod bool `json:"log_method,omitempty"` - LogUserAgent bool `json:"log_user_agent,omitempty"` - LogRequestID bool `json:"log_request_id,omitempty"` - LogReferer bool `json:"log_referer,omitempty"` - LogContentLength bool `json:"log_content_length,omitempty"` - LogResponseSize bool `json:"log_response_size,omitempty"` - HandleError bool `json:"handle_error,omitempty"` + LogStatus bool `json:"log_status,omitempty"` + LogURI bool `json:"log_uri,omitempty"` + LogError bool `json:"log_error,omitempty"` + LogLatency bool `json:"log_latency,omitempty"` + LogProtocol bool `json:"log_protocol,omitempty"` + LogRemoteIP bool `json:"log_remote_ip,omitempty"` + LogHost bool `json:"log_host,omitempty"` + LogMethod bool `json:"log_method,omitempty"` + LogUserAgent bool `json:"log_user_agent,omitempty"` + LogRequestID bool `json:"log_request_id,omitempty"` + LogReferer bool `json:"log_referer,omitempty"` + LogContentLength bool `json:"log_content_length,omitempty"` + LogResponseSize bool `json:"log_response_size,omitempty"` + HandleError bool `json:"handle_error,omitempty"` LogValuesFunc func(echo.Context, middleware.RequestLoggerValues) error `json:"-"` // Log values function, not serializable } // ProxyConfig configures Proxy middleware type ProxyConfig struct { - Targets []*ProxyTarget `json:"targets,omitempty"` // List of targets - Balancer interface{} `json:"-"` // Load balancer, not serializable - Rewrite map[string]string `json:"rewrite,omitempty"` // URL rewrite rules - RegexRewrite map[string]string `json:"regex_rewrite,omitempty"` // Regex rewrite rules - Timeout time.Duration `json:"timeout,omitempty"` // Request timeout - ModifyResponse func(*http.Response) error `json:"-"` // Response modifier, not serializable + Targets []*ProxyTarget `json:"targets,omitempty"` // List of targets + Balancer interface{} `json:"-"` // Load balancer, not serializable + Rewrite map[string]string `json:"rewrite,omitempty"` // URL rewrite rules + RegexRewrite map[string]string `json:"regex_rewrite,omitempty"` // Regex rewrite rules + Timeout time.Duration `json:"timeout,omitempty"` // Request timeout + ModifyResponse func(*http.Response) error `json:"-"` // Response modifier, not serializable } // ProxyTarget represents a proxy target @@ -512,9 +512,9 @@ type TrailingSlashConfig struct { // ContextTimeoutConfig configures ContextTimeout middleware type ContextTimeoutConfig struct { - Timeout time.Duration `json:"timeout"` // Request timeout - ErrorHandler func(error, echo.Context) error `json:"-"` // Timeout error handler, not serializable - OnTimeoutRouteErrorHandler func(error, echo.Context) `json:"-"` // Timeout route error handler, not serializable + Timeout time.Duration `json:"timeout"` // Request timeout + ErrorHandler func(error, echo.Context) error `json:"-"` // Timeout error handler, not serializable + OnTimeoutRouteErrorHandler func(error, echo.Context) `json:"-"` // Timeout route error handler, not serializable } // DefaultConfig returns a MiddlewareConfig with sensible defaults @@ -587,12 +587,12 @@ func ProductionConfig() MiddlewareConfig { TargetHeader: echo.HeaderXRequestID, }, CSRF: &CSRFConfig{ - TokenLength: 32, - TokenLookup: "header:X-CSRF-Token", - ContextKey: "csrf", - CookieName: "_csrf", - CookieMaxAge: 86400, // 24 hours - CookieSecure: true, + TokenLength: 32, + TokenLookup: "header:X-CSRF-Token", + ContextKey: "csrf", + CookieName: "_csrf", + CookieMaxAge: 86400, // 24 hours + CookieSecure: true, CookieHTTPOnly: true, }, BodyLimit: &BodyLimitConfig{ @@ -634,17 +634,17 @@ func SecurityConfig() MiddlewareConfig { TargetHeader: echo.HeaderXRequestID, }, CSRF: &CSRFConfig{ - TokenLength: 32, - TokenLookup: "header:X-CSRF-Token", - CookieSecure: true, + TokenLength: 32, + TokenLookup: "header:X-CSRF-Token", + CookieSecure: true, CookieHTTPOnly: true, }, Secure: &SecureConfig{ - XSSProtection: "1; mode=block", - ContentTypeNosniff: "nosniff", - XFrameOptions: "DENY", - HSTSMaxAge: 31536000, - HSTSPreloadEnabled: true, + XSSProtection: "1; mode=block", + ContentTypeNosniff: "nosniff", + XFrameOptions: "DENY", + HSTSMaxAge: 31536000, + HSTSPreloadEnabled: true, ContentSecurityPolicy: "default-src 'self'", }, RateLimiter: &RateLimiterConfig{ @@ -717,17 +717,20 @@ func ProxyGatewayConfig() MiddlewareConfig { // validates the parsed data, and returns a MiddlewareConfig struct. // // Example usage: -// config, err := middleware.LoadConfigFromYAML("config/middleware.yaml") -// if err != nil { -// log.Fatalf("Failed to load config: %v", err) -// } +// +// config, err := middleware.LoadConfigFromYAML("config/middleware.yaml") +// if err != nil { +// log.Fatalf("Failed to load config: %v", err) +// } // // Parameters: -// filename - Path to the YAML configuration file +// +// filename - Path to the YAML configuration file // // Returns: -// MiddlewareConfig - Parsed and validated configuration -// error - Any error that occurred during loading or validation +// +// MiddlewareConfig - Parsed and validated configuration +// error - Any error that occurred during loading or validation func LoadConfigFromYAML(filename string) (MiddlewareConfig, error) { var config MiddlewareConfig @@ -755,18 +758,21 @@ func LoadConfigFromYAML(filename string) (MiddlewareConfig, error) { // and writes it to the specified file with proper formatting. // // Example usage: -// config := middleware.DefaultConfig() -// err := middleware.SaveConfigToYAML(config, "config/middleware.yaml") -// if err != nil { -// log.Fatalf("Failed to save config: %v", err) -// } +// +// config := middleware.DefaultConfig() +// err := middleware.SaveConfigToYAML(config, "config/middleware.yaml") +// if err != nil { +// log.Fatalf("Failed to save config: %v", err) +// } // // Parameters: -// config - The middleware configuration to save -// filename - Path where the YAML file should be written +// +// config - The middleware configuration to save +// filename - Path where the YAML file should be written // // Returns: -// error - Any error that occurred during serialization or file writing +// +// error - Any error that occurred during serialization or file writing func SaveConfigToYAML(config MiddlewareConfig, filename string) error { // Serialize to YAML with proper formatting data, err := yaml.Marshal(&config) @@ -787,15 +793,18 @@ func SaveConfigToYAML(config MiddlewareConfig, filename string) error { // is valid and can be safely used with Echo middleware. // // Example usage: -// if err := middleware.ValidateConfig(config); err != nil { -// log.Fatalf("Invalid config: %v", err) -// } +// +// if err := middleware.ValidateConfig(config); err != nil { +// log.Fatalf("Invalid config: %v", err) +// } // // Parameters: -// config - The middleware configuration to validate +// +// config - The middleware configuration to validate // // Returns: -// error - Description of validation errors, or nil if valid +// +// error - Description of validation errors, or nil if valid func ValidateConfig(config MiddlewareConfig) error { // Validate CORS configuration if config.CORS != nil { @@ -924,4 +933,4 @@ type InterceptorConfig struct { // - 'headers.get("Content-Type").contains("json") ? {headers: {"Cache-Control": "no-cache"}} : null' // - 'response.status >= 400 ? null : {headers: {"X-Success": "true"}}' Response []string `json:"response,omitempty" yaml:"response,omitempty"` -} \ No newline at end of file +} diff --git a/middleware/doc.go b/middleware/doc.go index ed19785b..81840840 100644 --- a/middleware/doc.go +++ b/middleware/doc.go @@ -296,4 +296,4 @@ The package includes comprehensive test coverage and utilities: For detailed API reference and additional examples, see the complete configuration options in the source code and example files. */ -package middleware \ No newline at end of file +package middleware diff --git a/middleware/echo.go b/middleware/echo.go index a19cf002..9a716c45 100644 --- a/middleware/echo.go +++ b/middleware/echo.go @@ -417,4 +417,4 @@ func ApplyDevelopmentMiddleware(e *echo.Echo) { // ApplyProxyGatewayMiddleware applies proxy/gateway middleware configuration to an Echo instance func ApplyProxyGatewayMiddleware(e *echo.Echo) { ApplyMiddleware(e, ProxyGatewayConfig()) -} \ No newline at end of file +} diff --git a/middleware/echo_ginkgo_test.go b/middleware/echo_ginkgo_test.go index ca4a8cf6..8fada82d 100644 --- a/middleware/echo_ginkgo_test.go +++ b/middleware/echo_ginkgo_test.go @@ -153,9 +153,9 @@ var _ = Describe("Echo Middleware", func() { It("should create middleware with LogValuesFunc", func() { config := MiddlewareConfig{ RequestLogger: &RequestLoggerConfig{ - LogStatus: true, - LogURI: true, - LogMethod: true, + LogStatus: true, + LogURI: true, + LogMethod: true, LogValuesFunc: func(c echo.Context, v echomiddleware.RequestLoggerValues) error { // Custom logging logic return nil @@ -475,4 +475,4 @@ var _ = Describe("Echo Middleware", func() { ) }) }) -}) \ No newline at end of file +}) diff --git a/middleware/echo_suite_test.go b/middleware/echo_suite_test.go index a610fe3f..892cc8cb 100644 --- a/middleware/echo_suite_test.go +++ b/middleware/echo_suite_test.go @@ -10,4 +10,4 @@ import ( func TestMiddleware(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Middleware Suite") -} \ No newline at end of file +} diff --git a/middleware/echo_test.go b/middleware/echo_test.go index 963f0ebc..a12fad51 100644 --- a/middleware/echo_test.go +++ b/middleware/echo_test.go @@ -405,4 +405,4 @@ func TestApplyProxyGatewayMiddleware(t *testing.T) { require.NotPanics(t, func() { ApplyProxyGatewayMiddleware(e) }, "ApplyProxyGatewayMiddleware should not panic") -} \ No newline at end of file +} diff --git a/middleware/interceptor.go b/middleware/interceptor.go index 1ff89d83..0c93214b 100644 --- a/middleware/interceptor.go +++ b/middleware/interceptor.go @@ -389,4 +389,4 @@ func ValidateInterceptorConfig(config *InterceptorConfig) error { // LogInterceptorActivity logs interceptor activity for debugging func LogInterceptorActivity(c echo.Context, interceptorName string, phase string, result interface{}) { c.Logger().Debugf("Interceptor '%s' %s: %+v", interceptorName, phase, result) -} \ No newline at end of file +} diff --git a/middleware/jwt.go b/middleware/jwt.go index 57556650..c8bf9c89 100644 --- a/middleware/jwt.go +++ b/middleware/jwt.go @@ -334,4 +334,4 @@ func ValidateJWTConfig(config *JWTAuthConfig) error { } return nil -} \ No newline at end of file +} diff --git a/rpc/converter.go b/rpc/converter.go index e8d43841..515c9791 100644 --- a/rpc/converter.go +++ b/rpc/converter.go @@ -270,7 +270,6 @@ func isCRUDOperation(part string) bool { return false } - // getParentCommandName returns the name of the parent command for tagging func (c *Converter) getParentCommandName(cmd *cobra.Command) string { if cmd.Parent() != nil && cmd.Parent().Parent() != nil { diff --git a/task/doc.go b/task/doc.go index 6692ec00..bd5921b1 100644 --- a/task/doc.go +++ b/task/doc.go @@ -428,4 +428,4 @@ The task package provides a complete solution for concurrent task execution with comprehensive error handling, and flexible configuration options suitable for CLI tools, servers, and batch processing applications. */ -package task \ No newline at end of file +package task