Added PDF Generation
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package platepdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"printer.backend/internal/model"
|
||||
)
|
||||
|
||||
const renderDPI = 300
|
||||
|
||||
// BuildCompositeSVG assembles a plate-sized SVG by embedding each item as a PNG
|
||||
// rendered by rsvg-convert (identical to the standalone item file).
|
||||
func BuildCompositeSVG(plate model.Plate, spec model.ItemSpec, itemSVG []byte, preview model.LayoutPreview) ([]byte, error) {
|
||||
itemW, itemH, err := svgViewportMM(itemSVG)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if itemW <= 0 || itemH <= 0 {
|
||||
itemW, itemH = spec.SizeMM, spec.SizeMM
|
||||
}
|
||||
|
||||
itemPNG, err := svgToPNGViaRsvg(itemSVG, renderDPI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render item: %w", err)
|
||||
}
|
||||
itemB64 := base64.StdEncoding.EncodeToString(itemPNG)
|
||||
|
||||
var b bytes.Buffer
|
||||
fmt.Fprintf(&b, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 %.4f %.4f" width="%.4fmm" height="%.4fmm">
|
||||
<rect width="%.4f" height="%.4f" fill="#ffffff"/>
|
||||
<rect x="%.4f" y="%.4f" width="%.4f" height="%.4f" fill="none" stroke="#cccccc" stroke-width="0.2"/>
|
||||
`,
|
||||
plate.WidthMM, plate.HeightMM, plate.WidthMM, plate.HeightMM,
|
||||
plate.WidthMM, plate.HeightMM,
|
||||
preview.PrintableXMM, preview.PrintableYMM, preview.PrintableWMM, preview.PrintableHMM,
|
||||
)
|
||||
|
||||
for _, pos := range preview.Positions {
|
||||
fmt.Fprintf(&b,
|
||||
`<image x="%.4f" y="%.4f" width="%.4f" height="%.4f" href="data:image/png;base64,%s"/>`,
|
||||
pos.XMM, pos.YMM, itemW, itemH, itemB64,
|
||||
)
|
||||
}
|
||||
|
||||
b.WriteString("\n</svg>")
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package platepdf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"printer.backend/internal/model"
|
||||
)
|
||||
|
||||
// Generate builds a PDF of the plate layout using the item's SVG template.
|
||||
// Requires rsvg-convert on PATH.
|
||||
func Generate(plate model.Plate, spec model.ItemSpec, itemSVGPath string, preview model.LayoutPreview) ([]byte, error) {
|
||||
if !rsvgAvailable() {
|
||||
return nil, fmt.Errorf("rsvg-convert not found in PATH (required for PDF export)")
|
||||
}
|
||||
|
||||
itemSVG, err := os.ReadFile(itemSVGPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read item svg: %w", err)
|
||||
}
|
||||
|
||||
composite, err := BuildCompositeSVG(plate, spec, itemSVG, preview)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pdf, err := svgToPDFViaRsvg(composite, plate.WidthMM, plate.HeightMM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render plate pdf: %w", err)
|
||||
}
|
||||
return pdf, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package platepdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"printer.backend/internal/layout"
|
||||
"printer.backend/internal/model"
|
||||
"printer.backend/internal/svgtemplate"
|
||||
)
|
||||
|
||||
const sampleItemSVG = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="80mm" height="80mm" viewBox="-13 -13 106 106">
|
||||
<defs>
|
||||
<clipPath id="item-mask">
|
||||
<rect x="0" y="0" width="80" height="80" rx="5" ry="5" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="-13" y="-13" width="106" height="106" fill="#f9f9f9" />
|
||||
<g clip-path="url(#item-mask)">
|
||||
<rect x="-2" y="-2" width="84" height="84" fill="#00ff00" opacity="0.7" />
|
||||
</g>
|
||||
</svg>`
|
||||
|
||||
func requireRsvg(t *testing.T) {
|
||||
t.Helper()
|
||||
if !rsvgAvailable() {
|
||||
t.Skip("rsvg-convert not available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCompositeSVG(t *testing.T) {
|
||||
requireRsvg(t)
|
||||
|
||||
plate := model.Plate{
|
||||
WidthMM: 300, HeightMM: 400,
|
||||
MarginTop: 10, MarginRight: 10, MarginBottom: 10, MarginLeft: 10,
|
||||
}
|
||||
spec := model.ItemSpec{SizeMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3}
|
||||
preview := layout.Pack(plate, spec, 2)
|
||||
|
||||
out, err := BuildCompositeSVG(plate, spec, []byte(sampleItemSVG), preview)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(out)
|
||||
if !strings.Contains(s, `viewBox="0 0 300`) {
|
||||
t.Fatalf("expected plate viewBox, got: %s", s[:min(200, len(s))])
|
||||
}
|
||||
if preview.Count > 0 && !strings.Contains(s, `<image `) {
|
||||
t.Fatal("expected embedded item images")
|
||||
}
|
||||
if preview.Count > 0 && !strings.Contains(s, `x="10.0000"`) {
|
||||
t.Fatal("expected items at plate margin (footprint origin)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCompositeSVGInvalid(t *testing.T) {
|
||||
requireRsvg(t)
|
||||
|
||||
_, err := BuildCompositeSVG(model.Plate{}, model.ItemSpec{}, []byte("not svg"), model.LayoutPreview{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid svg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePDF(t *testing.T) {
|
||||
requireRsvg(t)
|
||||
|
||||
plate := model.Plate{
|
||||
WidthMM: 200, HeightMM: 200,
|
||||
MarginLeft: 10, MarginTop: 10, MarginRight: 10, MarginBottom: 10,
|
||||
}
|
||||
spec := model.ItemSpec{SizeMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3}
|
||||
preview := layout.Pack(plate, spec, 2)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svgtemplate.Write(&buf, svgtemplate.Build(spec.SizeMM, spec.BleedMM, spec.MarginMM, spec.PaddingMM)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
path := t.TempDir() + "/item.svg"
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pdf, err := Generate(plate, spec, path, preview)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pdf) < 4 || string(pdf[:4]) != "%PDF" {
|
||||
t.Fatalf("expected PDF header, got %d bytes", len(pdf))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRequiresRsvg(t *testing.T) {
|
||||
if rsvgAvailable() {
|
||||
t.Skip("rsvg-convert is available")
|
||||
}
|
||||
_, err := Generate(model.Plate{}, model.ItemSpec{}, "/nonexistent", model.LayoutPreview{})
|
||||
if err == nil || !strings.Contains(err.Error(), "rsvg-convert") {
|
||||
t.Fatalf("expected rsvg error, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package platepdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// rsvgAvailable returns true when rsvg-convert is found in PATH.
|
||||
func rsvgAvailable() bool {
|
||||
_, err := exec.LookPath("rsvg-convert")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// svgToPDFViaRsvg converts an SVG byte slice to a PDF using rsvg-convert.
|
||||
// widthMM / heightMM set the output page size so the PDF is exactly that physical size.
|
||||
func svgToPDFViaRsvg(svg []byte, widthMM, heightMM float64) ([]byte, error) {
|
||||
args := []string{
|
||||
"-f", "pdf",
|
||||
"--page-width", fmt.Sprintf("%.4fmm", widthMM),
|
||||
"--page-height", fmt.Sprintf("%.4fmm", heightMM),
|
||||
"-", // read from stdin
|
||||
}
|
||||
cmd := exec.Command("rsvg-convert", args...)
|
||||
cmd.Stdin = bytes.NewReader(svg)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
// rsvg-convert exits non-zero when writing to a non-terminal stdout; treat
|
||||
// a non-empty PDF output as success regardless of exit code.
|
||||
if stdout.Len() > 4 && string(stdout.Bytes()[:4]) == "%PDF" {
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rsvg-convert: %w: %s", err, stderr.String())
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
// svgToPNGViaRsvg converts SVG to PNG at the given DPI using rsvg-convert.
|
||||
func svgToPNGViaRsvg(svg []byte, dpi float64) ([]byte, error) {
|
||||
args := []string{
|
||||
"-f", "png",
|
||||
"-d", fmt.Sprintf("%.0f", dpi),
|
||||
"-p", fmt.Sprintf("%.0f", dpi),
|
||||
"-",
|
||||
}
|
||||
cmd := exec.Command("rsvg-convert", args...)
|
||||
cmd.Stdin = bytes.NewReader(svg)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var stderr []byte
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
stderr = ee.Stderr
|
||||
}
|
||||
return nil, fmt.Errorf("rsvg-convert: %w: %s", err, stderr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package platepdf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var svgAttrRE = regexp.MustCompile(`(?i)(\w+)\s*=\s*"([^"]*)"`)
|
||||
|
||||
func svgAttrValue(openTag, name string) string {
|
||||
for _, m := range svgAttrRE.FindAllStringSubmatch(openTag, -1) {
|
||||
if strings.EqualFold(m[1], name) {
|
||||
return m[2]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// svgViewportMM reads width/height from the root <svg> element (supports mm suffix).
|
||||
func svgViewportMM(data []byte) (widthMM, heightMM float64, err error) {
|
||||
s := string(data)
|
||||
open := strings.Index(s, "<svg")
|
||||
if open < 0 {
|
||||
return 0, 0, fmt.Errorf("invalid svg: missing root element")
|
||||
}
|
||||
tagEnd := strings.Index(s[open:], ">")
|
||||
if tagEnd < 0 {
|
||||
return 0, 0, fmt.Errorf("invalid svg: malformed root element")
|
||||
}
|
||||
tagEnd += open
|
||||
|
||||
w, err := parseLengthMM(svgAttrValue(s[open:tagEnd], "width"))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
h, err := parseLengthMM(svgAttrValue(s[open:tagEnd], "height"))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return w, h, nil
|
||||
}
|
||||
|
||||
func parseLengthMM(s string) (float64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty length")
|
||||
}
|
||||
if strings.HasSuffix(s, "mm") {
|
||||
return strconv.ParseFloat(strings.TrimSuffix(s, "mm"), 64)
|
||||
}
|
||||
return strconv.ParseFloat(s, 64)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package platepdf
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSVGViewportMM(t *testing.T) {
|
||||
w, h, err := svgViewportMM([]byte(`<svg width="80mm" height="80mm" viewBox="0 0 1 1"></svg>`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w != 80 || h != 80 {
|
||||
t.Fatalf("got %v×%v want 80×80", w, h)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user