diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a368a9..162c732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: ``, `` (incl. rounded corners), ``, ``, ``, ``, ``, `` 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 diff --git a/README.md b/README.md index 24a7295..691a99f 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/_examples/builder/12_svg_test.go b/_examples/builder/12_svg_test.go new file mode 100644 index 0000000..bc996b0 --- /dev/null +++ b/_examples/builder/12_svg_test.go @@ -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(` + + `) + 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(` + + `) + 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(` + + + + + + + `) + 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(` + + +`) + 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) +} diff --git a/_examples/testdata/golden/12_svg.pdf b/_examples/testdata/golden/12_svg.pdf new file mode 100644 index 0000000..5d5beb5 Binary files /dev/null and b/_examples/testdata/golden/12_svg.pdf differ diff --git a/_examples/testutil/testutil.go b/_examples/testutil/testutil.go index 5a6315c..cb9a167 100644 --- a/_examples/testutil/testutil.go +++ b/_examples/testutil/testutil.go @@ -3,6 +3,7 @@ package testutil import ( "bytes" + "fmt" "image" "image/color" "image/jpeg" @@ -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( + ``+ + ``+ + ``, + 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() diff --git a/document/image.go b/document/image.go index e8c82b2..9325319 100644 --- a/document/image.go +++ b/document/image.go @@ -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 diff --git a/document/render/overlay.go b/document/render/overlay.go index 7d76b9a..03b2bfb 100644 --- a/document/render/overlay.go +++ b/document/render/overlay.go @@ -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 { @@ -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 @@ -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 } @@ -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, } @@ -100,6 +117,7 @@ func (r *OverlayRenderer) RenderOverlay(nodes []layout.PlacedNode) (*OverlayResu Resources: resources, FontObjects: r.fontObjects, ImageObjects: r.imageObjects, + FormObjects: r.formObjects, }, nil } @@ -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 @@ -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 { @@ -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 } diff --git a/document/render/pdftarget.go b/document/render/pdftarget.go index e987e8e..4131520 100644 --- a/document/render/pdftarget.go +++ b/document/render/pdftarget.go @@ -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 diff --git a/document/render/svg.go b/document/render/svg.go new file mode 100644 index 0000000..c71f56d --- /dev/null +++ b/document/render/svg.go @@ -0,0 +1,1135 @@ +package render + +import ( + "bytes" + "encoding/xml" + "fmt" + "math" + "strconv" + "strings" + + "github.com/gpdf-dev/gpdf/pdf" +) + +// svgFormResult holds the output of converting an SVG to a PDF Form XObject. +type svgFormResult struct { + Content []byte // PDF content stream operators + ViewW float64 // SVG viewport width in user units (for BBox) + ViewH float64 // SVG viewport height in user units (for BBox) + Resources pdf.Dict // optional resources (e.g., ExtGState for opacity) +} + +// svgToFormContent converts SVG data to a PDF Form XObject content stream. +// The resulting Form XObject uses a normalizing matrix so that it maps to the +// [0,1]×[0,1] unit square (Y-flipped), making it compatible with the existing +// RenderImage placement code that uses [width 0 0 height x y cm]. +func svgToFormContent(data []byte) (*svgFormResult, error) { + root, err := parseSVGXML(data) + if err != nil { + return nil, fmt.Errorf("svg: parse error: %w", err) + } + if root == nil { + return nil, fmt.Errorf("svg: no root element found") + } + + viewW, viewH := svgViewBox(root.attrs) + if viewW <= 0 || viewH <= 0 { + viewW, viewH = 100, 100 + } + + var b svgBuilder + b.gsMap = make(map[[2]float64]string) + + for _, child := range root.children { + b.renderElem(child, svgDefaultStyle, viewW, viewH) + } + + resources := b.buildResources() + return &svgFormResult{ + Content: []byte(b.buf.String()), + ViewW: viewW, + ViewH: viewH, + Resources: resources, + }, nil +} + +// parseSVGDimensions returns the SVG viewport dimensions from the root element. +// Called from template/grid.go for setting ImageSource.Width/Height. +func parseSVGDimensions(data []byte) (float64, float64) { + dec := xml.NewDecoder(bytes.NewReader(data)) + for { + tok, err := dec.Token() + if err != nil { + break + } + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + if strings.ToLower(se.Name.Local) == "svg" { + attrs := xmlAttrsMap(se.Attr) + w, h := svgViewBox(attrs) + if w > 0 && h > 0 { + return w, h + } + } + break // only inspect the first element + } + return 100, 100 +} + +// svgViewBox extracts viewport dimensions from SVG root attributes. +func svgViewBox(attrs map[string]string) (w, h float64) { + if vb, ok := attrs["viewbox"]; ok { + parts := strings.Fields(strings.ReplaceAll(vb, ",", " ")) + if len(parts) == 4 { + vw, _ := strconv.ParseFloat(parts[2], 64) + vh, _ := strconv.ParseFloat(parts[3], 64) + if vw > 0 && vh > 0 { + return vw, vh + } + } + } + if ws, ok := attrs["width"]; ok { + if hs, ok2 := attrs["height"]; ok2 { + wv := parseSVGLength(ws) + hv := parseSVGLength(hs) + if wv > 0 && hv > 0 { + return wv, hv + } + } + } + return 0, 0 +} + +// ============================================================ +// XML parser +// ============================================================ + +type svgElem struct { + name string + attrs map[string]string + children []*svgElem +} + +func parseSVGXML(data []byte) (*svgElem, error) { + dec := xml.NewDecoder(bytes.NewReader(data)) + dec.Strict = false + var stack []*svgElem + var root *svgElem + + for { + tok, err := dec.Token() + if err != nil { + break + } + switch t := tok.(type) { + case xml.StartElement: + elem := &svgElem{ + name: strings.ToLower(t.Name.Local), + attrs: xmlAttrsMap(t.Attr), + } + if len(stack) == 0 { + root = elem + } else { + parent := stack[len(stack)-1] + parent.children = append(parent.children, elem) + } + stack = append(stack, elem) + case xml.EndElement: + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + } + } + return root, nil +} + +func xmlAttrsMap(attrs []xml.Attr) map[string]string { + m := make(map[string]string, len(attrs)) + for _, a := range attrs { + m[strings.ToLower(a.Name.Local)] = a.Value + } + return m +} + +// ============================================================ +// Style +// ============================================================ + +type svgColor struct { + R, G, B float64 + None bool // no paint (fill="none" or stroke="none") +} + +type svgPaint struct { + Color svgColor + Set bool +} + +type svgStyle struct { + Fill svgPaint + Stroke svgPaint + StrokeWidth float64 + Opacity float64 + FillOpacity float64 + StrokeOpacity float64 +} + +var svgDefaultStyle = svgStyle{ + Fill: svgPaint{Color: svgColor{0, 0, 0, false}, Set: true}, + Stroke: svgPaint{Color: svgColor{None: true}, Set: true}, + StrokeWidth: 1, + Opacity: 1, + FillOpacity: 1, + StrokeOpacity: 1, +} + +// parseElemStyle computes the effective style by merging parent with this element's attributes. +func parseElemStyle(attrs map[string]string, parent svgStyle) svgStyle { + s := parent + + apply := func(key, val string) { + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + switch key { + case "fill": + if c, ok := parseColor(val); ok { + s.Fill = svgPaint{Color: c, Set: true} + } + case "stroke": + if c, ok := parseColor(val); ok { + s.Stroke = svgPaint{Color: c, Set: true} + } + case "stroke-width": + if v, err := strconv.ParseFloat(val, 64); err == nil && v >= 0 { + s.StrokeWidth = v + } + case "opacity": + if v, err := strconv.ParseFloat(val, 64); err == nil { + s.Opacity = v + } + case "fill-opacity": + if v, err := strconv.ParseFloat(val, 64); err == nil { + s.FillOpacity = v + } + case "stroke-opacity": + if v, err := strconv.ParseFloat(val, 64); err == nil { + s.StrokeOpacity = v + } + } + } + + // Presentation attributes (lower specificity) + for k, v := range attrs { + apply(k, v) + } + + // Inline style attribute (higher specificity) + if styleStr, ok := attrs["style"]; ok { + for _, decl := range strings.Split(styleStr, ";") { + if idx := strings.IndexByte(decl, ':'); idx >= 0 { + apply(decl[:idx], decl[idx+1:]) + } + } + } + + return s +} + +// svgNamedColors covers the SVG/CSS named color subset. +var svgNamedColors = map[string]svgColor{ + "black": {0, 0, 0, false}, + "white": {1, 1, 1, false}, + "red": {1, 0, 0, false}, + "green": {0, 0.50196, 0, false}, + "blue": {0, 0, 1, false}, + "yellow": {1, 1, 0, false}, + "cyan": {0, 1, 1, false}, + "aqua": {0, 1, 1, false}, + "magenta": {1, 0, 1, false}, + "fuchsia": {1, 0, 1, false}, + "orange": {1, 0.64706, 0, false}, + "purple": {0.50196, 0, 0.50196, false}, + "pink": {1, 0.75294, 0.79608, false}, + "brown": {0.64706, 0.16471, 0.16471, false}, + "gray": {0.50196, 0.50196, 0.50196, false}, + "grey": {0.50196, 0.50196, 0.50196, false}, + "silver": {0.75294, 0.75294, 0.75294, false}, + "lime": {0, 1, 0, false}, + "navy": {0, 0, 0.50196, false}, + "teal": {0, 0.50196, 0.50196, false}, + "maroon": {0.50196, 0, 0, false}, + "olive": {0.50196, 0.50196, 0, false}, + "coral": {1, 0.49804, 0.31373, false}, + "salmon": {0.98039, 0.50196, 0.44706, false}, + "gold": {1, 0.84314, 0, false}, + "khaki": {0.94118, 0.90196, 0.54902, false}, + "violet": {0.93333, 0.50980, 0.93333, false}, + "indigo": {0.29412, 0, 0.50980, false}, + "crimson": {0.86275, 0.07843, 0.23529, false}, + "darkblue": {0, 0, 0.54510, false}, + "darkgreen": {0, 0.39216, 0, false}, + "darkred": {0.54510, 0, 0, false}, + "darkgray": {0.66275, 0.66275, 0.66275, false}, + "darkgrey": {0.66275, 0.66275, 0.66275, false}, + "lightblue": {0.67843, 0.84706, 0.90196, false}, + "lightgreen": {0.56471, 0.93333, 0.56471, false}, + "lightgray": {0.82745, 0.82745, 0.82745, false}, + "lightgrey": {0.82745, 0.82745, 0.82745, false}, + "transparent": {0, 0, 0, true}, + "none": {0, 0, 0, true}, +} + +func parseColor(s string) (svgColor, bool) { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return svgColor{}, false + } + if c, ok := svgNamedColors[s]; ok { + return c, true + } + if strings.HasPrefix(s, "#") { + hex := s[1:] + switch len(hex) { + case 6: + r, e1 := strconv.ParseInt(hex[0:2], 16, 32) + g, e2 := strconv.ParseInt(hex[2:4], 16, 32) + b, e3 := strconv.ParseInt(hex[4:6], 16, 32) + if e1 == nil && e2 == nil && e3 == nil { + return svgColor{float64(r) / 255, float64(g) / 255, float64(b) / 255, false}, true + } + case 3: + r, e1 := strconv.ParseInt(string([]byte{hex[0], hex[0]}), 16, 32) + g, e2 := strconv.ParseInt(string([]byte{hex[1], hex[1]}), 16, 32) + b, e3 := strconv.ParseInt(string([]byte{hex[2], hex[2]}), 16, 32) + if e1 == nil && e2 == nil && e3 == nil { + return svgColor{float64(r) / 255, float64(g) / 255, float64(b) / 255, false}, true + } + } + } + if strings.HasPrefix(s, "rgb(") && strings.HasSuffix(s, ")") { + inner := s[4 : len(s)-1] + parts := strings.Split(inner, ",") + if len(parts) == 3 { + r := parseColorComponent(strings.TrimSpace(parts[0])) + g := parseColorComponent(strings.TrimSpace(parts[1])) + b := parseColorComponent(strings.TrimSpace(parts[2])) + return svgColor{r, g, b, false}, true + } + } + if strings.HasPrefix(s, "rgba(") && strings.HasSuffix(s, ")") { + inner := s[5 : len(s)-1] + parts := strings.Split(inner, ",") + if len(parts) >= 3 { + r := parseColorComponent(strings.TrimSpace(parts[0])) + g := parseColorComponent(strings.TrimSpace(parts[1])) + b := parseColorComponent(strings.TrimSpace(parts[2])) + return svgColor{r, g, b, false}, true + } + } + return svgColor{}, false +} + +func parseColorComponent(s string) float64 { + if strings.HasSuffix(s, "%") { + v, _ := strconv.ParseFloat(s[:len(s)-1], 64) + return math.Max(0, math.Min(1, v/100)) + } + v, _ := strconv.ParseFloat(s, 64) + return math.Max(0, math.Min(1, v/255)) +} + +// ============================================================ +// Transforms +// ============================================================ + +// matrix6 is a 2D affine transformation in PDF convention: [a b c d e f]. +// Applied to point (x,y): x' = a*x + c*y + e, y' = b*x + d*y + f. +type matrix6 [6]float64 + +func identMatrix() matrix6 { return matrix6{1, 0, 0, 1, 0, 0} } + +// multiplyMatrix computes a * b (apply a first, then b). +func multiplyMatrix(a, b matrix6) matrix6 { + return matrix6{ + a[0]*b[0] + a[2]*b[1], + a[1]*b[0] + a[3]*b[1], + a[0]*b[2] + a[2]*b[3], + a[1]*b[2] + a[3]*b[3], + a[0]*b[4] + a[2]*b[5] + a[4], + a[1]*b[4] + a[3]*b[5] + a[5], + } +} + +// parseTransform parses an SVG transform attribute string into a combined matrix. +func parseTransform(s string) matrix6 { + result := identMatrix() + for s != "" { + paren := strings.IndexByte(s, '(') + if paren < 0 { + break + } + close := strings.IndexByte(s[paren:], ')') + if close < 0 { + break + } + fnName := strings.ToLower(strings.TrimSpace(s[:paren])) + args := parseNumberList(s[paren+1 : paren+close]) + s = strings.TrimSpace(s[paren+close+1:]) + + var m matrix6 + switch fnName { + case "translate": + tx, ty := 0.0, 0.0 + if len(args) >= 1 { + tx = args[0] + } + if len(args) >= 2 { + ty = args[1] + } + m = matrix6{1, 0, 0, 1, tx, ty} + case "scale": + sx, sy := 1.0, 1.0 + if len(args) >= 1 { + sx = args[0] + sy = sx + } + if len(args) >= 2 { + sy = args[1] + } + m = matrix6{sx, 0, 0, sy, 0, 0} + case "rotate": + angle := 0.0 + if len(args) >= 1 { + angle = args[0] + } + a := angle * math.Pi / 180 + cosA, sinA := math.Cos(a), math.Sin(a) + if len(args) == 3 { + cx, cy := args[1], args[2] + m = matrix6{cosA, sinA, -sinA, cosA, + cx - cx*cosA + cy*sinA, + cy - cx*sinA - cy*cosA} + } else { + m = matrix6{cosA, sinA, -sinA, cosA, 0, 0} + } + case "skewx": + if len(args) >= 1 { + m = matrix6{1, 0, math.Tan(args[0] * math.Pi / 180), 1, 0, 0} + } else { + m = identMatrix() + } + case "skewy": + if len(args) >= 1 { + m = matrix6{1, math.Tan(args[0] * math.Pi / 180), 0, 1, 0, 0} + } else { + m = identMatrix() + } + case "matrix": + if len(args) == 6 { + m = matrix6{args[0], args[1], args[2], args[3], args[4], args[5]} + } else { + m = identMatrix() + } + default: + m = identMatrix() + } + result = multiplyMatrix(result, m) + } + return result +} + +func parseNumberList(s string) []float64 { + s = strings.ReplaceAll(s, ",", " ") + parts := strings.Fields(s) + nums := make([]float64, 0, len(parts)) + for _, p := range parts { + if v, err := strconv.ParseFloat(p, 64); err == nil { + nums = append(nums, v) + } + } + return nums +} + +// ============================================================ +// SVG length parsing +// ============================================================ + +func parseSVGLength(s string) float64 { + s = strings.TrimSpace(s) + units := []struct { + suffix string + factor float64 + }{ + {"px", 1}, + {"pt", 4.0 / 3}, // 1pt = 4/3 px at 96dpi + {"mm", 96.0 / 25.4}, + {"cm", 96.0 / 2.54}, + {"in", 96}, + {"rem", 16}, + {"em", 16}, + } + for _, u := range units { + if strings.HasSuffix(s, u.suffix) { + v, err := strconv.ParseFloat(strings.TrimSuffix(s, u.suffix), 64) + if err == nil { + return v * u.factor + } + } + } + v, _ := strconv.ParseFloat(s, 64) + return v +} + +// ============================================================ +// PDF content builder +// ============================================================ + +type svgBuilder struct { + buf strings.Builder + gsMap map[[2]float64]string // [fillAlpha, strokeAlpha] -> gs name + gsCount int +} + +// buildResources returns a pdf.Dict for the ExtGState resources used in the form. +func (b *svgBuilder) buildResources() pdf.Dict { + if len(b.gsMap) == 0 { + return nil + } + gsDict := make(pdf.Dict, len(b.gsMap)) + for key, name := range b.gsMap { + entry := pdf.Dict{} + if key[0] < 1 { + entry[pdf.Name("ca")] = pdf.Real(key[0]) + } + if key[1] < 1 { + entry[pdf.Name("CA")] = pdf.Real(key[1]) + } + gsDict[pdf.Name(name)] = entry + } + return pdf.Dict{pdf.Name("ExtGState"): gsDict} +} + +func (b *svgBuilder) renderElem(elem *svgElem, parent svgStyle, viewW, viewH float64) { + // Skip non-rendering elements. + switch elem.name { + case "defs", "title", "desc", "metadata", "style", + "lineargradient", "radialgradient", "pattern", + "filter", "mask", "symbol", "clippath": + return + } + + style := parseElemStyle(elem.attrs, parent) + + var hasTransform bool + if t, ok := elem.attrs["transform"]; ok && t != "" { + m := parseTransform(t) + b.buf.WriteString("q\n") + fmt.Fprintf(&b.buf, "%g %g %g %g %g %g cm\n", m[0], m[1], m[2], m[3], m[4], m[5]) + hasTransform = true + } + + switch elem.name { + case "g", "a": + for _, child := range elem.children { + b.renderElem(child, style, viewW, viewH) + } + case "use": + // references are complex; skip for now. + case "path": + b.renderPath(elem, style) + case "rect": + b.renderRect(elem, style) + case "circle": + b.renderCircle(elem, style) + case "ellipse": + b.renderEllipse(elem, style) + case "line": + b.renderLine(elem, style) + case "polyline": + b.renderPolyPoints(elem, style, false) + case "polygon": + b.renderPolyPoints(elem, style, true) + } + + if hasTransform { + b.buf.WriteString("Q\n") + } +} + +// emitFillStroke emits color, ExtGState (if needed), and the paint operator. +func (b *svgBuilder) emitFillStroke(s svgStyle) { + fillNone := !s.Fill.Set || s.Fill.Color.None + strokeNone := !s.Stroke.Set || s.Stroke.Color.None + + fillAlpha := s.Opacity * s.FillOpacity + strokeAlpha := s.Opacity * s.StrokeOpacity + + // Emit ExtGState for opacity < 1. + if fillAlpha < 1 || strokeAlpha < 1 { + key := [2]float64{fillAlpha, strokeAlpha} + if _, ok := b.gsMap[key]; !ok { + b.gsCount++ + b.gsMap[key] = fmt.Sprintf("gs%d", b.gsCount) + } + fmt.Fprintf(&b.buf, "/%s gs\n", b.gsMap[key]) + } + + if !fillNone { + c := s.Fill.Color + fmt.Fprintf(&b.buf, "%g %g %g rg\n", c.R, c.G, c.B) + } + if !strokeNone { + c := s.Stroke.Color + fmt.Fprintf(&b.buf, "%g %g %g RG\n", c.R, c.G, c.B) + sw := s.StrokeWidth + if sw <= 0 { + sw = 1 + } + fmt.Fprintf(&b.buf, "%g w\n", sw) + } + + switch { + case !fillNone && !strokeNone: + b.buf.WriteString("B\n") + case !fillNone: + b.buf.WriteString("f\n") + case !strokeNone: + b.buf.WriteString("S\n") + default: + b.buf.WriteString("n\n") + } +} + +func (b *svgBuilder) renderPath(elem *svgElem, style svgStyle) { + d := elem.attrs["d"] + if d == "" { + return + } + b.buf.WriteString("q\n") + convertPathData(d, &b.buf) + b.emitFillStroke(style) + b.buf.WriteString("Q\n") +} + +func (b *svgBuilder) renderRect(elem *svgElem, style svgStyle) { + attrs := elem.attrs + x := attrFloat(attrs, "x", 0) + y := attrFloat(attrs, "y", 0) + w := attrFloat(attrs, "width", 0) + h := attrFloat(attrs, "height", 0) + if w <= 0 || h <= 0 { + return + } + rx := attrFloat(attrs, "rx", -1) + ry := attrFloat(attrs, "ry", -1) + + // Resolve rx/ry defaults per SVG spec. + if rx < 0 && ry < 0 { + rx, ry = 0, 0 + } else if rx < 0 { + rx = ry + } else if ry < 0 { + ry = rx + } + rx = math.Min(rx, w/2) + ry = math.Min(ry, h/2) + + b.buf.WriteString("q\n") + if rx == 0 && ry == 0 { + fmt.Fprintf(&b.buf, "%g %g %g %g re\n", x, y, w, h) + } else { + // Rounded rectangle via cubic Bézier curves. + // κ ≈ 0.5523 gives best circular approximation. + const kappa = 0.5523 + // Start at top-left corner, after the left arc. + fmt.Fprintf(&b.buf, "%g %g m\n", x+rx, y) + fmt.Fprintf(&b.buf, "%g %g l\n", x+w-rx, y) + fmt.Fprintf(&b.buf, "%g %g %g %g %g %g c\n", + x+w-rx+kappa*rx, y, x+w, y+kappa*ry, x+w, y+ry) + fmt.Fprintf(&b.buf, "%g %g l\n", x+w, y+h-ry) + fmt.Fprintf(&b.buf, "%g %g %g %g %g %g c\n", + x+w, y+h-ry+kappa*ry, x+w-rx+kappa*rx, y+h, x+w-rx, y+h) + fmt.Fprintf(&b.buf, "%g %g l\n", x+rx, y+h) + fmt.Fprintf(&b.buf, "%g %g %g %g %g %g c\n", + x+rx-kappa*rx, y+h, x, y+h-ry+kappa*ry, x, y+h-ry) + fmt.Fprintf(&b.buf, "%g %g l\n", x, y+ry) + fmt.Fprintf(&b.buf, "%g %g %g %g %g %g c\n", + x, y+ry-kappa*ry, x+rx-kappa*rx, y, x+rx, y) + b.buf.WriteString("h\n") + } + b.emitFillStroke(style) + b.buf.WriteString("Q\n") +} + +func (b *svgBuilder) renderCircle(elem *svgElem, style svgStyle) { + cx := attrFloat(elem.attrs, "cx", 0) + cy := attrFloat(elem.attrs, "cy", 0) + r := attrFloat(elem.attrs, "r", 0) + if r <= 0 { + return + } + b.renderEllipseShape(cx, cy, r, r, style) +} + +func (b *svgBuilder) renderEllipse(elem *svgElem, style svgStyle) { + cx := attrFloat(elem.attrs, "cx", 0) + cy := attrFloat(elem.attrs, "cy", 0) + rx := attrFloat(elem.attrs, "rx", 0) + ry := attrFloat(elem.attrs, "ry", 0) + if rx <= 0 || ry <= 0 { + return + } + b.renderEllipseShape(cx, cy, rx, ry, style) +} + +func (b *svgBuilder) renderEllipseShape(cx, cy, rx, ry float64, style svgStyle) { + const kappa = 0.5523 + b.buf.WriteString("q\n") + fmt.Fprintf(&b.buf, "%g %g m\n", cx+rx, cy) + fmt.Fprintf(&b.buf, "%g %g %g %g %g %g c\n", + cx+rx, cy+kappa*ry, cx+kappa*rx, cy+ry, cx, cy+ry) + fmt.Fprintf(&b.buf, "%g %g %g %g %g %g c\n", + cx-kappa*rx, cy+ry, cx-rx, cy+kappa*ry, cx-rx, cy) + fmt.Fprintf(&b.buf, "%g %g %g %g %g %g c\n", + cx-rx, cy-kappa*ry, cx-kappa*rx, cy-ry, cx, cy-ry) + fmt.Fprintf(&b.buf, "%g %g %g %g %g %g c\n", + cx+kappa*rx, cy-ry, cx+rx, cy-kappa*ry, cx+rx, cy) + b.buf.WriteString("h\n") + b.emitFillStroke(style) + b.buf.WriteString("Q\n") +} + +func (b *svgBuilder) renderLine(elem *svgElem, style svgStyle) { + x1 := attrFloat(elem.attrs, "x1", 0) + y1 := attrFloat(elem.attrs, "y1", 0) + x2 := attrFloat(elem.attrs, "x2", 0) + y2 := attrFloat(elem.attrs, "y2", 0) + b.buf.WriteString("q\n") + fmt.Fprintf(&b.buf, "%g %g m\n", x1, y1) + fmt.Fprintf(&b.buf, "%g %g l\n", x2, y2) + // Lines have no fill. + ls := style + ls.Fill = svgPaint{Color: svgColor{None: true}, Set: true} + b.emitFillStroke(ls) + b.buf.WriteString("Q\n") +} + +func (b *svgBuilder) renderPolyPoints(elem *svgElem, style svgStyle, close bool) { + pts := parsePointsList(elem.attrs["points"]) + if len(pts) < 4 { + return + } + b.buf.WriteString("q\n") + fmt.Fprintf(&b.buf, "%g %g m\n", pts[0], pts[1]) + for i := 2; i+1 < len(pts); i += 2 { + fmt.Fprintf(&b.buf, "%g %g l\n", pts[i], pts[i+1]) + } + if close { + b.buf.WriteString("h\n") + b.emitFillStroke(style) + } else { + ls := style + ls.Fill = svgPaint{Color: svgColor{None: true}, Set: true} + b.emitFillStroke(ls) + } + b.buf.WriteString("Q\n") +} + +func parsePointsList(s string) []float64 { + s = strings.ReplaceAll(s, ",", " ") + parts := strings.Fields(s) + nums := make([]float64, 0, len(parts)) + for _, p := range parts { + if v, err := strconv.ParseFloat(p, 64); err == nil { + nums = append(nums, v) + } + } + return nums +} + +func attrFloat(attrs map[string]string, key string, def float64) float64 { + if s, ok := attrs[key]; ok { + if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil { + return v + } + } + return def +} + +// ============================================================ +// SVG path data → PDF path operators +// ============================================================ + +// convertPathData parses an SVG path data string and writes the equivalent +// PDF path construction operators to buf. +func convertPathData(d string, buf *strings.Builder) { + p := &pathParser{data: d, buf: buf} + p.parse() +} + +type pathParser struct { + data string + pos int + buf *strings.Builder + curX float64 + curY float64 + startX float64 + startY float64 + lastCPX float64 // last (smooth) control point X + lastCPY float64 // last (smooth) control point Y +} + +func (p *pathParser) parse() { + var cmd byte + for { + p.skipSep() + if p.pos >= len(p.data) { + break + } + c := p.data[p.pos] + if isPathLetter(c) { + cmd = c + p.pos++ + p.skipSep() + } + if cmd == 0 { + break + } + if cmd == 'Z' || cmd == 'z' { + p.buf.WriteString("h\n") + p.curX, p.curY = p.startX, p.startY + p.lastCPX, p.lastCPY = p.curX, p.curY + cmd = 0 + continue + } + if p.pos >= len(p.data) || isPathLetter(p.data[p.pos]) { + continue + } + p.execOne(cmd) + // After M/m, implicit repetitions use L/l. + if cmd == 'M' { + cmd = 'L' + } else if cmd == 'm' { + cmd = 'l' + } + } +} + +func (p *pathParser) execOne(cmd byte) { + switch cmd { + case 'M': + x, y := p.readXY() + p.curX, p.curY = x, y + p.startX, p.startY = x, y + p.lastCPX, p.lastCPY = x, y + fmt.Fprintf(p.buf, "%g %g m\n", x, y) + case 'm': + x, y := p.readXY() + p.curX += x + p.curY += y + p.startX, p.startY = p.curX, p.curY + p.lastCPX, p.lastCPY = p.curX, p.curY + fmt.Fprintf(p.buf, "%g %g m\n", p.curX, p.curY) + case 'L': + x, y := p.readXY() + p.curX, p.curY = x, y + p.lastCPX, p.lastCPY = x, y + fmt.Fprintf(p.buf, "%g %g l\n", x, y) + case 'l': + x, y := p.readXY() + p.curX += x + p.curY += y + p.lastCPX, p.lastCPY = p.curX, p.curY + fmt.Fprintf(p.buf, "%g %g l\n", p.curX, p.curY) + case 'H': + x := p.readFloat() + p.curX = x + p.lastCPX = x + fmt.Fprintf(p.buf, "%g %g l\n", x, p.curY) + case 'h': + x := p.readFloat() + p.curX += x + p.lastCPX = p.curX + fmt.Fprintf(p.buf, "%g %g l\n", p.curX, p.curY) + case 'V': + y := p.readFloat() + p.curY = y + p.lastCPY = y + fmt.Fprintf(p.buf, "%g %g l\n", p.curX, y) + case 'v': + y := p.readFloat() + p.curY += y + p.lastCPY = p.curY + fmt.Fprintf(p.buf, "%g %g l\n", p.curX, p.curY) + case 'C': + x1, y1 := p.readXY() + x2, y2 := p.readXY() + x, y := p.readXY() + fmt.Fprintf(p.buf, "%g %g %g %g %g %g c\n", x1, y1, x2, y2, x, y) + p.lastCPX, p.lastCPY = x2, y2 + p.curX, p.curY = x, y + case 'c': + x1, y1 := p.readXY() + x2, y2 := p.readXY() + dx, dy := p.readXY() + ax1, ay1 := p.curX+x1, p.curY+y1 + ax2, ay2 := p.curX+x2, p.curY+y2 + ax, ay := p.curX+dx, p.curY+dy + fmt.Fprintf(p.buf, "%g %g %g %g %g %g c\n", ax1, ay1, ax2, ay2, ax, ay) + p.lastCPX, p.lastCPY = ax2, ay2 + p.curX, p.curY = ax, ay + case 'S': + x2, y2 := p.readXY() + x, y := p.readXY() + x1 := 2*p.curX - p.lastCPX + y1 := 2*p.curY - p.lastCPY + fmt.Fprintf(p.buf, "%g %g %g %g %g %g c\n", x1, y1, x2, y2, x, y) + p.lastCPX, p.lastCPY = x2, y2 + p.curX, p.curY = x, y + case 's': + x2, y2 := p.readXY() + dx, dy := p.readXY() + ax2, ay2 := p.curX+x2, p.curY+y2 + ax, ay := p.curX+dx, p.curY+dy + x1 := 2*p.curX - p.lastCPX + y1 := 2*p.curY - p.lastCPY + fmt.Fprintf(p.buf, "%g %g %g %g %g %g c\n", x1, y1, ax2, ay2, ax, ay) + p.lastCPX, p.lastCPY = ax2, ay2 + p.curX, p.curY = ax, ay + case 'Q': + qx1, qy1 := p.readXY() + x, y := p.readXY() + cp1x := p.curX + 2.0/3*(qx1-p.curX) + cp1y := p.curY + 2.0/3*(qy1-p.curY) + cp2x := x + 2.0/3*(qx1-x) + cp2y := y + 2.0/3*(qy1-y) + fmt.Fprintf(p.buf, "%g %g %g %g %g %g c\n", cp1x, cp1y, cp2x, cp2y, x, y) + p.lastCPX, p.lastCPY = qx1, qy1 + p.curX, p.curY = x, y + case 'q': + qx1, qy1 := p.readXY() + dx, dy := p.readXY() + aqx1, aqy1 := p.curX+qx1, p.curY+qy1 + ax, ay := p.curX+dx, p.curY+dy + cp1x := p.curX + 2.0/3*(aqx1-p.curX) + cp1y := p.curY + 2.0/3*(aqy1-p.curY) + cp2x := ax + 2.0/3*(aqx1-ax) + cp2y := ay + 2.0/3*(aqy1-ay) + fmt.Fprintf(p.buf, "%g %g %g %g %g %g c\n", cp1x, cp1y, cp2x, cp2y, ax, ay) + p.lastCPX, p.lastCPY = aqx1, aqy1 + p.curX, p.curY = ax, ay + case 'T': + x, y := p.readXY() + qx1 := 2*p.curX - p.lastCPX + qy1 := 2*p.curY - p.lastCPY + cp1x := p.curX + 2.0/3*(qx1-p.curX) + cp1y := p.curY + 2.0/3*(qy1-p.curY) + cp2x := x + 2.0/3*(qx1-x) + cp2y := y + 2.0/3*(qy1-y) + fmt.Fprintf(p.buf, "%g %g %g %g %g %g c\n", cp1x, cp1y, cp2x, cp2y, x, y) + p.lastCPX, p.lastCPY = qx1, qy1 + p.curX, p.curY = x, y + case 't': + dx, dy := p.readXY() + ax, ay := p.curX+dx, p.curY+dy + qx1 := 2*p.curX - p.lastCPX + qy1 := 2*p.curY - p.lastCPY + cp1x := p.curX + 2.0/3*(qx1-p.curX) + cp1y := p.curY + 2.0/3*(qy1-p.curY) + cp2x := ax + 2.0/3*(qx1-ax) + cp2y := ay + 2.0/3*(qy1-ay) + fmt.Fprintf(p.buf, "%g %g %g %g %g %g c\n", cp1x, cp1y, cp2x, cp2y, ax, ay) + p.lastCPX, p.lastCPY = qx1, qy1 + p.curX, p.curY = ax, ay + case 'A', 'a': + rx := math.Abs(p.readFloat()) + ry := math.Abs(p.readFloat()) + xRot := p.readFloat() + largeArc := p.readFloat() != 0 + sweep := p.readFloat() != 0 + x, y := p.readXY() + if cmd == 'a' { + x += p.curX + y += p.curY + } + arcToBezier(p.curX, p.curY, rx, ry, xRot, largeArc, sweep, x, y, p.buf) + p.lastCPX, p.lastCPY = x, y + p.curX, p.curY = x, y + } +} + +func (p *pathParser) skipSep() { + for p.pos < len(p.data) { + c := p.data[p.pos] + if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',' { + p.pos++ + } else { + break + } + } +} + +func (p *pathParser) readFloat() float64 { + p.skipSep() + if p.pos >= len(p.data) { + return 0 + } + start := p.pos + c := p.data[p.pos] + if c == '+' || c == '-' { + p.pos++ + } + for p.pos < len(p.data) { + c = p.data[p.pos] + if (c >= '0' && c <= '9') || c == '.' { + p.pos++ + } else { + break + } + } + // Scientific notation + if p.pos < len(p.data) && (p.data[p.pos] == 'e' || p.data[p.pos] == 'E') { + p.pos++ + if p.pos < len(p.data) && (p.data[p.pos] == '+' || p.data[p.pos] == '-') { + p.pos++ + } + for p.pos < len(p.data) && p.data[p.pos] >= '0' && p.data[p.pos] <= '9' { + p.pos++ + } + } + v, _ := strconv.ParseFloat(p.data[start:p.pos], 64) + return v +} + +func (p *pathParser) readXY() (float64, float64) { + x := p.readFloat() + y := p.readFloat() + return x, y +} + +func isPathLetter(c byte) bool { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') +} + +// ============================================================ +// Arc to cubic Bézier conversion +// ============================================================ + +// arcToBezier converts an SVG elliptical arc to cubic Bézier curves. +// Based on the SVG specification's endpoint-to-center parameterization. +func arcToBezier(x1, y1, rx, ry, phi float64, largeArc, sweep bool, x2, y2 float64, buf *strings.Builder) { + if rx == 0 || ry == 0 { + fmt.Fprintf(buf, "%g %g l\n", x2, y2) + return + } + if x1 == x2 && y1 == y2 { + return + } + + phiRad := phi * math.Pi / 180 + cosPhi := math.Cos(phiRad) + sinPhi := math.Sin(phiRad) + + // Step 1: midpoint. + dx := (x1 - x2) / 2 + dy := (y1 - y2) / 2 + x1p := cosPhi*dx + sinPhi*dy + y1p := -sinPhi*dx + cosPhi*dy + + // Ensure radii are large enough. + rx2, ry2 := rx*rx, ry*ry + x1p2, y1p2 := x1p*x1p, y1p*y1p + if lambda := x1p2/rx2 + y1p2/ry2; lambda > 1 { + sqrtL := math.Sqrt(lambda) + rx *= sqrtL + ry *= sqrtL + rx2 = rx * rx + ry2 = ry * ry + } + + // Step 2: center in rotated frame. + num := rx2*ry2 - rx2*y1p2 - ry2*x1p2 + den := rx2*y1p2 + ry2*x1p2 + var sq float64 + if den > 0 { + sq = math.Sqrt(math.Max(0, num/den)) + } + if largeArc == sweep { + sq = -sq + } + cxp := sq * rx * y1p / ry + cyp := -sq * ry * x1p / rx + + // Step 3: actual center. + cx := cosPhi*cxp - sinPhi*cyp + (x1+x2)/2 + cy := sinPhi*cxp + cosPhi*cyp + (y1+y2)/2 + + // Step 4: angles. + ux := (x1p - cxp) / rx + uy := (y1p - cyp) / ry + vx := (-x1p - cxp) / rx + vy := (-y1p - cyp) / ry + + theta1 := svgVecAngle(1, 0, ux, uy) + dTheta := svgVecAngle(ux, uy, vx, vy) + if !sweep && dTheta > 0 { + dTheta -= 2 * math.Pi + } else if sweep && dTheta < 0 { + dTheta += 2 * math.Pi + } + + // Split into ≤90° segments. + nSegs := int(math.Ceil(math.Abs(dTheta) / (math.Pi / 2))) + if nSegs < 1 { + nSegs = 1 + } + dSeg := dTheta / float64(nSegs) + for i := 0; i < nSegs; i++ { + t1 := theta1 + float64(i)*dSeg + t2 := t1 + dSeg + arcSegToBezier(cx, cy, rx, ry, phiRad, t1, t2, buf) + } +} + +func arcSegToBezier(cx, cy, rx, ry, phi, t1, t2 float64, buf *strings.Builder) { + halfDt := (t2 - t1) / 2 + alpha := math.Sin(t2-t1) * (math.Sqrt(4+3*math.Tan(halfDt)*math.Tan(halfDt)) - 1) / 3 + + cosPhi, sinPhi := math.Cos(phi), math.Sin(phi) + cosT1, sinT1 := math.Cos(t1), math.Sin(t1) + cosT2, sinT2 := math.Cos(t2), math.Sin(t2) + + p1x := cx + cosPhi*rx*cosT1 - sinPhi*ry*sinT1 + p1y := cy + sinPhi*rx*cosT1 + cosPhi*ry*sinT1 + p2x := cx + cosPhi*rx*cosT2 - sinPhi*ry*sinT2 + p2y := cy + sinPhi*rx*cosT2 + cosPhi*ry*sinT2 + + dx1 := -cosPhi*rx*sinT1 - sinPhi*ry*cosT1 + dy1 := -sinPhi*rx*sinT1 + cosPhi*ry*cosT1 + dx2 := -cosPhi*rx*sinT2 - sinPhi*ry*cosT2 + dy2 := -sinPhi*rx*sinT2 + cosPhi*ry*cosT2 + + cp1x := p1x + alpha*dx1 + cp1y := p1y + alpha*dy1 + cp2x := p2x - alpha*dx2 + cp2y := p2y - alpha*dy2 + + fmt.Fprintf(buf, "%g %g %g %g %g %g c\n", cp1x, cp1y, cp2x, cp2y, p2x, p2y) +} + +func svgVecAngle(ux, uy, vx, vy float64) float64 { + dot := ux*vx + uy*vy + lenU := math.Sqrt(ux*ux + uy*uy) + lenV := math.Sqrt(vx*vx + vy*vy) + if lenU == 0 || lenV == 0 { + return 0 + } + cosA := math.Max(-1, math.Min(1, dot/(lenU*lenV))) + angle := math.Acos(cosA) + if ux*vy-uy*vx < 0 { + angle = -angle + } + return angle +} diff --git a/document/render/svg_test.go b/document/render/svg_test.go new file mode 100644 index 0000000..e75a9e7 --- /dev/null +++ b/document/render/svg_test.go @@ -0,0 +1,774 @@ +package render + +import ( + "bytes" + "strings" + "testing" + + "github.com/gpdf-dev/gpdf/document" + "github.com/gpdf-dev/gpdf/pdf" +) + +// ============================================================ +// parseSVGDimensions +// ============================================================ + +func TestParseSVGDimensions_ViewBox(t *testing.T) { + svg := []byte(``) + w, h := parseSVGDimensions(svg) + if w != 200 || h != 100 { + t.Errorf("got (%g, %g), want (200, 100)", w, h) + } +} + +func TestParseSVGDimensions_WidthHeight(t *testing.T) { + svg := []byte(``) + w, h := parseSVGDimensions(svg) + if w != 300 || h != 150 { + t.Errorf("got (%g, %g), want (300, 150)", w, h) + } +} + +func TestParseSVGDimensions_ViewBoxPreferred(t *testing.T) { + // viewBox takes priority over width/height. + svg := []byte(``) + w, h := parseSVGDimensions(svg) + if w != 100 || h != 50 { + t.Errorf("got (%g, %g), want (100, 50)", w, h) + } +} + +func TestParseSVGDimensions_Fallback(t *testing.T) { + svg := []byte(``) + w, h := parseSVGDimensions(svg) + if w != 100 || h != 100 { + t.Errorf("got (%g, %g), want (100, 100)", w, h) + } +} + +func TestParseSVGDimensions_XMLDeclaration(t *testing.T) { + svg := []byte(``) + w, h := parseSVGDimensions(svg) + if w != 64 || h != 64 { + t.Errorf("got (%g, %g), want (64, 64)", w, h) + } +} + +// ============================================================ +// parseColor +// ============================================================ + +func TestParseColor_Named(t *testing.T) { + tests := []struct { + input string + r, g, b float64 + }{ + {"black", 0, 0, 0}, + {"white", 1, 1, 1}, + {"red", 1, 0, 0}, + {"blue", 0, 0, 1}, + } + for _, tt := range tests { + c, ok := parseColor(tt.input) + if !ok { + t.Errorf("parseColor(%q): got not-ok", tt.input) + continue + } + if c.None { + t.Errorf("parseColor(%q): unexpected None", tt.input) + continue + } + if abs(c.R-tt.r) > 0.01 || abs(c.G-tt.g) > 0.01 || abs(c.B-tt.b) > 0.01 { + t.Errorf("parseColor(%q): got (%g,%g,%g), want (%g,%g,%g)", + tt.input, c.R, c.G, c.B, tt.r, tt.g, tt.b) + } + } +} + +func TestParseColor_Hex6(t *testing.T) { + c, ok := parseColor("#ff8000") + if !ok || c.None { + t.Fatal("expected valid color") + } + if abs(c.R-1) > 0.01 || abs(c.G-0.502) > 0.01 || abs(c.B-0) > 0.01 { + t.Errorf("got (%g,%g,%g)", c.R, c.G, c.B) + } +} + +func TestParseColor_Hex3(t *testing.T) { + c, ok := parseColor("#f80") + if !ok || c.None { + t.Fatal("expected valid color") + } + // #f80 = #ff8800 + if abs(c.R-1) > 0.01 || abs(c.G-0.533) > 0.01 || abs(c.B-0) > 0.01 { + t.Errorf("got (%g,%g,%g)", c.R, c.G, c.B) + } +} + +func TestParseColor_RGB(t *testing.T) { + c, ok := parseColor("rgb(255, 128, 0)") + if !ok || c.None { + t.Fatal("expected valid color") + } + if abs(c.R-1) > 0.01 || abs(c.G-0.502) > 0.01 || abs(c.B-0) > 0.01 { + t.Errorf("got (%g,%g,%g)", c.R, c.G, c.B) + } +} + +func TestParseColor_None(t *testing.T) { + c, ok := parseColor("none") + if !ok || !c.None { + t.Error("expected None color") + } +} + +func TestParseColor_Unknown(t *testing.T) { + _, ok := parseColor("notacolor") + if ok { + t.Error("expected not-ok for unknown color") + } +} + +// ============================================================ +// parseTransform +// ============================================================ + +func TestParseTransform_Translate(t *testing.T) { + m := parseTransform("translate(10, 20)") + // Expected: [1 0 0 1 10 20] + if abs(m[0]-1) > 1e-9 || abs(m[3]-1) > 1e-9 || abs(m[4]-10) > 1e-9 || abs(m[5]-20) > 1e-9 { + t.Errorf("translate: got %v", m) + } +} + +func TestParseTransform_Scale(t *testing.T) { + m := parseTransform("scale(2)") + // Expected: [2 0 0 2 0 0] + if abs(m[0]-2) > 1e-9 || abs(m[3]-2) > 1e-9 { + t.Errorf("scale: got %v", m) + } +} + +func TestParseTransform_ScaleXY(t *testing.T) { + m := parseTransform("scale(3, 4)") + if abs(m[0]-3) > 1e-9 || abs(m[3]-4) > 1e-9 { + t.Errorf("scale(3,4): got %v", m) + } +} + +func TestParseTransform_Matrix(t *testing.T) { + m := parseTransform("matrix(1 2 3 4 5 6)") + want := matrix6{1, 2, 3, 4, 5, 6} + for i := range m { + if abs(m[i]-want[i]) > 1e-9 { + t.Errorf("matrix: got %v, want %v", m, want) + break + } + } +} + +func TestParseTransform_Combined(t *testing.T) { + // translate(10,0) scale(2) → matrix = T * S = [2 0 0 2 10 0] + m := parseTransform("translate(10,0) scale(2)") + if abs(m[0]-2) > 1e-9 || abs(m[3]-2) > 1e-9 || abs(m[4]-10) > 1e-9 || abs(m[5]-0) > 1e-9 { + t.Errorf("combined: got %v", m) + } +} + +// ============================================================ +// convertPathData +// ============================================================ + +func TestConvertPathData_MoveTo(t *testing.T) { + var buf strings.Builder + convertPathData("M 10 20", &buf) + if !strings.Contains(buf.String(), "10 20 m") { + t.Errorf("got: %q", buf.String()) + } +} + +func TestConvertPathData_LineTo(t *testing.T) { + var buf strings.Builder + convertPathData("M 0 0 L 10 20", &buf) + out := buf.String() + if !strings.Contains(out, "10 20 l") { + t.Errorf("got: %q", out) + } +} + +func TestConvertPathData_ClosePath(t *testing.T) { + var buf strings.Builder + convertPathData("M 0 0 L 10 0 L 5 10 Z", &buf) + if !strings.Contains(buf.String(), "h\n") { + t.Errorf("expected closepath (h), got: %q", buf.String()) + } +} + +func TestConvertPathData_HorizontalVertical(t *testing.T) { + var buf strings.Builder + convertPathData("M 0 0 H 50 V 30", &buf) + out := buf.String() + if !strings.Contains(out, "50 0 l") { + t.Errorf("H expected '50 0 l', got: %q", out) + } + if !strings.Contains(out, "50 30 l") { + t.Errorf("V expected '50 30 l', got: %q", out) + } +} + +func TestConvertPathData_CubicBezier(t *testing.T) { + var buf strings.Builder + convertPathData("M 0 0 C 10 20 30 40 50 60", &buf) + if !strings.Contains(buf.String(), "10 20 30 40 50 60 c") { + t.Errorf("got: %q", buf.String()) + } +} + +func TestConvertPathData_RelativeCommands(t *testing.T) { + var buf strings.Builder + convertPathData("m 5 10 l 20 0 l 0 15 z", &buf) + out := buf.String() + if !strings.Contains(out, "5 10 m") { + t.Errorf("relative m: got %q", out) + } +} + +func TestConvertPathData_ImplicitRepeat(t *testing.T) { + var buf strings.Builder + // M with multiple coordinate pairs: first is moveto, rest are lineto. + convertPathData("M 0 0 10 20 30 40", &buf) + out := buf.String() + if !strings.Contains(out, "0 0 m") { + t.Errorf("expected initial moveto, got: %q", out) + } + if !strings.Contains(out, "10 20 l") { + t.Errorf("expected implicit lineto, got: %q", out) + } +} + +func TestConvertPathData_Arc(t *testing.T) { + var buf strings.Builder + // Simple semicircle arc. + convertPathData("M 0 0 A 50 50 0 0 1 100 0", &buf) + out := buf.String() + // Arc should produce at least one Bézier curve. + if !strings.Contains(out, " c\n") { + t.Errorf("arc should produce Bézier curves, got: %q", out) + } +} + +// ============================================================ +// svgToFormContent +// ============================================================ + +func TestSVGToFormContent_Rect(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + if fc.ViewW != 100 || fc.ViewH != 50 { + t.Errorf("dimensions: got (%g, %g), want (100, 50)", fc.ViewW, fc.ViewH) + } + content := string(fc.Content) + if !strings.Contains(content, "re\n") { + t.Errorf("expected rectangle operator, got:\n%s", content) + } + if !strings.Contains(content, "1 0 0 rg") { + t.Errorf("expected red fill (1 0 0 rg), got:\n%s", content) + } +} + +func TestSVGToFormContent_Circle(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + content := string(fc.Content) + // Circle should produce Bézier curves. + if !strings.Contains(content, " c\n") { + t.Errorf("circle should use Bézier curves, got:\n%s", content) + } + // Should have both fill and stroke → B operator. + if !strings.Contains(content, "\nB\n") { + t.Errorf("expected fill+stroke (B), got:\n%s", content) + } +} + +func TestSVGToFormContent_Path(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + content := string(fc.Content) + if !strings.Contains(content, "m\n") { + t.Errorf("expected moveto, got:\n%s", content) + } +} + +func TestSVGToFormContent_StrokeOnly(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + content := string(fc.Content) + // Line has no fill → S operator. + if !strings.Contains(content, "\nS\n") { + t.Errorf("expected stroke-only (S), got:\n%s", content) + } +} + +func TestSVGToFormContent_GroupTransform(t *testing.T) { + svg := []byte(` + + + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + content := string(fc.Content) + // Group transform should emit cm operator. + if !strings.Contains(content, " cm\n") { + t.Errorf("expected transform (cm), got:\n%s", content) + } +} + +func TestSVGToFormContent_Opacity(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + // Should have ExtGState resources for opacity. + if fc.Resources == nil { + t.Error("expected Resources for opacity") + } + content := string(fc.Content) + if !strings.Contains(content, " gs\n") { + t.Errorf("expected ExtGState reference (gs), got:\n%s", content) + } +} + +func TestSVGToFormContent_FillNone(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + content := string(fc.Content) + // fill=none with stroke → S operator. + if !strings.Contains(content, "\nS\n") { + t.Errorf("expected stroke-only (S) for fill=none, got:\n%s", content) + } +} + +func TestSVGToFormContent_InlineStyle(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + content := string(fc.Content) + if !strings.Contains(content, "1 0 0 rg") { + t.Errorf("expected red fill from style attr, got:\n%s", content) + } + if !strings.Contains(content, "0 0 1 RG") { + t.Errorf("expected blue stroke from style attr, got:\n%s", content) + } +} + +func TestSVGToFormContent_Polygon(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + content := string(fc.Content) + if !strings.Contains(content, "h\n") { + t.Errorf("polygon should have closepath (h), got:\n%s", content) + } +} + +func TestSVGToFormContent_RoundedRect(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + content := string(fc.Content) + // Rounded rect uses Bézier curves. + if !strings.Contains(content, " c\n") { + t.Errorf("rounded rect should use Bézier curves, got:\n%s", content) + } +} + +func TestSVGToFormContent_Ellipse(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + if !strings.Contains(string(fc.Content), " c\n") { + t.Errorf("ellipse should use Bézier curves") + } +} + +func TestSVGToFormContent_Polyline(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + content := string(fc.Content) + // Polyline should not close the path and should stroke only. + if !strings.Contains(content, "\nS\n") { + t.Errorf("polyline: expected stroke-only (S), got:\n%s", content) + } +} + +func TestSVGToFormContent_NoFillNoStroke(t *testing.T) { + svg := []byte(` + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + if !strings.Contains(string(fc.Content), "\nn\n") { + t.Errorf("no fill/stroke: expected no-op (n)") + } +} + +func TestSVGToFormContent_IgnoredElements(t *testing.T) { + // defs, title, desc should be silently ignored. + svg := []byte(` + + Test + A description + + `) + fc, err := svgToFormContent(svg) + if err != nil { + t.Fatalf("svgToFormContent: %v", err) + } + if len(fc.Content) == 0 { + t.Error("expected non-empty content") + } +} + +// ============================================================ +// Path data: additional command coverage +// ============================================================ + +func TestConvertPathData_SmoothCubic(t *testing.T) { + var buf strings.Builder + // S uses reflection of previous C control point. + convertPathData("M 0 0 C 10 -10 20 10 30 0 S 50 -10 60 0", &buf) + out := buf.String() + if strings.Count(out, " c\n") < 2 { + t.Errorf("expected 2 cubic segments, got:\n%s", out) + } +} + +func TestConvertPathData_QuadraticBezier(t *testing.T) { + var buf strings.Builder + convertPathData("M 0 0 Q 50 -50 100 0", &buf) + // Q converts to cubic. + if !strings.Contains(buf.String(), " c\n") { + t.Errorf("quadratic should produce cubic, got:\n%s", buf.String()) + } +} + +func TestConvertPathData_SmoothQuadratic(t *testing.T) { + var buf strings.Builder + convertPathData("M 0 0 Q 50 -50 100 0 T 200 0", &buf) + if strings.Count(buf.String(), " c\n") < 2 { + t.Errorf("expected 2 cubic segments for Q+T, got:\n%s", buf.String()) + } +} + +func TestConvertPathData_RelativeSmoothCubic(t *testing.T) { + var buf strings.Builder + convertPathData("M 0 0 c 10 -10 20 10 30 0 s 20 10 30 0", &buf) + if strings.Count(buf.String(), " c\n") < 2 { + t.Errorf("expected 2 cubic segments (relative), got:\n%s", buf.String()) + } +} + +func TestConvertPathData_RelativeQuadratic(t *testing.T) { + var buf strings.Builder + convertPathData("M 0 0 q 25 -25 50 0 t 50 0", &buf) + if strings.Count(buf.String(), " c\n") < 2 { + t.Errorf("expected 2 cubic segments (relative q+t), got:\n%s", buf.String()) + } +} + +func TestConvertPathData_ScientificNotation(t *testing.T) { + var buf strings.Builder + convertPathData("M 1e2 2e1", &buf) + if !strings.Contains(buf.String(), "100 20 m") { + t.Errorf("scientific notation: got %q", buf.String()) + } +} + +// ============================================================ +// parseTransform: additional coverage +// ============================================================ + +func TestParseTransform_Rotate(t *testing.T) { + m := parseTransform("rotate(90)") + // 90° rotation: [cos90 sin90 -sin90 cos90 0 0] ≈ [0 1 -1 0 0 0] + if abs(m[0]-0) > 0.001 || abs(m[1]-1) > 0.001 || abs(m[2]+1) > 0.001 || abs(m[3]-0) > 0.001 { + t.Errorf("rotate(90): got %v", m) + } +} + +func TestParseTransform_RotateAroundPoint(t *testing.T) { + m := parseTransform("rotate(90, 50, 50)") + // Rotating 90° around (50,50): translation should be non-zero. + if abs(m[4]-100) > 0.001 || abs(m[5]-0) > 0.001 { + t.Errorf("rotate(90,50,50): unexpected translation (%g,%g)", m[4], m[5]) + } +} + +func TestParseTransform_SkewX(t *testing.T) { + m := parseTransform("skewX(45)") + // skewX(45): [1 0 tan(45) 1 0 0] ≈ [1 0 1 1 0 0] + if abs(m[0]-1) > 0.001 || abs(m[2]-1) > 0.01 { + t.Errorf("skewX(45): got %v", m) + } +} + +func TestParseTransform_SkewY(t *testing.T) { + m := parseTransform("skewY(45)") + if abs(m[3]-1) > 0.001 || abs(m[1]-1) > 0.01 { + t.Errorf("skewY(45): got %v", m) + } +} + +// ============================================================ +// parseColorComponent: percentage +// ============================================================ + +func TestParseColorComponent_Percentage(t *testing.T) { + c, ok := parseColor("rgb(100%, 50%, 0%)") + if !ok { + t.Fatal("expected valid color") + } + if abs(c.R-1) > 0.01 || abs(c.G-0.5) > 0.01 || abs(c.B-0) > 0.01 { + t.Errorf("rgb percentages: got (%g,%g,%g)", c.R, c.G, c.B) + } +} + +// ============================================================ +// parseSVGLength: unit conversions +// ============================================================ + +func TestParseSVGLength_Units(t *testing.T) { + tests := []struct { + input string + min float64 // expected minimum value + }{ + {"96px", 95}, + {"72pt", 95}, // 72pt = 96px + {"1in", 95}, // 1in = 96px + {"25.4mm", 95}, // 25.4mm = 1in = 96px + } + for _, tt := range tests { + got := parseSVGLength(tt.input) + if got < tt.min { + t.Errorf("parseSVGLength(%q): got %g, want >= %g", tt.input, got, tt.min) + } + } +} + +// ============================================================ +// pdf.Writer RegisterFormXObject +// ============================================================ + +func TestRegisterFormXObject(t *testing.T) { + var buf bytes.Buffer + w := pdf.NewWriter(&buf) + + content := []byte("q 1 0 0 rg 0 0 100 100 re f Q\n") + bbox := pdf.Rectangle{LLX: 0, LLY: 0, URX: 100, URY: 100} + matrix := [6]float64{0.01, 0, 0, -0.01, 0, 1} + + resName, ref, err := w.RegisterFormXObject("testform", content, bbox, matrix, nil) + if err != nil { + t.Fatalf("RegisterFormXObject: %v", err) + } + if resName != "Fm1" { + t.Errorf("resName = %q, want Fm1", resName) + } + if ref.Number == 0 { + t.Error("expected non-zero object ref") + } +} + +func TestParseColor_RGBA(t *testing.T) { + c, ok := parseColor("rgba(255, 0, 0, 0.5)") + if !ok || c.None { + t.Fatal("expected valid color from rgba()") + } + if abs(c.R-1) > 0.01 { + t.Errorf("rgba red channel: got %g, want 1", c.R) + } +} + +func TestArcToBezier_ZeroRadius(t *testing.T) { + var buf strings.Builder + // Arc with zero radius should fall back to a line. + convertPathData("M 0 0 A 0 0 0 0 1 50 50", &buf) + if !strings.Contains(buf.String(), "50 50 l") { + t.Errorf("zero-radius arc should produce lineto, got:\n%s", buf.String()) + } +} + +func TestArcToBezier_SamePoint(t *testing.T) { + var buf strings.Builder + // Arc where start == end should be a no-op. + convertPathData("M 50 50 A 25 25 0 0 1 50 50", &buf) + // Should only have the moveto, no extra lines. + out := buf.String() + if strings.Contains(out, " l\n") || strings.Contains(out, " c\n") { + t.Errorf("same-point arc should produce no extra drawing ops, got:\n%s", out) + } +} + +func TestRegisterFormXObject_Deduplication(t *testing.T) { + var buf bytes.Buffer + w := pdf.NewWriter(&buf) + + content := []byte("q Q\n") + bbox := pdf.Rectangle{LLX: 0, LLY: 0, URX: 10, URY: 10} + matrix := [6]float64{1, 0, 0, 1, 0, 0} + + resName1, ref1, _ := w.RegisterFormXObject("key1", content, bbox, matrix, nil) + resName2, ref2, _ := w.RegisterFormXObject("key1", content, bbox, matrix, nil) + + if resName1 != resName2 { + t.Errorf("duplicate: got different names %q and %q", resName1, resName2) + } + if ref1.Number != ref2.Number { + t.Errorf("duplicate: got different object numbers %d and %d", ref1.Number, ref2.Number) + } +} + +// ============================================================ +// Integration: RenderImage with SVG +// ============================================================ + +func TestRenderImage_SVG(t *testing.T) { + r, buf := newTestRenderer(t) + _ = r.BeginPage(document.Size{Width: 595, Height: 842}) + + svgData := []byte(` + + `) + + src := document.ImageSource{ + Data: svgData, + Format: document.ImageSVG, + Width: 100, + Height: 100, + } + + err := r.RenderImage(src, document.Point{X: 50, Y: 100}, document.Size{Width: 200, Height: 200}) + if err != nil { + t.Fatalf("RenderImage SVG: %v", err) + } + + content := string(r.pageContent) + if !strings.Contains(content, "Do\n") { + t.Errorf("expected Do operator, got:\n%s", content) + } + if !strings.Contains(content, " cm\n") { + t.Errorf("expected cm operator, got:\n%s", content) + } + + // Complete the page and produce a PDF. + if err := r.EndPage(); err != nil { + t.Fatalf("EndPage: %v", err) + } + pw := getPDFWriter(r) + if err := pw.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + data := buf.Bytes() + if len(data) == 0 { + t.Fatal("empty PDF") + } + if string(data[:5]) != "%PDF-" { + t.Fatalf("invalid PDF header: %q", string(data[:5])) + } +} + +func TestRenderImage_SVGDeduplication(t *testing.T) { + r, _ := newTestRenderer(t) + _ = r.BeginPage(document.Size{Width: 595, Height: 842}) + + svgData := []byte(` + + `) + + src := document.ImageSource{ + Data: svgData, + Format: document.ImageSVG, + Width: 50, + Height: 50, + } + + if err := r.RenderImage(src, document.Point{X: 0, Y: 0}, document.Size{Width: 100, Height: 100}); err != nil { + t.Fatalf("first render: %v", err) + } + if err := r.RenderImage(src, document.Point{X: 200, Y: 0}, document.Size{Width: 100, Height: 100}); err != nil { + t.Fatalf("second render: %v", err) + } + + // Only one form XObject should be registered. + if len(r.imageMap) != 1 { + t.Errorf("expected 1 form XObject, got %d", len(r.imageMap)) + } +} + +// getPDFWriter extracts the writer from a PDFRenderer (for test teardown). +func getPDFWriter(r *PDFRenderer) *pdf.Writer { + return r.writer +} + +func abs(x float64) float64 { + if x < 0 { + return -x + } + return x +} diff --git a/pdf/writer.go b/pdf/writer.go index 25128c5..57aa1f8 100644 --- a/pdf/writer.go +++ b/pdf/writer.go @@ -23,6 +23,7 @@ type Writer struct { pages []ObjectRef fonts map[string]ObjectRef // font name -> object ref images map[string]ObjectRef // image name -> object ref + forms map[string]ObjectRef // form XObject name -> object ref info DocumentInfo catalog ObjectRef pageTree ObjectRef @@ -60,6 +61,7 @@ func NewWriter(w io.Writer) *Writer { xref: NewXRefTable(), fonts: make(map[string]ObjectRef), images: make(map[string]ObjectRef), + forms: make(map[string]ObjectRef), nextObjNum: 1, compress: true, } @@ -357,6 +359,61 @@ func (pw *Writer) RegisterImage(name string, data []byte, width, height int, col return resName, imgRef, nil } +// RegisterFormXObject registers an SVG-derived PDF Form XObject and returns its +// resource name (e.g., "Fm1") and object reference. The content is the PDF +// content stream for the form. bbox defines the clipping rectangle in the +// form's coordinate space. matrix is the six-element transformation matrix +// [a b c d e f] applied before placing the form on the page. resources is an +// optional PDF resource dictionary for the form (e.g., ExtGState for opacity). +func (pw *Writer) RegisterFormXObject(name string, content []byte, bbox Rectangle, matrix [6]float64, resources Dict) (string, ObjectRef, error) { + if ref, ok := pw.forms[name]; ok { + idx := 1 + for k := range pw.forms { + if k == name { + break + } + idx++ + } + return fmt.Sprintf("Fm%d", idx), ref, nil + } + + formRef := pw.AllocObject() + resName := fmt.Sprintf("Fm%d", len(pw.forms)+1) + + formDict := Dict{ + Name("Type"): Name("XObject"), + Name("Subtype"): Name("Form"), + Name("BBox"): bbox, + Name("Matrix"): Array{ + Real(matrix[0]), Real(matrix[1]), + Real(matrix[2]), Real(matrix[3]), + Real(matrix[4]), Real(matrix[5]), + }, + } + + if len(resources) > 0 { + formDict[Name("Resources")] = resources + } + + formContent := content + if pw.compress { + compressed, err := CompressFlate(content) + if err != nil { + return "", ObjectRef{}, fmt.Errorf("pdf: failed to compress form content: %w", err) + } + formDict[Name("Filter")] = Name("FlateDecode") + formContent = compressed + } + + formStream := Stream{Dict: formDict, Content: formContent} + if err := pw.WriteObject(formRef, formStream); err != nil { + return "", ObjectRef{}, err + } + + pw.forms[name] = formRef + return resName, formRef, nil +} + // SetCompression enables or disables flate compression for streams. func (pw *Writer) SetCompression(enabled bool) { pw.compress = enabled diff --git a/template/grid.go b/template/grid.go index b8a35ef..78b1965 100644 --- a/template/grid.go +++ b/template/grid.go @@ -1,6 +1,12 @@ package template import ( + "bytes" + "encoding/xml" + "math" + "strconv" + "strings" + "github.com/gpdf-dev/gpdf/barcode" "github.com/gpdf-dev/gpdf/document" "github.com/gpdf-dev/gpdf/pdf" @@ -488,9 +494,28 @@ func detectImageFormat(data []byte) document.ImageFormat { if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { return document.ImageJPEG } + if isSVGData(data) { + return document.ImageSVG + } return document.ImagePNG } +// isSVGData returns true if data appears to be an SVG document. +func isSVGData(data []byte) bool { + trimmed := bytes.TrimSpace(data) + if bytes.HasPrefix(trimmed, []byte(" 2048 { + search = search[:2048] + } + lower := bytes.ToLower(search) + return bytes.Contains(lower, []byte(" 0 && h > 0 { + return int(math.Round(w)), int(math.Round(h)) + } + } + } + // Fall back to width/height attributes. + w := parseSVGLengthAttr(attrs["width"]) + h := parseSVGLengthAttr(attrs["height"]) + if w > 0 && h > 0 { + return int(math.Round(w)), int(math.Round(h)) + } + break + } + return 100, 100 // fallback +} + +// parseSVGLengthAttr converts an SVG length string (e.g. "100px", "50mm") to px units. +func parseSVGLengthAttr(s string) float64 { + s = strings.TrimSpace(s) + units := []struct { + suffix string + factor float64 + }{ + {"px", 1}, + {"pt", 4.0 / 3}, + {"mm", 96.0 / 25.4}, + {"cm", 96.0 / 2.54}, + {"in", 96}, + {"rem", 16}, + {"em", 16}, + } + for _, u := range units { + if strings.HasSuffix(s, u.suffix) { + v, err := strconv.ParseFloat(strings.TrimSuffix(s, u.suffix), 64) + if err == nil { + return v * u.factor + } + } + } + v, _ := strconv.ParseFloat(s, 64) + return v +} + // extractPNGDimensions reads width and height from the PNG IHDR chunk. // PNG layout: 8-byte signature, then IHDR chunk with width at offset 16 // and height at offset 20, both as 4-byte big-endian integers.