Add html2pdf package

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-07-02 09:58:31 +02:00
parent 3697985eb4
commit a17b704934
6 changed files with 1103 additions and 0 deletions

163
pkg/html2pdf/converter.go Normal file
View File

@@ -0,0 +1,163 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,413 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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("<html><body><h1>Test</h1></body></html>")
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(`
<!DOCTYPE html>
<html>
<head>
<title>Test Document</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
h1 { color: #333; }
.content { background-color: #f0f0f0; padding: 10px; }
</style>
</head>
<body>
<h1>Test PDF Generation</h1>
<div class="content">
<p>This is a test document to verify PDF generation works correctly.</p>
<p>It includes some basic styling and multiple elements.</p>
</div>
</body>
</html>
`)
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("<html><body><h1>Scale Test</h1></body></html>")
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("<html><body><h1>Format Test</h1></body></html>")
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("<html><body><h1>Margin Test</h1></body></html>")
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")
}
}

118
pkg/html2pdf/margin.go Normal file
View File

@@ -0,0 +1,118 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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))
}

365
pkg/html2pdf/margin_test.go Normal file
View File

@@ -0,0 +1,365 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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()
}
}
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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"
)

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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"
)