Added PDF Generation
This commit is contained in:
parent
c5d2e32355
commit
af4c944de7
6
go.mod
6
go.mod
@ -2,10 +2,12 @@ module printer.backend
|
|||||||
|
|
||||||
go 1.26.3
|
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 (
|
require (
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,25 +4,14 @@ import (
|
|||||||
"printer.backend/internal/model"
|
"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.
|
// 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 {
|
func Pack(plate model.Plate, spec model.ItemSpec, spacingMM float64) model.LayoutPreview {
|
||||||
pw := plate.PrintableWidth()
|
pw := plate.PrintableWidth()
|
||||||
ph := plate.PrintableHeight()
|
ph := plate.PrintableHeight()
|
||||||
fw, fh := Footprint(spec)
|
itemW := spec.SizeMM
|
||||||
cw, ch := CellPitch(spec, spacingMM)
|
cw := itemW + spacingMM
|
||||||
|
ch := itemW + spacingMM
|
||||||
|
|
||||||
cols, rows := gridCount(pw, ph, cw, ch)
|
cols, rows := gridCount(pw, ph, cw, ch)
|
||||||
count := cols * rows
|
count := cols * rows
|
||||||
@ -49,8 +38,8 @@ func Pack(plate model.Plate, spec model.ItemSpec, spacingMM float64) model.Layou
|
|||||||
PrintableHMM: ph,
|
PrintableHMM: ph,
|
||||||
CellWidthMM: cw,
|
CellWidthMM: cw,
|
||||||
CellHeightMM: ch,
|
CellHeightMM: ch,
|
||||||
FootprintWMM: fw,
|
FootprintWMM: itemW,
|
||||||
FootprintHMM: fh,
|
FootprintHMM: itemW,
|
||||||
SpacingMM: spacingMM,
|
SpacingMM: spacingMM,
|
||||||
Columns: cols,
|
Columns: cols,
|
||||||
Rows: rows,
|
Rows: rows,
|
||||||
|
|||||||
@ -16,20 +16,22 @@ func TestPack(t *testing.T) {
|
|||||||
MarginLeft: 10,
|
MarginLeft: 10,
|
||||||
}
|
}
|
||||||
spec := model.ItemSpec{SizeMM: 80, BleedMM: 2, MarginMM: 5}
|
spec := model.ItemSpec{SizeMM: 80, BleedMM: 2, MarginMM: 5}
|
||||||
// footprint = 80+4+10 = 94, cell = 94+2 = 96
|
// viewport 80mm, cell = 80+2 = 82, printable 280x380 -> cols=3, rows=4 -> 12
|
||||||
// printable 280x380 -> cols=2, rows=3 -> 6
|
|
||||||
preview := Pack(plate, spec, 2)
|
preview := Pack(plate, spec, 2)
|
||||||
if preview.Columns != 2 {
|
if preview.Columns != 3 {
|
||||||
t.Fatalf("columns: got %d want 2", preview.Columns)
|
t.Fatalf("columns: got %d want 3", preview.Columns)
|
||||||
}
|
}
|
||||||
if preview.Rows != 3 {
|
if preview.Rows != 4 {
|
||||||
t.Fatalf("rows: got %d want 3", preview.Rows)
|
t.Fatalf("rows: got %d want 4", preview.Rows)
|
||||||
}
|
}
|
||||||
if preview.Count != 6 {
|
if preview.Count != 12 {
|
||||||
t.Fatalf("count: got %d want 6", preview.Count)
|
t.Fatalf("count: got %d want 12", preview.Count)
|
||||||
}
|
}
|
||||||
if len(preview.Positions) != 6 {
|
if preview.FootprintWMM != 80 {
|
||||||
t.Fatalf("positions: got %d want 6", len(preview.Positions))
|
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 {
|
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)
|
t.Fatalf("first position: got (%v,%v)", preview.Positions[0].XMM, preview.Positions[0].YMM)
|
||||||
|
|||||||
50
internal/platepdf/composite.go
Normal file
50
internal/platepdf/composite.go
Normal file
@ -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
|
||||||
|
}
|
||||||
32
internal/platepdf/platepdf.go
Normal file
32
internal/platepdf/platepdf.go
Normal file
@ -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
|
||||||
|
}
|
||||||
106
internal/platepdf/platepdf_test.go
Normal file
106
internal/platepdf/platepdf_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
internal/platepdf/rsvg.go
Normal file
62
internal/platepdf/rsvg.go
Normal file
@ -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
|
||||||
|
}
|
||||||
54
internal/platepdf/svgparse.go
Normal file
54
internal/platepdf/svgparse.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
13
internal/platepdf/svgparse_test.go
Normal file
13
internal/platepdf/svgparse_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -68,6 +68,13 @@
|
|||||||
}
|
}
|
||||||
button:hover { filter: brightness(1.1); }
|
button:hover { filter: brightness(1.1); }
|
||||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
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 {
|
button.danger {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--err);
|
border: 1px solid var(--err);
|
||||||
@ -364,9 +371,15 @@
|
|||||||
<input type="number" step="0.1" min="0" x-model.number="configForm.spacing_mm" @input="refreshLayoutPreview()">
|
<input type="number" step="0.1" min="0" x-model.number="configForm.spacing_mm" @input="refreshLayoutPreview()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" :disabled="configSaving || !configForm.plate_id || !configForm.item_id">
|
<div class="btn-row">
|
||||||
<span x-text="configSaving ? 'Speichere…' : 'Konfiguration speichern'"></span>
|
<button type="submit" :disabled="configSaving || !configForm.plate_id || !configForm.item_id">
|
||||||
</button>
|
<span x-text="configSaving ? 'Speichere…' : 'Konfiguration speichern'"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="secondary" @click="downloadLayoutPDF()"
|
||||||
|
:disabled="pdfGenerating || !layoutPreview || !configForm.plate_id || !configForm.item_id">
|
||||||
|
<span x-text="pdfGenerating ? 'PDF…' : 'PDF-Vorschau'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p class="msg-err" x-show="configError" x-text="configError"></p>
|
<p class="msg-err" x-show="configError" x-text="configError"></p>
|
||||||
<p class="msg-ok" x-show="configSuccess" x-text="configSuccess"></p>
|
<p class="msg-ok" x-show="configSuccess" x-text="configSuccess"></p>
|
||||||
</form>
|
</form>
|
||||||
@ -377,7 +390,7 @@
|
|||||||
<div class="layout-stats">
|
<div class="layout-stats">
|
||||||
<span><strong x-text="layoutPreview.count"></strong> Items passen</span>
|
<span><strong x-text="layoutPreview.count"></strong> Items passen</span>
|
||||||
<span><strong x-text="layoutPreview.columns + ' × ' + layoutPreview.rows"></strong> Raster</span>
|
<span><strong x-text="layoutPreview.columns + ' × ' + layoutPreview.rows"></strong> Raster</span>
|
||||||
<span>Fußabdruck <strong x-text="layoutPreview.footprint_width_mm.toFixed(1) + ' mm'"></strong></span>
|
<span>Item <strong x-text="layoutPreview.footprint_width_mm.toFixed(1) + ' mm'"></strong></span>
|
||||||
<span>Zellenabstand <strong x-text="layoutPreview.cell_width_mm.toFixed(1) + ' mm'"></strong></span>
|
<span>Zellenabstand <strong x-text="layoutPreview.cell_width_mm.toFixed(1) + ' mm'"></strong></span>
|
||||||
<span>Druckfläche <strong x-text="layoutPreview.printable_width_mm.toFixed(1) + ' × ' + layoutPreview.printable_height_mm.toFixed(1) + ' mm'"></strong></span>
|
<span>Druckfläche <strong x-text="layoutPreview.printable_width_mm.toFixed(1) + ' × ' + layoutPreview.printable_height_mm.toFixed(1) + ' mm'"></strong></span>
|
||||||
</div>
|
</div>
|
||||||
@ -399,7 +412,13 @@
|
|||||||
<h3 x-text="c.name || ('Konfiguration ' + c.id.slice(0, 8))"></h3>
|
<h3 x-text="c.name || ('Konfiguration ' + c.id.slice(0, 8))"></h3>
|
||||||
<p class="muted" x-text="configSummary(c)"></p>
|
<p class="muted" x-text="configSummary(c)"></p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="danger" @click="deleteConfiguration(c.id)">Löschen</button>
|
<div class="row-actions">
|
||||||
|
<button type="button" class="secondary" @click="downloadConfigurationPDF(c.id)"
|
||||||
|
:disabled="pdfGenerating || !!c.preview_error" x-show="c.preview && !c.preview_error">
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
<button type="button" class="danger" @click="deleteConfiguration(c.id)">Löschen</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<p class="msg-err" x-show="c.preview_error" x-text="c.preview_error"></p>
|
<p class="msg-err" x-show="c.preview_error" x-text="c.preview_error"></p>
|
||||||
<div class="layout-preview-wrap" x-show="c.preview && !c.preview_error">
|
<div class="layout-preview-wrap" x-show="c.preview && !c.preview_error">
|
||||||
@ -486,6 +505,7 @@
|
|||||||
},
|
},
|
||||||
layoutPreview: null,
|
layoutPreview: null,
|
||||||
layoutLoading: false,
|
layoutLoading: false,
|
||||||
|
pdfGenerating: false,
|
||||||
_layoutTimer: null,
|
_layoutTimer: null,
|
||||||
|
|
||||||
apiUrl(path) {
|
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) {
|
async deleteConfiguration(id) {
|
||||||
if (!confirm('Konfiguration wirklich löschen?')) return;
|
if (!confirm('Konfiguration wirklich löschen?')) return;
|
||||||
this.configError = '';
|
this.configError = '';
|
||||||
|
|||||||
@ -18,6 +18,7 @@ func registerConfigurationRoutes(mux *http.ServeMux, configs *store.Configuratio
|
|||||||
mux.HandleFunc("DELETE /configurations/{id}", deleteConfiguration(configs))
|
mux.HandleFunc("DELETE /configurations/{id}", deleteConfiguration(configs))
|
||||||
mux.HandleFunc("GET /configurations/{id}/preview", previewConfiguration(configs, plates, items))
|
mux.HandleFunc("GET /configurations/{id}/preview", previewConfiguration(configs, plates, items))
|
||||||
mux.HandleFunc("GET /layout/preview", layoutPreview(plates, items))
|
mux.HandleFunc("GET /layout/preview", layoutPreview(plates, items))
|
||||||
|
registerPDFRoutes(mux, configs, plates, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
type configurationResponse struct {
|
type configurationResponse struct {
|
||||||
|
|||||||
120
internal/server/api/pdf.go
Normal file
120
internal/server/api/pdf.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user