Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added
- SVG vector graphics support — use SVGs in PDFs via the existing `c.Image()` API
- Auto-detected alongside JPEG/PNG; same fit-mode and alignment options apply
- Rendered as true PDF vector (Form XObject) — crisp at any scale, no rasterization
- Supported SVG elements: `<path>`, `<rect>` (incl. rounded corners), `<circle>`, `<ellipse>`, `<line>`, `<polyline>`, `<polygon>`, `<g>` groups
- Supported path commands: M/L/H/V/C/S/Q/T/A/Z (absolute and relative), arc-to-Bézier conversion
- Supported styling: `fill`, `stroke`, `stroke-width`, `opacity`, `fill-opacity`, `stroke-opacity`, inline `style` attribute
- Supported transforms: `translate`, `scale`, `rotate`, `matrix`, `skewX`, `skewY`
- Color formats: named colors, `#rrggbb`, `#rgb`, `rgb()`, `rgba()`
- Opacity via PDF ExtGState (`ca`/`CA`)
- `pdf.Writer.RegisterFormXObject()`: low-level Form XObject registration

### Fixed
- Multi-page table support — tables inside Row/Col now automatically split across pages
- `layoutHorizontal` propagates child overflow to the paginator
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![Go Reference](https://pkg.go.dev/badge/github.com/gpdf-dev/gpdf.svg)](https://pkg.go.dev/github.com/gpdf-dev/gpdf)
[![CI](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml/badge.svg)](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml)
![coverage](https://img.shields.io/badge/coverage-86.3%25-green)
![coverage](https://img.shields.io/badge/coverage-85.7%25-green)
[![Go Report Card](https://goreportcard.com/badge/github.com/gpdf-dev/gpdf)](https://goreportcard.com/report/github.com/gpdf-dev/gpdf)
[![Go Version](https://img.shields.io/badge/Go-%3E%3D1.22-blue)](https://go.dev/)
[![Website](https://img.shields.io/badge/Website-gpdf.dev-blue)](https://gpdf.dev/)
Expand Down
111 changes: 111 additions & 0 deletions _examples/builder/12_svg_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package builder_test

import (
"image/color"
"testing"

"github.com/gpdf-dev/gpdf/_examples/testutil"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)

func TestExample_12_SVG(t *testing.T) {
doc := template.New(
template.WithPageSize(document.A4),
template.WithMargins(document.UniformEdges(document.Mm(20))),
)

page := doc.AddPage()

page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("SVG Image Examples", template.FontSize(18), template.Bold())
c.Spacer(document.Mm(5))
})
})

// Basic filled rectangle SVG.
blueSVG := testutil.TestImageSVG(t, 200, 100, color.RGBA{R: 66, G: 133, B: 244, A: 255})
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("SVG rectangle (blue):")
c.Spacer(document.Mm(2))
c.Image(blueSVG, template.FitWidth(document.Mm(80)))
c.Spacer(document.Mm(5))
})
})

// SVG with circle and stroke.
circleSVG := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="#ea4335" stroke="#333333" stroke-width="3"/>
</svg>`)
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("SVG circle with stroke (red):")
c.Spacer(document.Mm(2))
c.Image(circleSVG, template.FitWidth(document.Mm(50)))
c.Spacer(document.Mm(5))
})
})

// SVG with path data (triangle).
triangleSVG := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M 50 5 L 95 95 L 5 95 Z" fill="#34a853" stroke="#1a5c2a" stroke-width="2"/>
</svg>`)
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("SVG path triangle (green):")
c.Spacer(document.Mm(2))
c.Image(triangleSVG, template.FitWidth(document.Mm(50)))
c.Spacer(document.Mm(5))
})
})

// SVG with multiple shapes and groups.
complexSVG := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80">
<rect x="0" y="0" width="200" height="80" fill="#f8f9fa"/>
<g transform="translate(10,10)">
<rect x="0" y="0" width="60" height="60" rx="8" ry="8" fill="#4285f4"/>
<rect x="70" y="0" width="60" height="60" rx="8" ry="8" fill="#ea4335"/>
<rect x="140" y="0" width="60" height="60" rx="8" ry="8" fill="#34a853"/>
</g>
</svg>`)
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("SVG group with rounded rectangles:")
c.Spacer(document.Mm(2))
c.Image(complexSVG, template.FitWidth(document.Mm(100)))
c.Spacer(document.Mm(5))
})
})

// SVG with XML declaration.
xmlSVG := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
<ellipse cx="50" cy="25" rx="45" ry="20" fill="#fbbc05"/>
</svg>`)
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("SVG with XML declaration (ellipse, yellow):")
c.Spacer(document.Mm(2))
c.Image(xmlSVG, template.FitWidth(document.Mm(80)))
c.Spacer(document.Mm(5))
})
})

// Two SVGs side by side (deduplication test: same SVG used twice).
page.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("Same SVG used twice (deduplication):")
c.Spacer(document.Mm(2))
c.Image(circleSVG, template.FitWidth(document.Mm(40)))
})
r.Col(6, func(c *template.ColBuilder) {
c.Text("(second reference)")
c.Spacer(document.Mm(2))
c.Image(circleSVG, template.FitWidth(document.Mm(40)))
})
})

testutil.GeneratePDFSharedGolden(t, "12_svg.pdf", doc)
}
Binary file added _examples/testdata/golden/12_svg.pdf
Binary file not shown.
19 changes: 19 additions & 0 deletions _examples/testutil/testutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package testutil

import (
"bytes"
"fmt"
"image"
"image/color"
"image/jpeg"
Expand Down Expand Up @@ -190,6 +191,24 @@ func WriteTestImageFile(t *testing.T, data []byte, name string) string {
return path
}

// TestImageSVG returns a simple SVG document containing a colored rectangle.
// The SVG has a viewBox of widthxheight and fills the viewport with c.
func TestImageSVG(t *testing.T, width, height int, c color.Color) []byte {
t.Helper()
r, g, b, _ := c.RGBA()
rf := float64(r>>8) / 255
gf := float64(g>>8) / 255
bf := float64(b>>8) / 255
svg := []byte(fmt.Sprintf(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d">`+
`<rect x="0" y="0" width="%d" height="%d" fill="rgb(%d,%d,%d)"/>`+
`</svg>`,
width, height, width, height,
int(rf*255), int(gf*255), int(bf*255),
))
return svg
}

// TestImageJPEG creates a small test JPEG image (colored rectangle).
func TestImageJPEG(t *testing.T, w, h int, c color.Color) []byte {
t.Helper()
Expand Down
2 changes: 2 additions & 0 deletions document/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const (
ImageJPEG ImageFormat = iota
// ImagePNG indicates PNG encoding.
ImagePNG
// ImageSVG indicates SVG vector graphics.
ImageSVG
)

// Image is a leaf document node that renders an image within the
Expand Down
68 changes: 67 additions & 1 deletion document/render/overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type OverlayResult struct {
FontObjects map[string]fontObject
// ImageObjects contains image objects that need to be written.
ImageObjects map[string]imageObject
// FormObjects contains Form XObject objects (e.g. SVGs) that need to be written.
FormObjects map[string]formObject
}

type fontObject struct {
Expand All @@ -39,6 +41,15 @@ type imageObject struct {
Filter string
}

type formObject struct {
ResName string
Content []byte
BBoxW float64
BBoxH float64
Matrix [6]float64
Resources pdf.Dict
}

// OverlayRenderer renders document nodes to a content stream byte slice,
// suitable for overlaying on an existing PDF page. Unlike PDFRenderer,
// it does not write to a pdf.Writer directly — instead it captures
Expand All @@ -56,6 +67,10 @@ type OverlayRenderer struct {
imageCount int
imageObjects map[string]imageObject

formMap map[string]string // hash -> resource name (Fm1, Fm2, ...)
formCount int
formObjects map[string]formObject

fontDataMap map[string][]byte
fonts map[string]*font.TrueTypeFont
}
Expand All @@ -71,6 +86,8 @@ func NewOverlayRenderer(pageWidth, pageHeight float64, fonts map[string]*font.Tr
fontObjects: make(map[string]fontObject),
imageMap: make(map[string]string),
imageObjects: make(map[string]imageObject),
formMap: make(map[string]string),
formObjects: make(map[string]formObject),
fontDataMap: fontDataMap,
fonts: fonts,
}
Expand Down Expand Up @@ -100,6 +117,7 @@ func (r *OverlayRenderer) RenderOverlay(nodes []layout.PlacedNode) (*OverlayResu
Resources: resources,
FontObjects: r.fontObjects,
ImageObjects: r.imageObjects,
FormObjects: r.formObjects,
}, nil
}

Expand Down Expand Up @@ -292,6 +310,26 @@ func (r *OverlayRenderer) ensureImage(key string, src document.ImageSource) stri
h = ph
}
colorSpace = "DeviceRGB"
case document.ImageSVG:
fc, svgErr := svgToFormContent(src.Data)
if svgErr == nil {
r.formCount++
fResName := fmt.Sprintf("OvFm%d", r.formCount)
r.formMap[key] = fResName
r.formObjects[key] = formObject{
ResName: fResName,
Content: fc.Content,
BBoxW: fc.ViewW,
BBoxH: fc.ViewH,
Matrix: [6]float64{1 / fc.ViewW, 0, 0, -1 / fc.ViewH, 0, 1},
Resources: fc.Resources,
}
r.imageMap[key] = fResName
return fResName
}
// Fall through to empty default on parse error.
r.imageObjects[key] = imageObject{ResName: resName}
return resName
default:
data = src.Data
w = src.Width
Expand Down Expand Up @@ -414,8 +452,9 @@ func WriteOverlayToModifier(result *OverlayResult, m *pdf.Modifier) ([]byte, *pd
}

// Register images.
if len(result.ImageObjects) > 0 {
if len(result.ImageObjects) > 0 || len(result.FormObjects) > 0 {
xobjDict := make(pdf.Dict)

for _, io := range result.ImageObjects {
var smaskRef pdf.ObjectRef
if len(io.SmaskData) > 0 {
Expand Down Expand Up @@ -472,6 +511,33 @@ func WriteOverlayToModifier(result *OverlayResult, m *pdf.Modifier) ([]byte, *pd

xobjDict[pdf.Name(io.ResName)] = imgRef
}

for _, fo := range result.FormObjects {
formRef := m.AllocObject()
formDict := pdf.Dict{
pdf.Name("Type"): pdf.Name("XObject"),
pdf.Name("Subtype"): pdf.Name("Form"),
pdf.Name("BBox"): pdf.Rectangle{
LLX: 0, LLY: 0, URX: fo.BBoxW, URY: fo.BBoxH,
},
pdf.Name("Matrix"): pdf.Array{
pdf.Real(fo.Matrix[0]), pdf.Real(fo.Matrix[1]),
pdf.Real(fo.Matrix[2]), pdf.Real(fo.Matrix[3]),
pdf.Real(fo.Matrix[4]), pdf.Real(fo.Matrix[5]),
},
}
if len(fo.Resources) > 0 {
formDict[pdf.Name("Resources")] = fo.Resources
}
compressed, err := pdf.CompressFlate(fo.Content)
if err != nil {
return nil, nil, fmt.Errorf("compress form XObject: %w", err)
}
formDict[pdf.Name("Filter")] = pdf.Name("FlateDecode")
m.SetObject(formRef, pdf.Stream{Dict: formDict, Content: compressed})
xobjDict[pdf.Name(fo.ResName)] = formRef
}

resources[pdf.Name("XObject")] = xobjDict
}

Expand Down
17 changes: 17 additions & 0 deletions document/render/pdftarget.go
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,23 @@ func (r *PDFRenderer) ensureImage(key string, src document.ImageSource) (string,
h = ph
colorSpace = "DeviceRGB"
filter = ""
case document.ImageSVG:
fc, svgErr := svgToFormContent(src.Data)
if svgErr != nil {
return "", fmt.Errorf("render: failed to convert SVG: %w", svgErr)
}
// The Form XObject uses a normalizing matrix [1/viewW 0 0 -1/viewH 0 1]
// so that it maps to the [0,1]×[0,1] unit square with Y-flipped, making
// it compatible with the existing RenderImage placement code.
bbox := pdf.Rectangle{LLX: 0, LLY: 0, URX: fc.ViewW, URY: fc.ViewH}
matrix := [6]float64{1 / fc.ViewW, 0, 0, -1 / fc.ViewH, 0, 1}
resName, ref, fErr := r.writer.RegisterFormXObject(key, fc.Content, bbox, matrix, fc.Resources)
if fErr != nil {
return "", fmt.Errorf("render: failed to register SVG form XObject: %w", fErr)
}
r.imageMap[key] = resName
r.imageRefs[key] = ref
return resName, nil
default:
data = src.Data
w = src.Width
Expand Down
Loading