From af4c944de765c7aa459834686a9f9aa6f2642f49 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 26 May 2026 17:11:09 +0200 Subject: [PATCH] Added PDF Generation --- go.mod | 6 +- internal/layout/layout.go | 23 ++--- internal/layout/layout_test.go | 22 +++-- internal/platepdf/composite.go | 50 ++++++++++ internal/platepdf/platepdf.go | 32 +++++++ internal/platepdf/platepdf_test.go | 106 +++++++++++++++++++++ internal/platepdf/rsvg.go | 62 ++++++++++++ internal/platepdf/svgparse.go | 54 +++++++++++ internal/platepdf/svgparse_test.go | 13 +++ internal/server/admin/static/index.html | 70 +++++++++++++- internal/server/api/configuration.go | 1 + internal/server/api/pdf.go | 120 ++++++++++++++++++++++++ 12 files changed, 525 insertions(+), 34 deletions(-) create mode 100644 internal/platepdf/composite.go create mode 100644 internal/platepdf/platepdf.go create mode 100644 internal/platepdf/platepdf_test.go create mode 100644 internal/platepdf/rsvg.go create mode 100644 internal/platepdf/svgparse.go create mode 100644 internal/platepdf/svgparse_test.go create mode 100644 internal/server/api/pdf.go diff --git a/go.mod b/go.mod index 3f6ac74..d245946 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,12 @@ module printer.backend go 1.26.3 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/google/uuid v1.6.0 + github.com/spf13/cobra v1.10.2 +) require ( - github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect ) diff --git a/internal/layout/layout.go b/internal/layout/layout.go index 962f479..5bacd70 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -4,25 +4,14 @@ import ( "printer.backend/internal/model" ) -// Footprint returns the physical width and height one item occupies on the plate (mm). -// Includes bleed on all sides and the item's margin as a safe zone. -func Footprint(spec model.ItemSpec) (width, height float64) { - w := spec.SizeMM + 2*spec.BleedMM + 2*spec.MarginMM - return w, w -} - -// CellPitch returns width and height of one grid cell (footprint + spacing between items). -func CellPitch(spec model.ItemSpec, spacingMM float64) (width, height float64) { - fw, fh := Footprint(spec) - return fw + spacingMM, fh + spacingMM -} - // Pack computes the maximum grid of items that fit on the plate. +// Item spacing uses the SVG viewport size (spec.SizeMM); bleed/margin/padding live inside the template. func Pack(plate model.Plate, spec model.ItemSpec, spacingMM float64) model.LayoutPreview { pw := plate.PrintableWidth() ph := plate.PrintableHeight() - fw, fh := Footprint(spec) - cw, ch := CellPitch(spec, spacingMM) + itemW := spec.SizeMM + cw := itemW + spacingMM + ch := itemW + spacingMM cols, rows := gridCount(pw, ph, cw, ch) count := cols * rows @@ -49,8 +38,8 @@ func Pack(plate model.Plate, spec model.ItemSpec, spacingMM float64) model.Layou PrintableHMM: ph, CellWidthMM: cw, CellHeightMM: ch, - FootprintWMM: fw, - FootprintHMM: fh, + FootprintWMM: itemW, + FootprintHMM: itemW, SpacingMM: spacingMM, Columns: cols, Rows: rows, diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go index 7011b40..b278777 100644 --- a/internal/layout/layout_test.go +++ b/internal/layout/layout_test.go @@ -16,20 +16,22 @@ func TestPack(t *testing.T) { MarginLeft: 10, } spec := model.ItemSpec{SizeMM: 80, BleedMM: 2, MarginMM: 5} - // footprint = 80+4+10 = 94, cell = 94+2 = 96 - // printable 280x380 -> cols=2, rows=3 -> 6 + // viewport 80mm, cell = 80+2 = 82, printable 280x380 -> cols=3, rows=4 -> 12 preview := Pack(plate, spec, 2) - if preview.Columns != 2 { - t.Fatalf("columns: got %d want 2", preview.Columns) + if preview.Columns != 3 { + t.Fatalf("columns: got %d want 3", preview.Columns) } - if preview.Rows != 3 { - t.Fatalf("rows: got %d want 3", preview.Rows) + if preview.Rows != 4 { + t.Fatalf("rows: got %d want 4", preview.Rows) } - if preview.Count != 6 { - t.Fatalf("count: got %d want 6", preview.Count) + if preview.Count != 12 { + t.Fatalf("count: got %d want 12", preview.Count) } - if len(preview.Positions) != 6 { - t.Fatalf("positions: got %d want 6", len(preview.Positions)) + if preview.FootprintWMM != 80 { + t.Fatalf("footprint width: got %v want 80", preview.FootprintWMM) + } + if len(preview.Positions) != 12 { + t.Fatalf("positions: got %d want 12", len(preview.Positions)) } if preview.Positions[0].XMM != 10 || preview.Positions[0].YMM != 10 { t.Fatalf("first position: got (%v,%v)", preview.Positions[0].XMM, preview.Positions[0].YMM) diff --git a/internal/platepdf/composite.go b/internal/platepdf/composite.go new file mode 100644 index 0000000..5a9bc4f --- /dev/null +++ b/internal/platepdf/composite.go @@ -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, ` + + + +`, + 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, + ``, + pos.XMM, pos.YMM, itemW, itemH, itemB64, + ) + } + + b.WriteString("\n") + return b.Bytes(), nil +} diff --git a/internal/platepdf/platepdf.go b/internal/platepdf/platepdf.go new file mode 100644 index 0000000..f4e7f8a --- /dev/null +++ b/internal/platepdf/platepdf.go @@ -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 +} diff --git a/internal/platepdf/platepdf_test.go b/internal/platepdf/platepdf_test.go new file mode 100644 index 0000000..3ea0a75 --- /dev/null +++ b/internal/platepdf/platepdf_test.go @@ -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 = ` + + + + + + + + + + +` + +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, ` 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) + } +} diff --git a/internal/platepdf/rsvg.go b/internal/platepdf/rsvg.go new file mode 100644 index 0000000..f6356ed --- /dev/null +++ b/internal/platepdf/rsvg.go @@ -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 +} diff --git a/internal/platepdf/svgparse.go b/internal/platepdf/svgparse.go new file mode 100644 index 0000000..c00d314 --- /dev/null +++ b/internal/platepdf/svgparse.go @@ -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 element (supports mm suffix). +func svgViewportMM(data []byte) (widthMM, heightMM float64, err error) { + s := string(data) + open := strings.Index(s, "") + 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) +} diff --git a/internal/platepdf/svgparse_test.go b/internal/platepdf/svgparse_test.go new file mode 100644 index 0000000..6a40658 --- /dev/null +++ b/internal/platepdf/svgparse_test.go @@ -0,0 +1,13 @@ +package platepdf + +import "testing" + +func TestSVGViewportMM(t *testing.T) { + w, h, err := svgViewportMM([]byte(``)) + if err != nil { + t.Fatal(err) + } + if w != 80 || h != 80 { + t.Fatalf("got %v×%v want 80×80", w, h) + } +} diff --git a/internal/server/admin/static/index.html b/internal/server/admin/static/index.html index a64bb58..07219d7 100644 --- a/internal/server/admin/static/index.html +++ b/internal/server/admin/static/index.html @@ -68,6 +68,13 @@ } button:hover { filter: brightness(1.1); } button:disabled { opacity: 0.5; cursor: not-allowed; } + button.secondary { + background: transparent; + border: 1px solid var(--accent); + color: var(--accent); + } + button.secondary:hover { background: rgba(59, 130, 246, 0.12); filter: none; } + .btn-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; } button.danger { background: transparent; border: 1px solid var(--err); @@ -364,9 +371,15 @@ - +
+ + +

@@ -377,7 +390,7 @@
Items passen Raster - Fußabdruck + Item Zellenabstand Druckfläche
@@ -399,7 +412,13 @@

- +
+ + +

@@ -486,6 +505,7 @@ }, layoutPreview: null, layoutLoading: false, + pdfGenerating: false, _layoutTimer: null, apiUrl(path) { @@ -714,6 +734,46 @@ } }, + async downloadBlob(path, filename) { + this.pdfGenerating = true; + this.configError = ''; + try { + const res = await fetch(this.apiUrl(path)); + if (!res.ok) { + let msg = res.statusText; + try { + const err = await res.json(); + if (err.error) msg = err.error; + } catch (_) {} + throw new Error(msg); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } catch (e) { + this.configError = String(e.message || e); + } finally { + this.pdfGenerating = false; + } + }, + + downloadLayoutPDF() { + const q = new URLSearchParams({ + plate_id: this.configForm.plate_id, + item_id: this.configForm.item_id, + spacing_mm: String(Number(this.configForm.spacing_mm) || 0), + }); + this.downloadBlob('/layout/pdf?' + q, 'layout-preview.pdf'); + }, + + downloadConfigurationPDF(id) { + this.downloadBlob('/configurations/' + id + '/pdf', 'configuration-' + id.slice(0, 8) + '.pdf'); + }, + async deleteConfiguration(id) { if (!confirm('Konfiguration wirklich löschen?')) return; this.configError = ''; diff --git a/internal/server/api/configuration.go b/internal/server/api/configuration.go index fa74204..5262501 100644 --- a/internal/server/api/configuration.go +++ b/internal/server/api/configuration.go @@ -18,6 +18,7 @@ func registerConfigurationRoutes(mux *http.ServeMux, configs *store.Configuratio mux.HandleFunc("DELETE /configurations/{id}", deleteConfiguration(configs)) mux.HandleFunc("GET /configurations/{id}/preview", previewConfiguration(configs, plates, items)) mux.HandleFunc("GET /layout/preview", layoutPreview(plates, items)) + registerPDFRoutes(mux, configs, plates, items) } type configurationResponse struct { diff --git a/internal/server/api/pdf.go b/internal/server/api/pdf.go new file mode 100644 index 0000000..e49fdda --- /dev/null +++ b/internal/server/api/pdf.go @@ -0,0 +1,120 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + + "printer.backend/internal/model" + "printer.backend/internal/platepdf" + "printer.backend/internal/store" +) + +func registerPDFRoutes(mux *http.ServeMux, configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) { + mux.HandleFunc("GET /layout/pdf", layoutPDF(plates, items)) + mux.HandleFunc("GET /configurations/{id}/pdf", configurationPDF(configs, plates, items)) +} + +func layoutPDF(plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + plateID := r.URL.Query().Get("plate_id") + itemID := r.URL.Query().Get("item_id") + if plateID == "" || itemID == "" { + writeError(w, http.StatusBadRequest, errors.New("plate_id and item_id query params are required")) + return + } + spacing, err := parseSpacing(r.URL.Query().Get("spacing_mm")) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + plate, item, preview, svgPath, err := resolveLayoutPDF(plates, items, plateID, itemID, spacing) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + pdf, err := platepdf.Generate(plate, item.Spec, svgPath, preview) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + servePDF(w, pdf, "layout-preview.pdf") + } +} + +func configurationPDF(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + c, err := configs.Get(id) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + + plate, item, preview, svgPath, err := resolveLayoutPDF(plates, items, c.PlateID, c.ItemID, c.SpacingMM) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + pdf, err := platepdf.Generate(plate, item.Spec, svgPath, preview) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + name := c.Name + if name == "" { + name = "configuration-" + id[:8] + } + servePDF(w, pdf, sanitizeFilename(name)+".pdf") + } +} + +func resolveLayoutPDF(plates *store.PlateStore, items *store.ItemStore, plateID, itemID string, spacingMM float64) ( + plate model.Plate, item model.Item, preview model.LayoutPreview, svgPath string, err error, +) { + plate, err = findPlate(plates, plateID) + if err != nil { + return plate, item, preview, "", err + } + item, err = items.Get(itemID) + if err != nil { + return plate, item, preview, "", err + } + svgPath, err = items.SVGPath(itemID) + if err != nil { + return plate, item, preview, "", err + } + preview, err = buildPreview(plates, items, plateID, itemID, spacingMM) + if err != nil { + return plate, item, preview, "", err + } + preview.PlateID = plateID + preview.ItemID = itemID + return plate, item, preview, svgPath, nil +} + +func servePDF(w http.ResponseWriter, pdf []byte, filename string) { + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(pdf) +} + +func sanitizeFilename(name string) string { + var b []byte + for i := 0; i < len(name); i++ { + c := name[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' { + b = append(b, c) + } else if c == ' ' { + b = append(b, '_') + } + } + if len(b) == 0 { + return "preview" + } + return string(b) +}