diff --git a/pkg/html2pdf/converter.go b/pkg/html2pdf/converter.go new file mode 100644 index 000000000..6833c48c6 --- /dev/null +++ b/pkg/html2pdf/converter.go @@ -0,0 +1,163 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package html2pdf + +import ( + "bytes" + "context" + _ "embed" + "encoding/base64" + "fmt" + "io" + + "github.com/chromedp/cdproto/page" + "github.com/chromedp/chromedp" + "go.gearno.de/kit/log" +) + +var ( + waitUntilDocumentReady = func(ctx context.Context) error { + var ready bool + return chromedp.Evaluate(`document.readyState === 'complete'`, &ready).Do(ctx) + } +) + +type ( + RenderConfig struct { + PageFormat PageFormat + Orientation Orientation + MarginTop Margin + MarginBottom Margin + MarginLeft Margin + MarginRight Margin + PrintBackground bool + Scale float64 // Print scale (0.1 to 2.0) + } + + Option func(*Converter) + + Converter struct { + l *log.Logger + addr string + } +) + +func WithLogger(l *log.Logger) Option { + return func(c *Converter) { + c.l = l + } +} + +func NewConverter(addr string, opts ...Option) *Converter { + c := &Converter{ + addr: addr, + l: log.NewLogger(log.WithOutput(io.Discard)), + } + for _, opt := range opts { + opt(c) + } + + c.l = c.l.Named("mdpdf").With(log.String("addr", addr)) + + return c +} + +func getPageDimensions(format PageFormat, orientation Orientation) (width, height float64) { + var w, h float64 + + switch format { + case PageFormatA4: + w, h = 8.27, 11.69 // A4 in inches + case PageFormatLetter: + w, h = 8.5, 11.0 // Letter in inches + default: + w, h = 8.27, 11.69 // Default to A4 + } + + if orientation == OrientationLandscape { + return h, w // Swap width and height for landscape + } + return w, h +} + +func (c *Converter) GeneratePDF(ctx context.Context, htmlDocument []byte, cfg RenderConfig) (io.Reader, error) { + allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, c.addr) + defer cancel() + + ctx, cancel = chromedp.NewContext(allocCtx) + defer cancel() + + dataURL := fmt.Sprintf( + "data:text/html;base64,%s", + base64.StdEncoding.EncodeToString(htmlDocument), + ) + + width, height := getPageDimensions(cfg.PageFormat, cfg.Orientation) + + marginTop := cfg.MarginTop.ToInches() + marginBottom := cfg.MarginBottom.ToInches() + marginLeft := cfg.MarginLeft.ToInches() + marginRight := cfg.MarginRight.ToInches() + + scale := cfg.Scale + if scale <= 0 { + scale = 1.0 + } + + var pdfBytes []byte + + c.l.InfoCtx( + ctx, + "running chromedp", + log.String("dataURL", dataURL), + log.Float64("width", width), + log.Float64("height", height), + log.Float64("marginTop", marginTop), + log.Float64("marginBottom", marginBottom), + log.Float64("marginLeft", marginLeft), + log.Float64("marginRight", marginRight), + log.Float64("scale", scale), + log.Bool("printBackground", cfg.PrintBackground), + ) + + err := chromedp.Run(ctx, + chromedp.Navigate(dataURL), + chromedp.WaitReady("body"), + chromedp.ActionFunc(waitUntilDocumentReady), + chromedp.ActionFunc( + func(ctx context.Context) (err error) { + pdfBytes, _, err = page.PrintToPDF(). + WithPrintBackground(cfg.PrintBackground). + WithPaperWidth(width). + WithPaperHeight(height). + WithMarginTop(marginTop). + WithMarginBottom(marginBottom). + WithMarginLeft(marginLeft). + WithMarginRight(marginRight). + WithScale(scale). + WithPreferCSSPageSize(false). + Do(ctx) + + return err + }, + ), + ) + + if err != nil { + return nil, fmt.Errorf("cannot run chromedp: %w", err) + } + + return bytes.NewReader(pdfBytes), nil +} diff --git a/pkg/html2pdf/converter_test.go b/pkg/html2pdf/converter_test.go new file mode 100644 index 000000000..a573165a3 --- /dev/null +++ b/pkg/html2pdf/converter_test.go @@ -0,0 +1,413 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package html2pdf + +import ( + "bytes" + "context" + "io" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/log" +) + +func TestNewConverter(t *testing.T) { + tests := []struct { + name string + addr string + opts []Option + }{ + { + name: "converter without options", + addr: "ws://localhost:9222", + opts: nil, + }, + { + name: "converter with logger option", + addr: "ws://localhost:9222", + opts: []Option{WithLogger(log.NewLogger())}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + converter := NewConverter(tt.addr, tt.opts...) + + assert.NotNil(t, converter) + assert.Equal(t, tt.addr, converter.addr) + assert.NotNil(t, converter.l) + }) + } +} + +func TestWithLogger(t *testing.T) { + logger := log.NewLogger() + converter := &Converter{} + + option := WithLogger(logger) + option(converter) + + assert.NotNil(t, converter.l) +} + +func TestGetPageDimensions(t *testing.T) { + tests := []struct { + name string + format PageFormat + orientation Orientation + wantWidth float64 + wantHeight float64 + }{ + { + name: "A4 portrait", + format: PageFormatA4, + orientation: OrientationPortrait, + wantWidth: 8.27, + wantHeight: 11.69, + }, + { + name: "A4 landscape", + format: PageFormatA4, + orientation: OrientationLandscape, + wantWidth: 11.69, + wantHeight: 8.27, + }, + { + name: "Letter portrait", + format: PageFormatLetter, + orientation: OrientationPortrait, + wantWidth: 8.5, + wantHeight: 11.0, + }, + { + name: "Letter landscape", + format: PageFormatLetter, + orientation: OrientationLandscape, + wantWidth: 11.0, + wantHeight: 8.5, + }, + { + name: "Unknown format defaults to A4 portrait", + format: PageFormat("unknown"), + orientation: OrientationPortrait, + wantWidth: 8.27, + wantHeight: 11.69, + }, + { + name: "Unknown format defaults to A4 landscape", + format: PageFormat("unknown"), + orientation: OrientationLandscape, + wantWidth: 11.69, + wantHeight: 8.27, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + width, height := getPageDimensions(tt.format, tt.orientation) + + assert.Equal(t, tt.wantWidth, width) + assert.Equal(t, tt.wantHeight, height) + }) + } +} + +func TestGeneratePDF_InvalidChrome(t *testing.T) { + converter := NewConverter("ws://invalid:9999") + + htmlContent := []byte("

Test

") + cfg := RenderConfig{ + PageFormat: PageFormatA4, + Orientation: OrientationPortrait, + MarginTop: NewMarginInches(1.0), + MarginBottom: NewMarginInches(1.0), + MarginLeft: NewMarginInches(1.0), + MarginRight: NewMarginInches(1.0), + PrintBackground: true, + Scale: 1.0, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := converter.GeneratePDF(ctx, htmlContent, cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot run chromedp") +} + +func TestGeneratePDF_WithValidChrome(t *testing.T) { + // Skip this test if Chrome is not available + chromeAddr := os.Getenv("CHROME_WS_URL") + if chromeAddr == "" { + t.Skip("Skipping test: CHROME_WS_URL environment variable not set") + } + + converter := NewConverter(chromeAddr) + + htmlContent := []byte(` + + + + Test Document + + + +

Test PDF Generation

+
+

This is a test document to verify PDF generation works correctly.

+

It includes some basic styling and multiple elements.

+
+ + + `) + + cfg := RenderConfig{ + PageFormat: PageFormatA4, + Orientation: OrientationPortrait, + MarginTop: NewMarginInches(1.0), + MarginBottom: NewMarginInches(1.0), + MarginLeft: NewMarginInches(1.0), + MarginRight: NewMarginInches(1.0), + PrintBackground: true, + Scale: 1.0, + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + reader, err := converter.GeneratePDF(ctx, htmlContent, cfg) + require.NoError(t, err) + require.NotNil(t, reader) + + // Read the PDF content to verify it's not empty + pdfContent, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Greater(t, len(pdfContent), 0) + + // Verify it looks like a PDF file (starts with PDF magic bytes) + assert.True(t, bytes.HasPrefix(pdfContent, []byte("%PDF-"))) +} + +func TestGeneratePDF_ScaleHandling(t *testing.T) { + chromeAddr := os.Getenv("CHROME_WS_URL") + if chromeAddr == "" { + t.Skip("Skipping test: CHROME_WS_URL environment variable not set") + } + + converter := NewConverter(chromeAddr) + htmlContent := []byte("

Scale Test

") + + tests := []struct { + name string + scale float64 + wantScale float64 + }{ + { + name: "zero scale defaults to 1.0", + scale: 0.0, + wantScale: 1.0, + }, + { + name: "negative scale defaults to 1.0", + scale: -0.5, + wantScale: 1.0, + }, + { + name: "valid scale preserved", + scale: 1.5, + wantScale: 1.5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := RenderConfig{ + PageFormat: PageFormatA4, + Orientation: OrientationPortrait, + MarginTop: NewMarginInches(1.0), + MarginBottom: NewMarginInches(1.0), + MarginLeft: NewMarginInches(1.0), + MarginRight: NewMarginInches(1.0), + PrintBackground: false, + Scale: tt.scale, + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + reader, err := converter.GeneratePDF(ctx, htmlContent, cfg) + require.NoError(t, err) + require.NotNil(t, reader) + + // Just verify we get a valid PDF - detailed scale verification would require + // more complex PDF parsing + pdfContent, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Greater(t, len(pdfContent), 0) + assert.True(t, bytes.HasPrefix(pdfContent, []byte("%PDF-"))) + }) + } +} + +func TestRenderConfig_AllFormatsAndOrientations(t *testing.T) { + chromeAddr := os.Getenv("CHROME_WS_URL") + if chromeAddr == "" { + t.Skip("Skipping test: CHROME_WS_URL environment variable not set") + } + + converter := NewConverter(chromeAddr) + htmlContent := []byte("

Format Test

") + + tests := []struct { + name string + format PageFormat + orientation Orientation + }{ + { + name: "A4 Portrait", + format: PageFormatA4, + orientation: OrientationPortrait, + }, + { + name: "A4 Landscape", + format: PageFormatA4, + orientation: OrientationLandscape, + }, + { + name: "Letter Portrait", + format: PageFormatLetter, + orientation: OrientationPortrait, + }, + { + name: "Letter Landscape", + format: PageFormatLetter, + orientation: OrientationLandscape, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := RenderConfig{ + PageFormat: tt.format, + Orientation: tt.orientation, + MarginTop: NewMarginInches(0.5), + MarginBottom: NewMarginInches(0.5), + MarginLeft: NewMarginInches(0.5), + MarginRight: NewMarginInches(0.5), + PrintBackground: true, + Scale: 1.0, + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + reader, err := converter.GeneratePDF(ctx, htmlContent, cfg) + require.NoError(t, err) + require.NotNil(t, reader) + + pdfContent, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Greater(t, len(pdfContent), 0) + assert.True(t, bytes.HasPrefix(pdfContent, []byte("%PDF-"))) + }) + } +} + +func TestGeneratePDF_WithDifferentMargins(t *testing.T) { + chromeAddr := os.Getenv("CHROME_WS_URL") + if chromeAddr == "" { + t.Skip("Skipping test: CHROME_WS_URL environment variable not set") + } + + converter := NewConverter(chromeAddr) + htmlContent := []byte("

Margin Test

") + + tests := []struct { + name string + marginTop Margin + marginBottom Margin + marginLeft Margin + marginRight Margin + }{ + { + name: "equal margins in inches", + marginTop: NewMarginInches(1.0), + marginBottom: NewMarginInches(1.0), + marginLeft: NewMarginInches(1.0), + marginRight: NewMarginInches(1.0), + }, + { + name: "mixed margin units", + marginTop: NewMarginInches(0.5), + marginBottom: NewMarginMillimeters(12.7), + marginLeft: NewMarginCentimeters(1.27), + marginRight: NewMarginPoints(36.0), + }, + { + name: "zero margins", + marginTop: NewMarginInches(0.0), + marginBottom: NewMarginInches(0.0), + marginLeft: NewMarginInches(0.0), + marginRight: NewMarginInches(0.0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := RenderConfig{ + PageFormat: PageFormatA4, + Orientation: OrientationPortrait, + MarginTop: tt.marginTop, + MarginBottom: tt.marginBottom, + MarginLeft: tt.marginLeft, + MarginRight: tt.marginRight, + PrintBackground: false, + Scale: 1.0, + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + reader, err := converter.GeneratePDF(ctx, htmlContent, cfg) + require.NoError(t, err) + require.NotNil(t, reader) + + pdfContent, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Greater(t, len(pdfContent), 0) + assert.True(t, bytes.HasPrefix(pdfContent, []byte("%PDF-"))) + }) + } +} + +func BenchmarkGetPageDimensions(b *testing.B) { + for i := 0; i < b.N; i++ { + getPageDimensions(PageFormatA4, OrientationPortrait) + } +} + +func BenchmarkNewConverter(b *testing.B) { + for i := 0; i < b.N; i++ { + NewConverter("ws://localhost:9222") + } +} diff --git a/pkg/html2pdf/margin.go b/pkg/html2pdf/margin.go new file mode 100644 index 000000000..7573cc2a7 --- /dev/null +++ b/pkg/html2pdf/margin.go @@ -0,0 +1,118 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package html2pdf + +import ( + "fmt" + "strconv" + "strings" +) + +type ( + MarginUnit string + + Margin struct { + Value float64 + Unit MarginUnit + } +) + +const ( + MarginUnitInch MarginUnit = "in" + MarginUnitMillimeter MarginUnit = "mm" + MarginUnitCentimeter MarginUnit = "cm" + MarginUnitPoint MarginUnit = "pt" +) + +// NewMargin creates a new margin with the specified value and unit +func NewMargin(value float64, unit MarginUnit) Margin { + return Margin{Value: value, Unit: unit} +} + +// NewMarginInches creates a new margin in inches +func NewMarginInches(value float64) Margin { + return Margin{Value: value, Unit: MarginUnitInch} +} + +// NewMarginMillimeters creates a new margin in millimeters +func NewMarginMillimeters(value float64) Margin { + return Margin{Value: value, Unit: MarginUnitMillimeter} +} + +// NewMarginCentimeters creates a new margin in centimeters +func NewMarginCentimeters(value float64) Margin { + return Margin{Value: value, Unit: MarginUnitCentimeter} +} + +// NewMarginPoints creates a new margin in points +func NewMarginPoints(value float64) Margin { + return Margin{Value: value, Unit: MarginUnitPoint} +} + +// ParseMargin parses a CSS margin string into a Margin +func ParseMargin(margin string) Margin { + if margin == "" { + return NewMarginInches(1.0) // Default 1 inch + } + + margin = strings.TrimSpace(margin) + + // Handle different units + if strings.HasSuffix(margin, "in") { + if val, err := strconv.ParseFloat(strings.TrimSuffix(margin, "in"), 64); err == nil { + return NewMarginInches(val) + } + } else if strings.HasSuffix(margin, "mm") { + if val, err := strconv.ParseFloat(strings.TrimSuffix(margin, "mm"), 64); err == nil { + return NewMarginMillimeters(val) + } + } else if strings.HasSuffix(margin, "cm") { + if val, err := strconv.ParseFloat(strings.TrimSuffix(margin, "cm"), 64); err == nil { + return NewMarginCentimeters(val) + } + } else if strings.HasSuffix(margin, "pt") { + if val, err := strconv.ParseFloat(strings.TrimSuffix(margin, "pt"), 64); err == nil { + return NewMarginPoints(val) + } + } else { + // Try to parse as plain number (assume inches) + if val, err := strconv.ParseFloat(margin, 64); err == nil { + return NewMarginInches(val) + } + } + + return NewMarginInches(1.0) // Default fallback +} + +// ToInches converts the margin to inches (required by Chrome DevTools Protocol) +func (m Margin) ToInches() float64 { + switch m.Unit { + case MarginUnitInch: + return m.Value + case MarginUnitMillimeter: + return m.Value / 25.4 + case MarginUnitCentimeter: + return m.Value / 2.54 + case MarginUnitPoint: + return m.Value / 72.0 + default: + return m.Value // Assume inches if unknown unit + } +} + +// String returns the margin as a CSS string +func (m Margin) String() string { + return fmt.Sprintf("%.2f%s", m.Value, string(m.Unit)) +} diff --git a/pkg/html2pdf/margin_test.go b/pkg/html2pdf/margin_test.go new file mode 100644 index 000000000..933253b6e --- /dev/null +++ b/pkg/html2pdf/margin_test.go @@ -0,0 +1,365 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package html2pdf + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewMargin(t *testing.T) { + tests := []struct { + name string + value float64 + unit MarginUnit + want Margin + }{ + { + name: "inches", + value: 1.5, + unit: MarginUnitInch, + want: Margin{Value: 1.5, Unit: MarginUnitInch}, + }, + { + name: "millimeters", + value: 25.4, + unit: MarginUnitMillimeter, + want: Margin{Value: 25.4, Unit: MarginUnitMillimeter}, + }, + { + name: "centimeters", + value: 2.54, + unit: MarginUnitCentimeter, + want: Margin{Value: 2.54, Unit: MarginUnitCentimeter}, + }, + { + name: "points", + value: 72.0, + unit: MarginUnitPoint, + want: Margin{Value: 72.0, Unit: MarginUnitPoint}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewMargin(tt.value, tt.unit) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNewMarginInches(t *testing.T) { + value := 2.0 + expected := Margin{Value: 2.0, Unit: MarginUnitInch} + got := NewMarginInches(value) + assert.Equal(t, expected, got) +} + +func TestNewMarginMillimeters(t *testing.T) { + value := 50.8 + expected := Margin{Value: 50.8, Unit: MarginUnitMillimeter} + got := NewMarginMillimeters(value) + assert.Equal(t, expected, got) +} + +func TestNewMarginCentimeters(t *testing.T) { + value := 5.08 + expected := Margin{Value: 5.08, Unit: MarginUnitCentimeter} + got := NewMarginCentimeters(value) + assert.Equal(t, expected, got) +} + +func TestNewMarginPoints(t *testing.T) { + value := 144.0 + expected := Margin{Value: 144.0, Unit: MarginUnitPoint} + got := NewMarginPoints(value) + assert.Equal(t, expected, got) +} + +func TestParseMargin(t *testing.T) { + tests := []struct { + name string + input string + want Margin + }{ + { + name: "empty string returns default", + input: "", + want: NewMarginInches(1.0), + }, + { + name: "inches with unit", + input: "2.5in", + want: NewMarginInches(2.5), + }, + { + name: "millimeters with unit", + input: "25.4mm", + want: NewMarginMillimeters(25.4), + }, + { + name: "centimeters with unit", + input: "2.54cm", + want: NewMarginCentimeters(2.54), + }, + { + name: "points with unit", + input: "72pt", + want: NewMarginPoints(72), + }, + { + name: "plain number assumes inches", + input: "1.5", + want: NewMarginInches(1.5), + }, + { + name: "whitespace is trimmed", + input: " 1.5in ", + want: NewMarginInches(1.5), + }, + { + name: "integer values", + input: "1in", + want: NewMarginInches(1.0), + }, + { + name: "zero values", + input: "0mm", + want: NewMarginMillimeters(0.0), + }, + { + name: "invalid input returns default", + input: "invalid", + want: NewMarginInches(1.0), + }, + { + name: "invalid number with valid unit returns default", + input: "invalidmm", + want: NewMarginInches(1.0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseMargin(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMargin_ToInches(t *testing.T) { + tests := []struct { + name string + margin Margin + want float64 + }{ + { + name: "inches to inches", + margin: NewMarginInches(2.0), + want: 2.0, + }, + { + name: "millimeters to inches", + margin: NewMarginMillimeters(25.4), + want: 1.0, + }, + { + name: "centimeters to inches", + margin: NewMarginCentimeters(2.54), + want: 1.0, + }, + { + name: "points to inches", + margin: NewMarginPoints(72.0), + want: 1.0, + }, + { + name: "fractional values", + margin: NewMarginMillimeters(12.7), // 0.5 inches + want: 0.5, + }, + { + name: "zero value", + margin: NewMarginInches(0.0), + want: 0.0, + }, + { + name: "unknown unit defaults to value", + margin: Margin{Value: 2.5, Unit: MarginUnit("unknown")}, + want: 2.5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.margin.ToInches() + assert.InDelta(t, tt.want, got, 0.0001) // Allow small floating point errors + }) + } +} + +func TestMargin_String(t *testing.T) { + tests := []struct { + name string + margin Margin + want string + }{ + { + name: "inches", + margin: NewMarginInches(1.5), + want: "1.50in", + }, + { + name: "millimeters", + margin: NewMarginMillimeters(25.4), + want: "25.40mm", + }, + { + name: "centimeters", + margin: NewMarginCentimeters(2.54), + want: "2.54cm", + }, + { + name: "points", + margin: NewMarginPoints(72.0), + want: "72.00pt", + }, + { + name: "zero value", + margin: NewMarginInches(0.0), + want: "0.00in", + }, + { + name: "integer value shows decimal", + margin: NewMarginInches(1.0), + want: "1.00in", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.margin.String() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMarginConstants(t *testing.T) { + // Test that the margin unit constants are properly defined + assert.Equal(t, MarginUnit("in"), MarginUnitInch) + assert.Equal(t, MarginUnit("mm"), MarginUnitMillimeter) + assert.Equal(t, MarginUnit("cm"), MarginUnitCentimeter) + assert.Equal(t, MarginUnit("pt"), MarginUnitPoint) +} + +func TestMarginConversions(t *testing.T) { + // Test conversion accuracy between units + tests := []struct { + name string + original Margin + expected float64 // expected inches + }{ + { + name: "1 inch = 25.4 mm", + original: NewMarginMillimeters(25.4), + expected: 1.0, + }, + { + name: "1 inch = 2.54 cm", + original: NewMarginCentimeters(2.54), + expected: 1.0, + }, + { + name: "1 inch = 72 points", + original: NewMarginPoints(72.0), + expected: 1.0, + }, + { + name: "0.5 inch = 12.7 mm", + original: NewMarginMillimeters(12.7), + expected: 0.5, + }, + { + name: "0.5 inch = 1.27 cm", + original: NewMarginCentimeters(1.27), + expected: 0.5, + }, + { + name: "0.5 inch = 36 points", + original: NewMarginPoints(36.0), + expected: 0.5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.original.ToInches() + assert.InDelta(t, tt.expected, got, 0.0001) + }) + } +} + +// Benchmark tests +func BenchmarkParseMargin(b *testing.B) { + testCases := []string{ + "1.5in", + "25.4mm", + "2.54cm", + "72pt", + "1.5", + "", + "invalid", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, tc := range testCases { + ParseMargin(tc) + } + } +} + +func BenchmarkMarginToInches(b *testing.B) { + margins := []Margin{ + NewMarginInches(1.5), + NewMarginMillimeters(25.4), + NewMarginCentimeters(2.54), + NewMarginPoints(72.0), + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, margin := range margins { + margin.ToInches() + } + } +} + +func BenchmarkMarginString(b *testing.B) { + margins := []Margin{ + NewMarginInches(1.5), + NewMarginMillimeters(25.4), + NewMarginCentimeters(2.54), + NewMarginPoints(72.0), + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, margin := range margins { + margin.String() + } + } +} diff --git a/pkg/html2pdf/orientation.go b/pkg/html2pdf/orientation.go new file mode 100644 index 000000000..82b4dc736 --- /dev/null +++ b/pkg/html2pdf/orientation.go @@ -0,0 +1,22 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package html2pdf + +type Orientation string + +const ( + OrientationPortrait Orientation = "portrait" + OrientationLandscape Orientation = "landscape" +) diff --git a/pkg/html2pdf/page_format.go b/pkg/html2pdf/page_format.go new file mode 100644 index 000000000..18c2c09d0 --- /dev/null +++ b/pkg/html2pdf/page_format.go @@ -0,0 +1,22 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package html2pdf + +type PageFormat string + +const ( + PageFormatA4 PageFormat = "A4" + PageFormatLetter PageFormat = "Letter" +)