163
pkg/html2pdf/converter.go
Normal file
163
pkg/html2pdf/converter.go
Normal 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
|
||||
}
|
||||
413
pkg/html2pdf/converter_test.go
Normal file
413
pkg/html2pdf/converter_test.go
Normal 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
118
pkg/html2pdf/margin.go
Normal 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
365
pkg/html2pdf/margin_test.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
22
pkg/html2pdf/orientation.go
Normal file
22
pkg/html2pdf/orientation.go
Normal 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"
|
||||
)
|
||||
22
pkg/html2pdf/page_format.go
Normal file
22
pkg/html2pdf/page_format.go
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user