Compare commits

..

10 Commits

38 changed files with 4533 additions and 304 deletions

110
README.md Normal file
View File

@ -0,0 +1,110 @@
# Alox printer backend
HTTP backend and admin UI for managing print plates, item SVG templates, layout configurations, and PDF previews.
## Requirements
| Requirement | Required for | Notes |
|-------------|--------------|-------|
| **Go 1.26+** | Build and run | See `go.mod` for the exact toolchain version. |
| **`rsvg-convert`** | PDF export only | Part of [librsvg](https://wiki.gnome.org/Projects/LibRsvg). The server starts without it; PDF endpoints return an error until it is installed and on `PATH`. |
### Installing `rsvg-convert`
Examples:
```bash
# Debian / Ubuntu
sudo apt install librsvg2-bin
# Fedora
sudo dnf install librsvg2-tools
# Arch / Manjaro
sudo pacman -S librsvg
# macOS (Homebrew)
brew install librsvg
```
Verify:
```bash
rsvg-convert --version
```
## Build
From the repository root:
```bash
go build -o printer-backend .
```
## Run
Start the API and admin servers from the **repository root** (or any directory where you want the `data/` folder to live):
```bash
./printer-backend serve
```
Defaults (no config file):
- **API:** `http://127.0.0.1:8080`
- **Admin dashboard:** `http://127.0.0.1:8081`
Stop with `Ctrl+C`.
### Optional configuration
Copy `config.example.json` and pass it with `--config`:
```bash
cp config.example.json config.json
./printer-backend serve --config config.json
```
| Field | Default | Description |
|-------|---------|-------------|
| `host` | `127.0.0.1` | Bind address for both servers |
| `api_port` | `8080` | REST API port |
| `admin_port` | `8081` | Admin UI port |
Legacy field `port` is still accepted and maps to `api_port` when `api_port` is unset.
## Data directory
All runtime data is stored under `data/` relative to the **current working directory**:
```
data/
plates/ # plate definitions (JSON)
svg_template/ # item SVG files and metadata
configurations/ # plate + item + spacing presets (JSON)
orders/ # orders (JSON) and uploaded images per order
```
Directories are created automatically when you save plates, items, configurations, or orders. Run `serve` from the project root if you want to use the bundled sample data.
## Other commands
```bash
# Print version
./printer-backend version
# Generate an SVG item template (writes to data/svg_template/)
./printer-backend template -o my_item.svg -w 100 -H 50 -r 8 -b 2 -m 5 -p 3
# Square shortcut: -s 80 sets width and height
./printer-backend template -o square.svg -s 80 -r 5 -b 2 -m 5 -p 3
```
## Quick check
After starting the server:
```bash
curl -s http://127.0.0.1:8080/health
```
Open the admin UI in a browser at `http://127.0.0.1:8081`.

View File

@ -12,6 +12,9 @@ import (
var (
sizeFlag float64
widthFlag float64
heightFlag float64
cornerRadiusFlag float64
bleedFlag float64
marginFlag float64
paddingFlag float64
@ -20,9 +23,26 @@ var (
var generateTemplateCmd = &cobra.Command{
Use: "template",
Short: "Generate a square SVG mask template with bleed and margins",
Short: "Generate an SVG mask template with bleed and margins",
RunE: func(cmd *cobra.Command, args []string) error {
data := svgtemplate.Build(sizeFlag, bleedFlag, marginFlag, paddingFlag)
spec := model.ItemSpec{
SizeMM: sizeFlag,
WidthMM: widthFlag,
HeightMM: heightFlag,
CornerRadiusMM: cornerRadiusFlag,
BleedMM: bleedFlag,
MarginMM: marginFlag,
PaddingMM: paddingFlag,
}
if err := spec.Normalize(); err != nil {
return err
}
data := svgtemplate.Build(
spec.WidthMM, spec.HeightMM,
spec.BleedMM, spec.MarginMM, spec.PaddingMM,
spec.CornerRadiusMM,
)
base := filepath.Base(outputFlag)
outPath := filepath.Join(svgtemplate.OutputDir, base)
@ -30,12 +50,6 @@ var generateTemplateCmd = &cobra.Command{
return fmt.Errorf("write svg: %w", err)
}
spec := model.ItemSpec{
SizeMM: sizeFlag,
BleedMM: bleedFlag,
MarginMM: marginFlag,
PaddingMM: paddingFlag,
}
name := strings.TrimSuffix(base, filepath.Ext(base))
item, err := svgtemplate.WriteMeta(base, spec, name)
if err != nil {
@ -51,7 +65,10 @@ var generateTemplateCmd = &cobra.Command{
func init() {
rootCmd.AddCommand(generateTemplateCmd)
generateTemplateCmd.Flags().Float64VarP(&sizeFlag, "size", "s", 100.0, "Size of the final square product in mm")
generateTemplateCmd.Flags().Float64VarP(&sizeFlag, "size", "s", 0, "Square product size in mm (sets width and height)")
generateTemplateCmd.Flags().Float64VarP(&widthFlag, "width", "w", 0, "Product width in mm")
generateTemplateCmd.Flags().Float64VarP(&heightFlag, "height", "H", 0, "Product height in mm")
generateTemplateCmd.Flags().Float64VarP(&cornerRadiusFlag, "radius", "r", 0, "Corner radius in mm")
generateTemplateCmd.Flags().Float64VarP(&bleedFlag, "bleed", "b", 2.0, "Bleed (Überdruck) in mm")
generateTemplateCmd.Flags().Float64VarP(&marginFlag, "margin", "m", 5.0, "Margin to the outer elements in mm")
generateTemplateCmd.Flags().Float64VarP(&paddingFlag, "padding", "p", 3.0, "Inner padding (safety margin) in mm")

7
go.mod
View File

@ -2,10 +2,13 @@ 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
golang.org/x/image v0.41.0 // indirect
)

2
go.sum
View File

@ -9,4 +9,6 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -0,0 +1,57 @@
package itemimage
import (
"encoding/base64"
"fmt"
"net/http"
"regexp"
"strings"
"printer.backend/internal/model"
"printer.backend/internal/svgtemplate"
)
var maskGroupRE = regexp.MustCompile(`(?s)(<g clip-path="url\(#item-mask\)">).*?(</g>)`)
// Embed replaces the green placeholder in an item SVG with the given raster image,
// clipped to the product mask (same area as the green preview rect).
func Embed(itemSVG []byte, spec model.ItemSpec, imageData []byte, contentType string) ([]byte, error) {
if len(imageData) == 0 {
return nil, fmt.Errorf("empty image data")
}
if err := spec.Normalize(); err != nil {
return nil, err
}
d := svgtemplate.Build(
spec.WidthMM, spec.HeightMM,
spec.BleedMM, spec.MarginMM, spec.PaddingMM,
spec.CornerRadiusMM,
)
prepared, mime, err := prepareRaster(imageData, d)
if err != nil {
return nil, err
}
if mime == "" {
mime = normalizeMIME(contentType, prepared)
}
href := fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(prepared))
inner := fmt.Sprintf(
`<image x="%.4f" y="%.4f" width="%.4f" height="%.4f" preserveAspectRatio="xMidYMid slice" href="%s"/>`,
d.OuterOffsetX, d.OuterOffsetY, d.OuterWidth, d.OuterHeight, href,
)
out := maskGroupRE.ReplaceAll(itemSVG, []byte("${1}"+inner+"${2}"))
if string(out) == string(itemSVG) {
return nil, fmt.Errorf("item svg has no printable mask group")
}
return out, nil
}
func normalizeMIME(contentType string, data []byte) string {
if ct := strings.TrimSpace(strings.Split(contentType, ";")[0]); ct != "" {
return ct
}
return http.DetectContentType(data)
}

View File

@ -0,0 +1,78 @@
package itemimage
import (
"bytes"
"image"
"image/color"
"image/png"
"strings"
"testing"
"printer.backend/internal/model"
)
const sampleItemSVG = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" 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>
<g clip-path="url(#item-mask)">
<rect x="-2" y="-2" width="84" height="84" fill="#00FF00" opacity="0.7" />
</g>
</svg>`
func testPNG(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
}
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
func TestEmbedReplacesGreenMask(t *testing.T) {
spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3}
out, err := Embed([]byte(sampleItemSVG), spec, testPNG(1, 1), "image/png")
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "#00FF00") || strings.Contains(s, "#00ff00") {
t.Fatal("green placeholder should be replaced")
}
if !strings.Contains(s, `<image `) || !strings.Contains(s, `preserveAspectRatio="xMidYMid slice"`) {
t.Fatalf("expected embedded image, got: %s", s)
}
if !strings.Contains(s, `x="-2.0000"`) {
t.Fatal("expected bleed-aligned image placement")
}
if !strings.Contains(s, "data:image/jpeg;base64,") {
t.Fatal("expected downscaled jpeg data uri")
}
}
func TestEmbedDownscalesLargeImage(t *testing.T) {
spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3}
huge := testPNG(4000, 3000)
out, err := Embed([]byte(sampleItemSVG), spec, huge, "image/png")
if err != nil {
t.Fatal(err)
}
const maxSVG = 600_000 // ~600 KB embedded payload is plenty for rsvg
if len(out) > maxSVG {
t.Fatalf("embedded svg too large: %d bytes", len(out))
}
}
func TestEmbedInvalidSVG(t *testing.T) {
spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5}
_, err := Embed([]byte("<svg></svg>"), spec, testPNG(1, 1), "image/png")
if err == nil {
t.Fatal("expected error")
}
}

View File

@ -0,0 +1,84 @@
package itemimage
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"math"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"printer.backend/internal/svgtemplate"
)
// EmbedDPI matches plate PDF rasterization; embedded pixels need not exceed this resolution.
const EmbedDPI = 300
const jpegEmbedQuality = 88
// prepareRaster decodes and downscales image data so the resulting SVG stays small enough for rsvg-convert.
func prepareRaster(data []byte, d svgtemplate.Data) ([]byte, string, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, "", fmt.Errorf("decode image: %w", err)
}
maxW := mmToPx(d.OuterWidth)
maxH := mmToPx(d.OuterHeight)
if maxW < 1 {
maxW = 1
}
if maxH < 1 {
maxH = 1
}
covered := scaleCover(img, maxW, maxH)
rgba := image.NewNRGBA(image.Rect(0, 0, maxW, maxH))
draw.CatmullRom.Scale(rgba, rgba.Bounds(), covered, covered.Bounds(), draw.Over, nil)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, rgba, &jpeg.Options{Quality: jpegEmbedQuality}); err != nil {
return nil, "", fmt.Errorf("encode jpeg: %w", err)
}
return buf.Bytes(), "image/jpeg", nil
}
func mmToPx(mm float64) int {
return int(math.Ceil(mm / 25.4 * EmbedDPI))
}
// scaleCover returns an image scaled to cover dw×dh (center crop), for preserveAspectRatio slice.
func scaleCover(src image.Image, dw, dh int) image.Image {
sb := src.Bounds()
sw, sh := sb.Dx(), sb.Dy()
if sw <= 0 || sh <= 0 {
return image.NewNRGBA(image.Rect(0, 0, dw, dh))
}
scale := math.Max(float64(dw)/float64(sw), float64(dh)/float64(sh))
nw := int(math.Ceil(float64(sw) * scale))
nh := int(math.Ceil(float64(sh) * scale))
scaled := image.NewNRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(scaled, scaled.Bounds(), src, sb, draw.Over, nil)
x0 := (nw - dw) / 2
y0 := (nh - dh) / 2
if x0 < 0 {
x0 = 0
}
if y0 < 0 {
y0 = 0
}
if nw < dw {
dw = nw
}
if nh < dh {
dh = nh
}
cropped := image.NewNRGBA(image.Rect(0, 0, dw, dh))
draw.Draw(cropped, cropped.Bounds(), scaled, image.Point{x0, y0}, draw.Src)
return cropped
}

70
internal/layout/layout.go Normal file
View File

@ -0,0 +1,70 @@
package layout
import (
"printer.backend/internal/model"
)
// Pack computes the maximum grid of items that fit on the plate.
// Cells use the full item SVG canvas (product + bleed, margin, crop marks); spacing is between canvases.
func Pack(plate model.Plate, spec model.ItemSpec, spacingMM float64) model.LayoutPreview {
pw := plate.PrintableWidth()
ph := plate.PrintableHeight()
footW := spec.ViewportWidth()
footH := spec.ViewportHeight()
canvasW := spec.CanvasWidthMM()
canvasH := spec.CanvasHeightMM()
trim := spec.TrimOffsetMM()
cw := canvasW + spacingMM
ch := canvasH + spacingMM
cols, rows := gridCount(pw, ph, cw, ch)
count := cols * rows
ox := plate.MarginLeft
oy := plate.MarginTop
positions := make([]model.LayoutPosition, 0, count)
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
positions = append(positions, model.LayoutPosition{
XMM: ox + float64(col)*cw,
YMM: oy + float64(row)*ch,
})
}
}
return model.LayoutPreview{
PlateWidthMM: plate.WidthMM,
PlateHeightMM: plate.HeightMM,
PrintableXMM: ox,
PrintableYMM: oy,
PrintableWMM: pw,
PrintableHMM: ph,
CellWidthMM: cw,
CellHeightMM: ch,
FootprintWMM: footW,
FootprintHMM: footH,
TrimOffsetMM: trim,
CanvasWidthMM: canvasW,
CanvasHeightMM: canvasH,
Columns: cols,
Rows: rows,
Count: count,
Positions: positions,
}
}
func gridCount(printableW, printableH, cellW, cellH float64) (cols, rows int) {
if printableW <= 0 || printableH <= 0 || cellW <= 0 || cellH <= 0 {
return 0, 0
}
cols = int(printableW / cellW)
rows = int(printableH / cellH)
if cols < 0 {
cols = 0
}
if rows < 0 {
rows = 0
}
return cols, rows
}

View File

@ -0,0 +1,71 @@
package layout
import (
"testing"
"printer.backend/internal/model"
)
func TestPack(t *testing.T) {
plate := model.Plate{
WidthMM: 300,
HeightMM: 400,
MarginTop: 10,
MarginRight: 10,
MarginBottom: 10,
MarginLeft: 10,
}
spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5}
// canvas 106mm (80+2*13), cell 108, printable 280×380 → cols=2, rows=3
preview := Pack(plate, spec, 2)
if preview.Columns != 2 {
t.Fatalf("columns: got %d want 2", preview.Columns)
}
if preview.Rows != 3 {
t.Fatalf("rows: got %d want 3", preview.Rows)
}
if preview.Count != 6 {
t.Fatalf("count: got %d want 6", preview.Count)
}
if preview.FootprintWMM != 80 {
t.Fatalf("footprint width: got %v want 80", preview.FootprintWMM)
}
if preview.CanvasWidthMM != 106 {
t.Fatalf("canvas width: got %v want 106", preview.CanvasWidthMM)
}
if len(preview.Positions) != 6 {
t.Fatalf("positions: got %d want 6", 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)
}
}
func TestPackRectangle(t *testing.T) {
plate := model.Plate{
WidthMM: 300,
HeightMM: 400,
MarginTop: 10,
MarginRight: 10,
MarginBottom: 10,
MarginLeft: 10,
}
// 100×50 mm, trim 6 → canvas 112×62, cell 114×64, printable 280×380 → cols=2, rows=5
spec := model.ItemSpec{WidthMM: 100, HeightMM: 50, BleedMM: 0, MarginMM: 0}
preview := Pack(plate, spec, 2)
if preview.Columns != 2 || preview.Rows != 5 || preview.Count != 10 {
t.Fatalf("got %d cols, %d rows, %d count; want 2, 5, 10", preview.Columns, preview.Rows, preview.Count)
}
if preview.FootprintWMM != 100 || preview.FootprintHMM != 50 {
t.Fatalf("footprint: got %.0f×%.0f want 100×50", preview.FootprintWMM, preview.FootprintHMM)
}
}
func TestPackZeroCell(t *testing.T) {
plate := model.Plate{WidthMM: 10, HeightMM: 10}
spec := model.ItemSpec{SizeMM: 100}
preview := Pack(plate, spec, 0)
if preview.Count != 0 {
t.Fatalf("expected 0 items, got %d", preview.Count)
}
}

View File

@ -0,0 +1,43 @@
package model
import "time"
// Configuration links a plate and item with layout spacing for maximum packing.
type Configuration struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
PlateID string `json:"plate_id"`
ItemID string `json:"item_id"`
SpacingMM float64 `json:"spacing_mm"`
CreatedAt time.Time `json:"created_at"`
}
// LayoutPosition is the top-left corner of one item on the plate (mm).
type LayoutPosition struct {
XMM float64 `json:"x_mm"`
YMM float64 `json:"y_mm"`
}
// LayoutPreview describes how many items fit on a plate.
type LayoutPreview struct {
PlateID string `json:"plate_id"`
ItemID string `json:"item_id"`
SpacingMM float64 `json:"spacing_mm"`
PlateWidthMM float64 `json:"plate_width_mm"`
PlateHeightMM float64 `json:"plate_height_mm"`
PrintableXMM float64 `json:"printable_x_mm"`
PrintableYMM float64 `json:"printable_y_mm"`
PrintableWMM float64 `json:"printable_width_mm"`
PrintableHMM float64 `json:"printable_height_mm"`
CellWidthMM float64 `json:"cell_width_mm"`
CellHeightMM float64 `json:"cell_height_mm"`
FootprintWMM float64 `json:"footprint_width_mm"`
FootprintHMM float64 `json:"footprint_height_mm"`
TrimOffsetMM float64 `json:"trim_offset_mm"`
CanvasWidthMM float64 `json:"canvas_width_mm"`
CanvasHeightMM float64 `json:"canvas_height_mm"`
Columns int `json:"columns"`
Rows int `json:"rows"`
Count int `json:"count"`
Positions []LayoutPosition `json:"positions"`
}

View File

@ -1,15 +1,76 @@
package model
import "time"
import (
"fmt"
"math"
"time"
)
// ItemSpec holds mask parameters (from template CLI).
type ItemSpec struct {
SizeMM float64 `json:"size_mm"`
// SizeMM is legacy: square product when width_mm/height_mm are unset.
SizeMM float64 `json:"size_mm,omitempty"`
WidthMM float64 `json:"width_mm,omitempty"`
HeightMM float64 `json:"height_mm,omitempty"`
CornerRadiusMM float64 `json:"corner_radius_mm,omitempty"`
BleedMM float64 `json:"bleed_mm"`
MarginMM float64 `json:"margin_mm"`
PaddingMM float64 `json:"padding_mm"`
}
// ViewportWidth returns the SVG viewport width used for layout packing.
func (s ItemSpec) ViewportWidth() float64 {
if s.WidthMM > 0 {
return s.WidthMM
}
return s.SizeMM
}
// ViewportHeight returns the SVG viewport height used for layout packing.
func (s ItemSpec) ViewportHeight() float64 {
if s.HeightMM > 0 {
return s.HeightMM
}
return s.SizeMM
}
// markLengthMM is crop-mark extension outside the product (must match svgtemplate).
const markLengthMM = 6
// TrimOffsetMM is bleed + margin + crop marks drawn outside the product rect.
func (s ItemSpec) TrimOffsetMM() float64 {
return s.BleedMM + s.MarginMM + markLengthMM
}
// CanvasWidthMM is the full item SVG width including trim and crop marks.
func (s ItemSpec) CanvasWidthMM() float64 {
return s.ViewportWidth() + 2*s.TrimOffsetMM()
}
// CanvasHeightMM is the full item SVG height including trim and crop marks.
func (s ItemSpec) CanvasHeightMM() float64 {
return s.ViewportHeight() + 2*s.TrimOffsetMM()
}
// Normalize fills width/height from legacy size_mm and validates dimensions.
func (s *ItemSpec) Normalize() error {
if s.WidthMM <= 0 && s.HeightMM <= 0 && s.SizeMM > 0 {
s.WidthMM = s.SizeMM
s.HeightMM = s.SizeMM
}
if s.WidthMM <= 0 || s.HeightMM <= 0 {
return fmt.Errorf("width_mm and height_mm must be positive")
}
if s.CornerRadiusMM < 0 {
return fmt.Errorf("corner_radius_mm must be non-negative")
}
maxR := math.Min(s.WidthMM, s.HeightMM) / 2
if s.CornerRadiusMM > maxR {
return fmt.Errorf("corner_radius_mm must be at most %.2f", maxR)
}
return nil
}
// Item is a printable product type (SVG mask) placed on a plate.
type Item struct {
ID string `json:"id"`

24
internal/model/order.go Normal file
View File

@ -0,0 +1,24 @@
package model
import "time"
// OrderImage is one uploaded image belonging to an order.
type OrderImage struct {
ID string `json:"id"`
OriginalName string `json:"original_name,omitempty"`
ContentType string `json:"content_type,omitempty"`
Filename string `json:"filename"`
CreatedAt time.Time `json:"created_at"`
}
// Order groups an arbitrary number of customer images for printing.
type Order struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Printed bool `json:"printed"`
Shipped bool `json:"shipped"`
Ref string `json:"ref,omitempty"`
Images []OrderImage `json:"images"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -0,0 +1,36 @@
package model
import "time"
// PrintJob groups a configuration with one or more orders for plate printing.
type PrintJob struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
ConfigurationID string `json:"configuration_id"`
OrderIDs []string `json:"order_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PrintJobImageRef identifies one order image assigned to a layout slot.
type PrintJobImageRef struct {
OrderID string `json:"order_id"`
ImageID string `json:"image_id"`
}
// PrintJobSlotAssignment maps one plate item position to a customer image.
type PrintJobSlotAssignment struct {
Slot int `json:"slot"`
Image PrintJobImageRef `json:"image"`
Position LayoutPosition `json:"position"`
}
// PrintJobSummary describes image distribution and validation for a print job.
type PrintJobSummary struct {
ImageCount int `json:"image_count"`
SlotCount int `json:"slot_count"`
ImageOverflow bool `json:"image_overflow"`
Warning string `json:"warning,omitempty"`
Assignments []PrintJobSlotAssignment `json:"assignments"`
Preview LayoutPreview `json:"preview"`
}

View File

@ -5,4 +5,7 @@ const (
DataDir = "data"
PlatesDir = "data/plates"
SVGTemplateDir = "data/svg_template"
ConfigurationsDir = "data/configurations"
OrdersDir = "data/orders"
PrintJobsDir = "data/print_jobs"
)

View File

@ -0,0 +1,66 @@
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) {
slotSVGs := make([][]byte, len(preview.Positions))
for i := range preview.Positions {
slotSVGs[i] = itemSVG
}
return BuildCompositeSVGSlots(plate, spec, slotSVGs, preview)
}
// BuildCompositeSVGSlots places one rendered item PNG per layout position.
// len(slotSVGs) must equal len(preview.Positions).
func BuildCompositeSVGSlots(plate model.Plate, spec model.ItemSpec, slotSVGs [][]byte, preview model.LayoutPreview) ([]byte, error) {
if len(slotSVGs) != len(preview.Positions) {
return nil, fmt.Errorf("slot svg count %d does not match positions %d", len(slotSVGs), len(preview.Positions))
}
canvasW := preview.CanvasWidthMM
canvasH := preview.CanvasHeightMM
if canvasW <= 0 || canvasH <= 0 {
canvasW = spec.CanvasWidthMM()
canvasH = spec.CanvasHeightMM()
}
slotPNGs := make([]string, len(slotSVGs))
for i, svg := range slotSVGs {
itemPNG, err := svgToPNGViaRsvg(svg, renderDPI)
if err != nil {
return nil, fmt.Errorf("render item slot %d: %w", i, err)
}
slotPNGs[i] = 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 i, 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, canvasW, canvasH, slotPNGs[i],
)
}
b.WriteString("\n</svg>")
return b.Bytes(), nil
}

View File

@ -0,0 +1,49 @@
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
}
// GenerateWithSlots builds a PDF with a distinct item SVG per layout slot.
// Requires rsvg-convert on PATH.
func GenerateWithSlots(plate model.Plate, spec model.ItemSpec, slotSVGs [][]byte, preview model.LayoutPreview) ([]byte, error) {
if !rsvgAvailable() {
return nil, fmt.Errorf("rsvg-convert not found in PATH (required for PDF export)")
}
composite, err := BuildCompositeSVGSlots(plate, spec, slotSVGs, 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
}

View File

@ -0,0 +1,119 @@
package platepdf
import (
"bytes"
"fmt"
"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{WidthMM: 80, HeightMM: 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 (canvas origin)")
}
if preview.Count > 0 && !strings.Contains(s, fmt.Sprintf(`width="%.4f"`, preview.CanvasWidthMM)) {
t.Fatal("expected full canvas width on placed images")
}
}
func TestBuildCompositeSVGInvalid(t *testing.T) {
requireRsvg(t)
preview := model.LayoutPreview{
Positions: []model.LayoutPosition{{XMM: 0, YMM: 0}},
CanvasWidthMM: 10,
CanvasHeightMM: 10,
}
_, err := BuildCompositeSVG(model.Plate{}, model.ItemSpec{WidthMM: 10, HeightMM: 10}, []byte("not svg"), preview)
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{WidthMM: 80, HeightMM: 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.WidthMM, spec.HeightMM,
spec.BleedMM, spec.MarginMM, spec.PaddingMM,
spec.CornerRadiusMM,
)); 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
View 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
}

View 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)
}

View 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)
}
}

View File

@ -0,0 +1,63 @@
package printjob
import (
"fmt"
"printer.backend/internal/model"
)
// CollectImageRefs returns all order images in job order (orders, then images within each order).
func CollectImageRefs(orders []model.Order) []model.PrintJobImageRef {
var refs []model.PrintJobImageRef
for _, o := range orders {
for _, img := range o.Images {
refs = append(refs, model.PrintJobImageRef{
OrderID: o.ID,
ImageID: img.ID,
})
}
}
return refs
}
// AssignSlots maps images to layout slots (at most one image per slot).
func AssignSlots(refs []model.PrintJobImageRef, preview model.LayoutPreview) []model.PrintJobSlotAssignment {
limit := len(preview.Positions)
if len(refs) < limit {
limit = len(refs)
}
out := make([]model.PrintJobSlotAssignment, 0, limit)
for i := 0; i < limit; i++ {
out = append(out, model.PrintJobSlotAssignment{
Slot: i,
Image: refs[i],
Position: preview.Positions[i],
})
}
return out
}
// Summary builds validation and slot assignments for a print job.
func Summary(orders []model.Order, preview model.LayoutPreview) model.PrintJobSummary {
refs := CollectImageRefs(orders)
slots := preview.Count
imageCount := len(refs)
overflow := imageCount > slots
var warning string
if overflow {
warning = fmt.Sprintf(
"%d Bilder für %d Item-Positionen: %d Bilder werden nicht platziert",
imageCount, slots, imageCount-slots,
)
}
return model.PrintJobSummary{
ImageCount: imageCount,
SlotCount: slots,
ImageOverflow: overflow,
Warning: warning,
Assignments: AssignSlots(refs, preview),
Preview: preview,
}
}

View File

@ -0,0 +1,56 @@
package printjob
import (
"strings"
"testing"
"printer.backend/internal/layout"
"printer.backend/internal/model"
)
func TestSummaryOverflowWarning(t *testing.T) {
plate := model.Plate{
WidthMM: 100, HeightMM: 100,
MarginTop: 5, MarginRight: 5, MarginBottom: 5, MarginLeft: 5,
}
spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3}
preview := layout.Pack(plate, spec, 0)
orders := []model.Order{
{ID: "o1", Images: []model.OrderImage{{ID: "a"}, {ID: "b"}}},
{ID: "o2", Images: []model.OrderImage{{ID: "c"}}},
}
s := Summary(orders, preview)
if preview.Count == 0 {
if s.ImageCount != 3 {
t.Fatalf("expected 3 images, got %d", s.ImageCount)
}
return
}
if preview.Count >= 3 {
if s.ImageOverflow {
t.Fatal("expected no overflow")
}
return
}
if !s.ImageOverflow || s.Warning == "" {
t.Fatalf("expected overflow warning, got overflow=%v warning=%q", s.ImageOverflow, s.Warning)
}
if !strings.Contains(s.Warning, "Bilder") {
t.Fatalf("unexpected warning: %s", s.Warning)
}
if len(s.Assignments) != preview.Count {
t.Fatalf("expected %d assignments, got %d", preview.Count, len(s.Assignments))
}
}
func TestCollectImageRefsOrder(t *testing.T) {
orders := []model.Order{
{ID: "o1", Images: []model.OrderImage{{ID: "a"}, {ID: "b"}}},
{ID: "o2", Images: []model.OrderImage{{ID: "c"}}},
}
refs := CollectImageRefs(orders)
if len(refs) != 3 || refs[0].ImageID != "a" || refs[2].OrderID != "o2" {
t.Fatalf("unexpected refs: %+v", refs)
}
}

124
internal/printjob/pdf.go Normal file
View File

@ -0,0 +1,124 @@
package printjob
import (
"fmt"
"os"
"printer.backend/internal/itemimage"
"printer.backend/internal/layout"
"printer.backend/internal/model"
"printer.backend/internal/platepdf"
"printer.backend/internal/store"
)
// RenderPDF builds a plate PDF with customer images placed in each item mask.
func RenderPDF(
job model.PrintJob,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) ([]byte, model.PrintJobSummary, error) {
cfg, err := configs.Get(job.ConfigurationID)
if err != nil {
return nil, model.PrintJobSummary{}, fmt.Errorf("configuration: %w", err)
}
plate, err := findPlate(plates, cfg.PlateID)
if err != nil {
return nil, model.PrintJobSummary{}, err
}
item, err := items.Get(cfg.ItemID)
if err != nil {
return nil, model.PrintJobSummary{}, fmt.Errorf("item: %w", err)
}
itemSVGPath, err := items.SVGPath(cfg.ItemID)
if err != nil {
return nil, model.PrintJobSummary{}, err
}
itemSVG, err := os.ReadFile(itemSVGPath)
if err != nil {
return nil, model.PrintJobSummary{}, fmt.Errorf("read item svg: %w", err)
}
orderList, err := loadOrders(orders, job.OrderIDs)
if err != nil {
return nil, model.PrintJobSummary{}, err
}
preview := layout.Pack(plate, item.Spec, cfg.SpacingMM)
preview.PlateID = cfg.PlateID
preview.ItemID = cfg.ItemID
summary := Summary(orderList, preview)
slotSVGs, err := buildSlotSVGs(itemSVG, item.Spec, preview, summary.Assignments, orders)
if err != nil {
return nil, summary, err
}
pdf, err := platepdf.GenerateWithSlots(plate, item.Spec, slotSVGs, preview)
if err != nil {
return nil, summary, err
}
return pdf, summary, nil
}
func buildSlotSVGs(
templateSVG []byte,
spec model.ItemSpec,
preview model.LayoutPreview,
assignments []model.PrintJobSlotAssignment,
orders *store.OrderStore,
) ([][]byte, error) {
slotSVGs := make([][]byte, len(preview.Positions))
for i := range slotSVGs {
slotSVGs[i] = templateSVG
}
for _, a := range assignments {
if a.Slot < 0 || a.Slot >= len(slotSVGs) {
return nil, fmt.Errorf("slot %d out of range", a.Slot)
}
path, img, err := orders.ImagePath(a.Image.OrderID, a.Image.ImageID)
if err != nil {
return nil, fmt.Errorf("slot %d: %w", a.Slot, err)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("slot %d: read image: %w", a.Slot, err)
}
embedded, err := itemimage.Embed(templateSVG, spec, data, img.ContentType)
if err != nil {
return nil, fmt.Errorf("slot %d: %w", a.Slot, err)
}
slotSVGs[a.Slot] = embedded
}
return slotSVGs, nil
}
func loadOrders(orders *store.OrderStore, ids []string) ([]model.Order, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("at least one order_id is required")
}
out := make([]model.Order, 0, len(ids))
for _, id := range ids {
o, err := orders.Get(id)
if err != nil {
return nil, fmt.Errorf("order %s: %w", id, err)
}
out = append(out, o)
}
return out, nil
}
func findPlate(plates *store.PlateStore, id string) (model.Plate, error) {
list, err := plates.List()
if err != nil {
return model.Plate{}, err
}
for _, p := range list {
if p.ID == id {
return p, nil
}
}
return model.Plate{}, fmt.Errorf("plate not found: %s", id)
}

File diff suppressed because it is too large Load Diff

View File

@ -15,24 +15,32 @@ import (
func NewHandler() http.Handler {
plates := store.NewPlateStore("")
items := store.NewItemStore("")
configs := store.NewConfigurationStore("")
orders := store.NewOrderStore("")
printJobs := store.NewPrintJobStore("")
mux := http.NewServeMux()
mux.HandleFunc("GET /health", health)
mux.HandleFunc("GET /", root)
mux.HandleFunc("GET /plates", listPlates(plates))
mux.HandleFunc("POST /plates", savePlate(plates))
mux.HandleFunc("PUT /plates/{id}", updatePlate(plates))
mux.HandleFunc("DELETE /plates/{id}", deletePlate(plates))
mux.HandleFunc("GET /items", listItems(items))
mux.HandleFunc("POST /items", saveItem(items))
mux.HandleFunc("PUT /items/{id}", updateItem(items))
mux.HandleFunc("DELETE /items/{id}", deleteItem(items))
mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items))
registerConfigurationRoutes(mux, configs, plates, items)
registerOrderRoutes(mux, orders)
registerPrintJobRoutes(mux, printJobs, configs, plates, items, orders)
return withCORS(mux)
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
@ -109,6 +117,40 @@ func savePlate(s *store.PlateStore) http.HandlerFunc {
}
}
func updatePlate(s *store.PlateStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, errors.New("id required"))
return
}
var req savePlateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.WidthMM <= 0 || req.HeightMM <= 0 {
writeError(w, http.StatusBadRequest, errors.New("width_mm and height_mm must be positive"))
return
}
p := model.Plate{
Name: req.Name,
WidthMM: req.WidthMM,
HeightMM: req.HeightMM,
MarginTop: req.MarginTop,
MarginRight: req.MarginRight,
MarginBottom: req.MarginBottom,
MarginLeft: req.MarginLeft,
}
saved, err := s.Update(id, p)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, saved)
}
}
func deletePlate(s *store.PlateStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
@ -138,14 +180,6 @@ func listItems(s *store.ItemStore) http.HandlerFunc {
}
}
type saveItemRequest struct {
Name string `json:"name"`
SizeMM float64 `json:"size_mm"`
BleedMM float64 `json:"bleed_mm"`
MarginMM float64 `json:"margin_mm"`
PaddingMM float64 `json:"padding_mm"`
}
func saveItem(s *store.ItemStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req saveItemRequest
@ -153,11 +187,10 @@ func saveItem(s *store.ItemStore) http.HandlerFunc {
writeError(w, http.StatusBadRequest, err)
return
}
spec := model.ItemSpec{
SizeMM: req.SizeMM,
BleedMM: req.BleedMM,
MarginMM: req.MarginMM,
PaddingMM: req.PaddingMM,
spec, err := itemSpecFromRequest(req)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Create(req.Name, spec)
if err != nil {
@ -168,6 +201,32 @@ func saveItem(s *store.ItemStore) http.HandlerFunc {
}
}
func updateItem(s *store.ItemStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, errors.New("id required"))
return
}
var req saveItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
spec, err := itemSpecFromRequest(req)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Update(id, req.Name, spec)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, saved)
}
}
func deleteItem(s *store.ItemStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")

View File

@ -0,0 +1,270 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"printer.backend/internal/layout"
"printer.backend/internal/model"
"printer.backend/internal/store"
)
func registerConfigurationRoutes(mux *http.ServeMux, configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) {
mux.HandleFunc("GET /configurations", listConfigurations(configs, plates, items))
mux.HandleFunc("POST /configurations", saveConfiguration(configs, plates, items))
mux.HandleFunc("PUT /configurations/{id}", updateConfiguration(configs, plates, items))
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 {
model.Configuration
Preview *model.LayoutPreview `json:"preview,omitempty"`
PreviewError string `json:"preview_error,omitempty"`
}
func listConfigurations(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
list, err := configs.List()
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
if list == nil {
list = []model.Configuration{}
}
out := make([]configurationResponse, 0, len(list))
for _, c := range list {
out = append(out, configurationResponseFor(c, plates, items))
}
writeJSON(w, http.StatusOK, out)
}
}
type saveConfigurationRequest struct {
Name string `json:"name"`
PlateID string `json:"plate_id"`
ItemID string `json:"item_id"`
SpacingMM float64 `json:"spacing_mm"`
}
func saveConfiguration(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req saveConfigurationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.PlateID == "" || req.ItemID == "" {
writeError(w, http.StatusBadRequest, errors.New("plate_id and item_id are required"))
return
}
if _, err := findPlate(plates, req.PlateID); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if _, err := items.Get(req.ItemID); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.SpacingMM < 0 {
writeError(w, http.StatusBadRequest, errors.New("spacing_mm must be non-negative"))
return
}
c := model.Configuration{
Name: req.Name,
PlateID: req.PlateID,
ItemID: req.ItemID,
SpacingMM: req.SpacingMM,
CreatedAt: time.Now().UTC(),
}
saved, err := configs.Save(c)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
resp := configurationResponseFor(saved, plates, items)
if resp.PreviewError != "" {
writeError(w, http.StatusInternalServerError, errors.New(resp.PreviewError))
return
}
writeJSON(w, http.StatusCreated, resp)
}
}
func updateConfiguration(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, errors.New("id required"))
return
}
var req saveConfigurationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.PlateID == "" || req.ItemID == "" {
writeError(w, http.StatusBadRequest, errors.New("plate_id and item_id are required"))
return
}
if _, err := findPlate(plates, req.PlateID); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if _, err := items.Get(req.ItemID); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.SpacingMM < 0 {
writeError(w, http.StatusBadRequest, errors.New("spacing_mm must be non-negative"))
return
}
c := model.Configuration{
Name: req.Name,
PlateID: req.PlateID,
ItemID: req.ItemID,
SpacingMM: req.SpacingMM,
}
saved, err := configs.Update(id, c)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
resp := configurationResponseFor(saved, plates, items)
if resp.PreviewError != "" {
writeError(w, http.StatusInternalServerError, errors.New(resp.PreviewError))
return
}
writeJSON(w, http.StatusOK, resp)
}
}
func deleteConfiguration(configs *store.ConfigurationStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, errors.New("id required"))
return
}
if err := configs.Delete(id); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func previewConfiguration(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
}
preview, err := buildPreview(plates, items, c.PlateID, c.ItemID, c.SpacingMM)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
preview.PlateID = c.PlateID
preview.ItemID = c.ItemID
writeJSON(w, http.StatusOK, preview)
}
}
func layoutPreview(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
}
preview, err := buildPreview(plates, items, plateID, itemID, spacing)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
preview.PlateID = plateID
preview.ItemID = itemID
writeJSON(w, http.StatusOK, preview)
}
}
func configurationResponseFor(c model.Configuration, plates *store.PlateStore, items *store.ItemStore) configurationResponse {
resp := configurationResponse{Configuration: c}
preview, err := buildPreview(plates, items, c.PlateID, c.ItemID, c.SpacingMM)
if err != nil {
resp.PreviewError = err.Error()
return resp
}
preview.PlateID = c.PlateID
preview.ItemID = c.ItemID
resp.Preview = &preview
return resp
}
func buildPreview(plates *store.PlateStore, items *store.ItemStore, plateID, itemID string, spacingMM float64) (model.LayoutPreview, error) {
plate, err := findPlate(plates, plateID)
if err != nil {
return model.LayoutPreview{}, err
}
item, err := items.Get(itemID)
if err != nil {
return model.LayoutPreview{}, err
}
return layout.Pack(plate, item.Spec, spacingMM), nil
}
func findPlate(plates *store.PlateStore, id string) (model.Plate, error) {
list, err := plates.List()
if err != nil {
return model.Plate{}, err
}
plate, err := storeFindPlate(list, id)
if err != nil {
return model.Plate{}, errors.New("plate not found: " + id)
}
return plate, nil
}
func storeFindPlate(list []model.Plate, id string) (model.Plate, error) {
for _, p := range list {
if p.ID == id {
return p, nil
}
}
return model.Plate{}, errors.New("not found")
}
func parseSpacing(s string) (float64, error) {
if s == "" {
return 0, nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, errors.New("invalid spacing_mm")
}
if v < 0 {
return 0, errors.New("spacing_mm must be non-negative")
}
return v, nil
}

View File

@ -0,0 +1,30 @@
package api
import "printer.backend/internal/model"
type saveItemRequest struct {
Name string `json:"name"`
SizeMM float64 `json:"size_mm"`
WidthMM float64 `json:"width_mm"`
HeightMM float64 `json:"height_mm"`
CornerRadiusMM float64 `json:"corner_radius_mm"`
BleedMM float64 `json:"bleed_mm"`
MarginMM float64 `json:"margin_mm"`
PaddingMM float64 `json:"padding_mm"`
}
func itemSpecFromRequest(req saveItemRequest) (model.ItemSpec, error) {
spec := model.ItemSpec{
SizeMM: req.SizeMM,
WidthMM: req.WidthMM,
HeightMM: req.HeightMM,
CornerRadiusMM: req.CornerRadiusMM,
BleedMM: req.BleedMM,
MarginMM: req.MarginMM,
PaddingMM: req.PaddingMM,
}
if err := spec.Normalize(); err != nil {
return model.ItemSpec{}, err
}
return spec, nil
}

View File

@ -0,0 +1,225 @@
package api
import (
"encoding/json"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"printer.backend/internal/model"
"printer.backend/internal/store"
)
func registerOrderRoutes(mux *http.ServeMux, orders *store.OrderStore) {
mux.HandleFunc("GET /orders", listOrders(orders))
mux.HandleFunc("POST /orders", saveOrder(orders))
mux.HandleFunc("GET /orders/{id}", getOrder(orders))
mux.HandleFunc("PUT /orders/{id}", updateOrder(orders))
mux.HandleFunc("DELETE /orders/{id}", deleteOrder(orders))
mux.HandleFunc("POST /orders/{id}/images", addOrderImages(orders))
mux.HandleFunc("DELETE /orders/{id}/images/{imageId}", deleteOrderImage(orders))
mux.HandleFunc("GET /orders/{id}/images/{imageId}", serveOrderImage(orders))
}
func listOrders(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
list, err := s.List()
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
if list == nil {
list = []model.Order{}
}
for i := range list {
if list[i].Images == nil {
list[i].Images = []model.OrderImage{}
}
}
writeJSON(w, http.StatusOK, list)
}
}
type saveOrderRequest struct {
Name string `json:"name"`
Printed bool `json:"printed"`
Shipped bool `json:"shipped"`
Ref string `json:"ref"`
}
func orderFromRequest(req saveOrderRequest) model.Order {
return model.Order{
Name: req.Name,
Printed: req.Printed,
Shipped: req.Shipped,
Ref: req.Ref,
}
}
func saveOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req saveOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Save(orderFromRequest(req))
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, saved)
}
}
func getOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
o, err := s.Get(id)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, o)
}
}
func updateOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req saveOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Update(id, orderFromRequest(req))
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, saved)
}
}
func deleteOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.Delete(id); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func addOrderImages(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("id")
if _, err := s.Get(orderID); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
var files []*multipart.FileHeader
if r.MultipartForm != nil && len(r.MultipartForm.File["images"]) > 0 {
files = r.MultipartForm.File["images"]
} else if _, fh, err := r.FormFile("image"); err == nil {
files = []*multipart.FileHeader{fh}
} else {
writeError(w, http.StatusBadRequest, errors.New("image or images field required"))
return
}
added := make([]model.OrderImage, 0, len(files))
for _, fh := range files {
data, contentType, err := readUpload(fh)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
img, err := s.AddImage(orderID, fh.Filename, data, contentType)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
added = append(added, img)
}
order, err := s.Get(orderID)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"added": added,
"order": order,
})
}
}
func readUpload(fh *multipart.FileHeader) ([]byte, string, error) {
rc, err := fh.Open()
if err != nil {
return nil, "", err
}
defer rc.Close()
const maxImageSize = 32 << 20
data, err := io.ReadAll(io.LimitReader(rc, maxImageSize+1))
if err != nil {
return nil, "", err
}
if len(data) > maxImageSize {
return nil, "", errors.New("image too large")
}
contentType := http.DetectContentType(data)
return data, contentType, nil
}
func deleteOrderImage(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("id")
imageID := r.PathValue("imageId")
if err := s.DeleteImage(orderID, imageID); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func serveOrderImage(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("id")
imageID := r.PathValue("imageId")
path, img, err := s.ImagePath(orderID, imageID)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
data, err := os.ReadFile(path)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
contentType := img.ContentType
if contentType == "" {
contentType = mime.TypeByExtension(filepath.Ext(path))
}
if contentType == "" || contentType == "application/octet-stream" {
contentType = http.DetectContentType(data)
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(data)
}
}

120
internal/server/api/pdf.go Normal file
View 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)
}

View File

@ -0,0 +1,255 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"printer.backend/internal/model"
"printer.backend/internal/printjob"
"printer.backend/internal/store"
)
func registerPrintJobRoutes(
mux *http.ServeMux,
jobs *store.PrintJobStore,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) {
mux.HandleFunc("GET /print-jobs", listPrintJobs(jobs, configs, plates, items, orders))
mux.HandleFunc("POST /print-jobs", savePrintJob(jobs, configs, orders))
mux.HandleFunc("GET /print-jobs/{id}", getPrintJob(jobs, configs, plates, items, orders))
mux.HandleFunc("PUT /print-jobs/{id}", updatePrintJob(jobs, configs, orders))
mux.HandleFunc("DELETE /print-jobs/{id}", deletePrintJob(jobs))
mux.HandleFunc("GET /print-jobs/{id}/summary", printJobSummary(jobs, configs, plates, items, orders))
mux.HandleFunc("GET /print-jobs/{id}/pdf", printJobPDF(jobs, configs, plates, items, orders))
}
type printJobResponse struct {
model.PrintJob
Summary *model.PrintJobSummary `json:"summary,omitempty"`
}
type savePrintJobRequest struct {
Name string `json:"name"`
ConfigurationID string `json:"configuration_id"`
OrderIDs []string `json:"order_ids"`
}
func listPrintJobs(
jobs *store.PrintJobStore,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
list, err := jobs.List()
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
if list == nil {
list = []model.PrintJob{}
}
out := make([]printJobResponse, 0, len(list))
for _, j := range list {
out = append(out, printJobResponseFor(j, configs, plates, items, orders))
}
writeJSON(w, http.StatusOK, out)
}
}
func savePrintJob(jobs *store.PrintJobStore, configs *store.ConfigurationStore, orders *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req savePrintJobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := validatePrintJobRequest(req, configs, orders); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
j := model.PrintJob{
Name: req.Name,
ConfigurationID: req.ConfigurationID,
OrderIDs: req.OrderIDs,
CreatedAt: time.Now().UTC(),
}
saved, err := jobs.Save(j)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, saved)
}
}
func getPrintJob(
jobs *store.PrintJobStore,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
j, err := jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, printJobResponseFor(j, configs, plates, items, orders))
}
}
func updatePrintJob(jobs *store.PrintJobStore, configs *store.ConfigurationStore, orders *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req savePrintJobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := validatePrintJobRequest(req, configs, orders); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
j := model.PrintJob{
Name: req.Name,
ConfigurationID: req.ConfigurationID,
OrderIDs: req.OrderIDs,
}
saved, err := jobs.Update(id, j)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, saved)
}
}
func deletePrintJob(jobs *store.PrintJobStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := jobs.Delete(r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func printJobSummary(
jobs *store.PrintJobStore,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
j, err := jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
summary, err := buildPrintJobSummary(j, configs, plates, items, orders)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, summary)
}
}
func printJobPDF(
jobs *store.PrintJobStore,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
j, err := jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
pdf, summary, err := printjob.RenderPDF(j, configs, plates, items, orders)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if summary.ImageOverflow {
w.Header().Set("X-Print-Job-Warning", summary.Warning)
}
name := j.Name
if name == "" {
name = "print-job-" + j.ID[:8]
}
servePDF(w, pdf, sanitizeFilename(name)+".pdf")
}
}
func validatePrintJobRequest(req savePrintJobRequest, configs *store.ConfigurationStore, orders *store.OrderStore) error {
if req.ConfigurationID == "" {
return errors.New("configuration_id is required")
}
if _, err := configs.Get(req.ConfigurationID); err != nil {
return errors.New("configuration not found: " + req.ConfigurationID)
}
if len(req.OrderIDs) == 0 {
return errors.New("at least one order_id is required")
}
for _, id := range req.OrderIDs {
if _, err := orders.Get(id); err != nil {
return errors.New("order not found: " + id)
}
}
return nil
}
func printJobResponseFor(
j model.PrintJob,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) printJobResponse {
resp := printJobResponse{PrintJob: j}
if summary, err := buildPrintJobSummary(j, configs, plates, items, orders); err == nil {
resp.Summary = &summary
}
return resp
}
func buildPrintJobSummary(
j model.PrintJob,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) (model.PrintJobSummary, error) {
cfg, err := configs.Get(j.ConfigurationID)
if err != nil {
return model.PrintJobSummary{}, err
}
preview, err := buildPreview(plates, items, cfg.PlateID, cfg.ItemID, cfg.SpacingMM)
if err != nil {
return model.PrintJobSummary{}, err
}
orderList := make([]model.Order, 0, len(j.OrderIDs))
for _, id := range j.OrderIDs {
o, err := orders.Get(id)
if err != nil {
return model.PrintJobSummary{}, err
}
orderList = append(orderList, o)
}
return printjob.Summary(orderList, preview), nil
}

View File

@ -0,0 +1,80 @@
package store
import (
"fmt"
"path/filepath"
"time"
"github.com/google/uuid"
"printer.backend/internal/model"
"printer.backend/internal/paths"
)
// ConfigurationStore persists configurations as JSON files.
type ConfigurationStore struct {
dir string
}
// NewConfigurationStore creates a store under dir (default data/configurations).
func NewConfigurationStore(dir string) *ConfigurationStore {
if dir == "" {
dir = paths.ConfigurationsDir
}
return &ConfigurationStore{dir: dir}
}
// List returns all configurations sorted by creation time (newest first).
func (s *ConfigurationStore) List() ([]model.Configuration, error) {
return listFromDir(s.dir,
func(name string) bool { return filepath.Ext(name) == ".json" },
"configurations dir",
func(c model.Configuration) time.Time { return c.CreatedAt },
)
}
// Get returns a configuration by ID.
func (s *ConfigurationStore) Get(id string) (model.Configuration, error) {
list, err := s.List()
if err != nil {
return model.Configuration{}, err
}
return findByID(list, id, func(c model.Configuration) string { return c.ID })
}
// Save writes a new configuration and assigns an ID when empty.
func (s *ConfigurationStore) Save(c model.Configuration) (model.Configuration, error) {
if err := ensureDir(s.dir); err != nil {
return model.Configuration{}, err
}
if c.ID == "" {
c.ID = uuid.NewString()
}
c.CreatedAt = stampNew(&c.CreatedAt)
path := filepath.Join(s.dir, c.ID+".json")
if err := writeJSON(path, c); err != nil {
return model.Configuration{}, fmt.Errorf("write configuration: %w", err)
}
return c, nil
}
// Update replaces an existing configuration by ID (preserves created_at).
func (s *ConfigurationStore) Update(id string, c model.Configuration) (model.Configuration, error) {
path := filepath.Join(s.dir, id+".json")
existing, err := readJSON[model.Configuration](path)
if err != nil {
return model.Configuration{}, fmt.Errorf("configuration not found: %s", id)
}
c.ID = existing.ID
c.CreatedAt = existing.CreatedAt
if err := writeJSON(path, c); err != nil {
return model.Configuration{}, fmt.Errorf("write configuration: %w", err)
}
return c, nil
}
// Delete removes a configuration by ID.
func (s *ConfigurationStore) Delete(id string) error {
path := filepath.Join(s.dir, id+".json")
return removeFile(path, fmt.Sprintf("configuration not found: %s", id))
}

View File

@ -49,12 +49,16 @@ func (s *ItemStore) Get(id string) (model.Item, error) {
// Create generates SVG + metadata for a new item.
func (s *ItemStore) Create(name string, spec model.ItemSpec) (model.Item, error) {
if spec.SizeMM <= 0 {
return model.Item{}, fmt.Errorf("size_mm must be positive")
if err := spec.Normalize(); err != nil {
return model.Item{}, err
}
basename := svgBasename(name)
data := svgtemplate.Build(spec.SizeMM, spec.BleedMM, spec.MarginMM, spec.PaddingMM)
data := svgtemplate.Build(
spec.WidthMM, spec.HeightMM,
spec.BleedMM, spec.MarginMM, spec.PaddingMM,
spec.CornerRadiusMM,
)
if err := svgtemplate.WriteFile(basename, data); err != nil {
return model.Item{}, fmt.Errorf("write svg: %w", err)
}
@ -65,6 +69,34 @@ func (s *ItemStore) Create(name string, spec model.ItemSpec) (model.Item, error)
return svgtemplate.WriteMeta(basename, spec, displayName)
}
// Update regenerates the SVG and metadata for an existing item (preserves id, svg filename, created_at).
func (s *ItemStore) Update(id string, name string, spec model.ItemSpec) (model.Item, error) {
if err := spec.Normalize(); err != nil {
return model.Item{}, err
}
item, err := s.Get(id)
if err != nil {
return model.Item{}, err
}
data := svgtemplate.Build(
spec.WidthMM, spec.HeightMM,
spec.BleedMM, spec.MarginMM, spec.PaddingMM,
spec.CornerRadiusMM,
)
if err := svgtemplate.WriteFile(item.SVGTemplate, data); err != nil {
return model.Item{}, fmt.Errorf("write svg: %w", err)
}
if name != "" {
item.Name = name
}
item.Spec = spec
metaPath := filepath.Join(s.dir, model.MetaFilename(item.SVGTemplate))
if err := writeJSON(metaPath, item); err != nil {
return model.Item{}, fmt.Errorf("write meta: %w", err)
}
return item, nil
}
// Delete removes an item's SVG and metadata by ID.
func (s *ItemStore) Delete(id string) error {
item, err := s.Get(id)

257
internal/store/order.go Normal file
View File

@ -0,0 +1,257 @@
package store
import (
"encoding/json"
"fmt"
"mime"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/google/uuid"
"printer.backend/internal/model"
"printer.backend/internal/paths"
)
// OrderStore persists orders as JSON files with images in per-order subdirectories.
type OrderStore struct {
dir string
}
// NewOrderStore creates a store under dir (default data/orders).
func NewOrderStore(dir string) *OrderStore {
if dir == "" {
dir = paths.OrdersDir
}
return &OrderStore{dir: dir}
}
// List returns all orders sorted by creation time (newest first).
func (s *OrderStore) List() ([]model.Order, error) {
if err := ensureDir(s.dir); err != nil {
return nil, err
}
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, fmt.Errorf("read orders dir: %w", err)
}
var out []model.Order
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
o, err := readOrderFile(filepath.Join(s.dir, e.Name()))
if err != nil {
return nil, err
}
out = append(out, o)
}
sort.Slice(out, func(i, j int) bool {
return out[i].CreatedAt.After(out[j].CreatedAt)
})
return out, nil
}
// Get returns an order by ID.
func (s *OrderStore) Get(id string) (model.Order, error) {
o, err := readOrderFile(s.metaPath(id))
if err != nil {
if os.IsNotExist(err) {
return model.Order{}, fmt.Errorf("order not found: %s", id)
}
return model.Order{}, err
}
return o, nil
}
func readOrderFile(path string) (model.Order, error) {
var aux struct {
model.Order
RefLink string `json:"ref_link,omitempty"`
}
data, err := os.ReadFile(path)
if err != nil {
return model.Order{}, err
}
if err := json.Unmarshal(data, &aux); err != nil {
return model.Order{}, err
}
o := aux.Order
if o.Ref == "" && aux.RefLink != "" {
o.Ref = aux.RefLink
}
if o.Images == nil {
o.Images = []model.OrderImage{}
}
return o, nil
}
// Save writes a new order and assigns an ID when empty.
func (s *OrderStore) Save(o model.Order) (model.Order, error) {
if err := ensureDir(s.dir); err != nil {
return model.Order{}, err
}
if o.ID == "" {
o.ID = uuid.NewString()
}
if o.Images == nil {
o.Images = []model.OrderImage{}
}
now := stampNew(&o.CreatedAt)
o.CreatedAt = now
o.UpdatedAt = now
if err := writeJSON(s.metaPath(o.ID), o); err != nil {
return model.Order{}, fmt.Errorf("write order: %w", err)
}
return o, nil
}
// Update replaces order metadata (preserves id, images, created_at).
func (s *OrderStore) Update(id string, o model.Order) (model.Order, error) {
existing, err := s.Get(id)
if err != nil {
return model.Order{}, err
}
o.ID = existing.ID
o.Images = existing.Images
o.CreatedAt = existing.CreatedAt
o.UpdatedAt = time.Now().UTC()
if err := writeJSON(s.metaPath(id), o); err != nil {
return model.Order{}, fmt.Errorf("write order: %w", err)
}
return o, nil
}
// Delete removes an order and all of its images.
func (s *OrderStore) Delete(id string) error {
if _, err := s.Get(id); err != nil {
return err
}
if err := os.Remove(s.metaPath(id)); err != nil && !os.IsNotExist(err) {
return err
}
return os.RemoveAll(s.imagesDir(id))
}
// AddImage stores image bytes and appends metadata to the order.
func (s *OrderStore) AddImage(orderID, originalName string, data []byte, contentType string) (model.OrderImage, error) {
if len(data) == 0 {
return model.OrderImage{}, fmt.Errorf("empty image data")
}
order, err := s.Get(orderID)
if err != nil {
return model.OrderImage{}, err
}
ext := extensionForImage(originalName, contentType)
imageID := uuid.NewString()
filename := imageID + ext
if err := ensureDir(s.imagesDir(orderID)); err != nil {
return model.OrderImage{}, err
}
path := filepath.Join(s.imagesDir(orderID), filename)
if err := os.WriteFile(path, data, 0o644); err != nil {
return model.OrderImage{}, fmt.Errorf("write image: %w", err)
}
img := model.OrderImage{
ID: imageID,
OriginalName: filepath.Base(originalName),
ContentType: contentType,
Filename: filename,
CreatedAt: time.Now().UTC(),
}
order.Images = append(order.Images, img)
order.UpdatedAt = time.Now().UTC()
if err := writeJSON(s.metaPath(orderID), order); err != nil {
_ = os.Remove(path)
return model.OrderImage{}, fmt.Errorf("update order: %w", err)
}
return img, nil
}
// DeleteImage removes one image from an order.
func (s *OrderStore) DeleteImage(orderID, imageID string) error {
order, err := s.Get(orderID)
if err != nil {
return err
}
idx := -1
var filename string
for i, img := range order.Images {
if img.ID == imageID {
idx = i
filename = img.Filename
break
}
}
if idx < 0 {
return fmt.Errorf("image not found: %s", imageID)
}
path := filepath.Join(s.imagesDir(orderID), filename)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
order.Images = append(order.Images[:idx], order.Images[idx+1:]...)
order.UpdatedAt = time.Now().UTC()
return writeJSON(s.metaPath(orderID), order)
}
// ImagePath returns the filesystem path for an order image.
func (s *OrderStore) ImagePath(orderID, imageID string) (string, model.OrderImage, error) {
order, err := s.Get(orderID)
if err != nil {
return "", model.OrderImage{}, err
}
for _, img := range order.Images {
if img.ID == imageID {
path := filepath.Join(s.imagesDir(orderID), img.Filename)
if _, err := os.Stat(path); err != nil {
return "", model.OrderImage{}, fmt.Errorf("image file missing: %w", err)
}
return path, img, nil
}
}
return "", model.OrderImage{}, fmt.Errorf("image not found: %s", imageID)
}
func (s *OrderStore) metaPath(id string) string {
return filepath.Join(s.dir, id+".json")
}
func (s *OrderStore) imagesDir(orderID string) string {
return filepath.Join(s.dir, orderID)
}
func extensionForImage(originalName, contentType string) string {
if ext := filepath.Ext(originalName); ext != "" {
return strings.ToLower(ext)
}
if exts, _ := mime.ExtensionsByType(contentType); len(exts) > 0 {
return exts[0]
}
switch contentType {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/webp":
return ".webp"
case "image/gif":
return ".gif"
case "image/svg+xml":
return ".svg"
default:
return ".bin"
}
}

View File

@ -49,6 +49,21 @@ func (s *PlateStore) Save(p model.Plate) (model.Plate, error) {
return p, nil
}
// Update replaces an existing plate by ID (preserves created_at).
func (s *PlateStore) Update(id string, p model.Plate) (model.Plate, error) {
path := filepath.Join(s.dir, id+".json")
existing, err := readJSON[model.Plate](path)
if err != nil {
return model.Plate{}, fmt.Errorf("plate not found: %s", id)
}
p.ID = existing.ID
p.CreatedAt = existing.CreatedAt
if err := writeJSON(path, p); err != nil {
return model.Plate{}, fmt.Errorf("write plate: %w", err)
}
return p, nil
}
// Delete removes a plate by ID.
func (s *PlateStore) Delete(id string) error {
path := filepath.Join(s.dir, id+".json")

View File

@ -0,0 +1,97 @@
package store
import (
"fmt"
"path/filepath"
"time"
"github.com/google/uuid"
"printer.backend/internal/model"
"printer.backend/internal/paths"
)
// PrintJobStore persists print jobs as JSON files.
type PrintJobStore struct {
dir string
}
// NewPrintJobStore creates a store under dir (default data/print_jobs).
func NewPrintJobStore(dir string) *PrintJobStore {
if dir == "" {
dir = paths.PrintJobsDir
}
return &PrintJobStore{dir: dir}
}
// List returns all print jobs sorted by creation time (newest first).
func (s *PrintJobStore) List() ([]model.PrintJob, error) {
return listFromDir(s.dir,
func(name string) bool { return filepath.Ext(name) == ".json" },
"print jobs dir",
func(j model.PrintJob) time.Time { return j.CreatedAt },
)
}
// Get returns a print job by ID.
func (s *PrintJobStore) Get(id string) (model.PrintJob, error) {
list, err := s.List()
if err != nil {
return model.PrintJob{}, err
}
j, err := findByID(list, id, func(j model.PrintJob) string { return j.ID })
if err != nil {
return model.PrintJob{}, fmt.Errorf("print job not found: %s", id)
}
if j.OrderIDs == nil {
j.OrderIDs = []string{}
}
return j, nil
}
// Save writes a new print job and assigns an ID when empty.
func (s *PrintJobStore) Save(j model.PrintJob) (model.PrintJob, error) {
if err := ensureDir(s.dir); err != nil {
return model.PrintJob{}, err
}
if j.ID == "" {
j.ID = uuid.NewString()
}
if j.OrderIDs == nil {
j.OrderIDs = []string{}
}
now := stampNew(&j.CreatedAt)
j.CreatedAt = now
j.UpdatedAt = now
path := filepath.Join(s.dir, j.ID+".json")
if err := writeJSON(path, j); err != nil {
return model.PrintJob{}, fmt.Errorf("write print job: %w", err)
}
return j, nil
}
// Update replaces an existing print job (preserves id and created_at).
func (s *PrintJobStore) Update(id string, j model.PrintJob) (model.PrintJob, error) {
existing, err := s.Get(id)
if err != nil {
return model.PrintJob{}, err
}
j.ID = existing.ID
j.CreatedAt = existing.CreatedAt
j.UpdatedAt = time.Now().UTC()
if j.OrderIDs == nil {
j.OrderIDs = []string{}
}
path := filepath.Join(s.dir, id+".json")
if err := writeJSON(path, j); err != nil {
return model.PrintJob{}, fmt.Errorf("write print job: %w", err)
}
return j, nil
}
// Delete removes a print job by ID.
func (s *PrintJobStore) Delete(id string) error {
path := filepath.Join(s.dir, id+".json")
return removeFile(path, fmt.Sprintf("print job not found: %s", id))
}

View File

@ -1,41 +1,77 @@
package svgtemplate
import "math"
// Data holds dynamic values for the SVG mask template.
type Data struct {
Size float64
Width, Height float64
CornerRadius float64
Bleed float64
Margin float64
Padding float64
ViewBoxMin float64
ViewBoxSize float64
MaskSize float64
OuterSize float64
OuterOffset float64
InnerSize float64
InnerOffset float64
ViewBoxWidth float64
ViewBoxHeight float64
MaskWidth float64
MaskHeight float64
OuterWidth float64
OuterHeight float64
OuterOffsetX float64
OuterOffsetY float64
InnerWidth float64
InnerHeight float64
InnerOffsetX float64
InnerOffsetY float64
InnerRadius float64
MarkLength float64
}
const defaultMarkLength = 6.0 // mm
// Build computes template data from product dimensions.
func Build(size, bleed, margin, padding float64) Data {
func Build(width, height, bleed, margin, padding, cornerRadius float64) Data {
offset := bleed + margin + defaultMarkLength
viewBoxMin := -offset
viewBoxSize := size + (offset * 2)
viewBoxW := width + (offset * 2)
viewBoxH := height + (offset * 2)
innerW := width - (padding * 2)
innerH := height - (padding * 2)
if innerW < 0 {
innerW = 0
}
if innerH < 0 {
innerH = 0
}
innerR := cornerRadius - padding
if innerR < 0 {
innerR = 0
}
maxInnerR := math.Min(innerW, innerH) / 2
if innerR > maxInnerR && maxInnerR > 0 {
innerR = maxInnerR
}
return Data{
Size: size,
Width: width,
Height: height,
CornerRadius: cornerRadius,
Bleed: bleed,
Margin: margin,
Padding: padding,
ViewBoxMin: viewBoxMin,
ViewBoxSize: viewBoxSize,
ViewBoxMin: -offset,
ViewBoxWidth: viewBoxW,
ViewBoxHeight: viewBoxH,
MarkLength: defaultMarkLength,
MaskSize: size,
OuterSize: size + (bleed * 2),
OuterOffset: -bleed,
InnerSize: size - (padding * 2),
InnerOffset: padding,
MaskWidth: width,
MaskHeight: height,
OuterWidth: width + (bleed * 2),
OuterHeight: height + (bleed * 2),
OuterOffsetX: -bleed,
OuterOffsetY: -bleed,
InnerWidth: innerW,
InnerHeight: innerH,
InnerOffsetX: padding,
InnerOffsetY: padding,
InnerRadius: innerR,
}
}

View File

@ -10,35 +10,35 @@ import (
)
const svgTemplate = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{{.Size}}mm" height="{{.Size}}mm" viewBox="{{.ViewBoxMin}} {{.ViewBoxMin}} {{.ViewBoxSize}} {{.ViewBoxSize}}">
<svg xmlns="http://www.w3.org/2000/svg" width="{{.ViewBoxWidth}}mm" height="{{.ViewBoxHeight}}mm" viewBox="{{.ViewBoxMin}} {{.ViewBoxMin}} {{.ViewBoxWidth}} {{.ViewBoxHeight}}">
<defs>
<clipPath id="item-mask">
<rect x="0" y="0" width="{{.MaskSize}}" height="{{.MaskSize}}" rx="5" ry="5" />
<rect x="0" y="0" width="{{.MaskWidth}}" height="{{.MaskHeight}}" rx="{{.CornerRadius}}" ry="{{.CornerRadius}}" />
</clipPath>
</defs>
<rect x="{{.ViewBoxMin}}" y="{{.ViewBoxMin}}" width="{{.ViewBoxSize}}" height="{{.ViewBoxSize}}" fill="#f9f9f9" />
<rect x="{{.ViewBoxMin}}" y="{{.ViewBoxMin}}" width="{{.ViewBoxWidth}}" height="{{.ViewBoxHeight}}" fill="#f9f9f9" />
<g clip-path="url(#item-mask)">
<rect x="{{.OuterOffset}}" y="{{.OuterOffset}}" width="{{.OuterSize}}" height="{{.OuterSize}}" fill="#00FF00" opacity="0.7" />
<rect x="{{.OuterOffsetX}}" y="{{.OuterOffsetY}}" width="{{.OuterWidth}}" height="{{.OuterHeight}}" fill="#00FF00" opacity="0.7" />
</g>
<rect x="{{.InnerOffset}}" y="{{.InnerOffset}}" width="{{.InnerSize}}" height="{{.InnerSize}}" fill="none" stroke="#0000FF" stroke-width="0.3" stroke-dasharray="1,1" />
<rect x="{{.InnerOffsetX}}" y="{{.InnerOffsetY}}" width="{{.InnerWidth}}" height="{{.InnerHeight}}" rx="{{.InnerRadius}}" ry="{{.InnerRadius}}" fill="none" stroke="#0000FF" stroke-width="0.3" stroke-dasharray="1,1" />
<rect x="0" y="0" width="{{.MaskSize}}" height="{{.MaskSize}}" fill="none" stroke="#ccc" stroke-width="0.2" stroke-dasharray="2,2" />
<rect x="0" y="0" width="{{.MaskWidth}}" height="{{.MaskHeight}}" rx="{{.CornerRadius}}" ry="{{.CornerRadius}}" fill="none" stroke="#ccc" stroke-width="0.2" stroke-dasharray="2,2" />
<g stroke="#000000" stroke-width="0.3">
<line x1="0" y1="-{{.Bleed}}" x2="0" y2="-{{appendBleed .Bleed .MarkLength}}" />
<line x1="-{{.Bleed}}" y1="0" x2="-{{appendBleed .Bleed .MarkLength}}" y2="0" />
<line x1="{{.Size}}" y1="-{{.Bleed}}" x2="{{.Size}}" y2="-{{appendBleed .Bleed .MarkLength}}" />
<line x1="{{add .Size .Bleed}}" y1="0" x2="{{addThree .Size .Bleed .MarkLength}}" y2="0" />
<line x1="{{.Width}}" y1="-{{.Bleed}}" x2="{{.Width}}" y2="-{{appendBleed .Bleed .MarkLength}}" />
<line x1="{{add .Width .Bleed}}" y1="0" x2="{{addThree .Width .Bleed .MarkLength}}" y2="0" />
<line x1="0" y1="{{add .Size .Bleed}}" x2="0" y2="{{addThree .Size .Bleed .MarkLength}}" />
<line x1="-{{.Bleed}}" y1="{{.Size}}" x2="-{{appendBleed .Bleed .MarkLength}}" y2="{{.Size}}" />
<line x1="0" y1="{{add .Height .Bleed}}" x2="0" y2="{{addThree .Height .Bleed .MarkLength}}" />
<line x1="-{{.Bleed}}" y1="{{.Height}}" x2="-{{appendBleed .Bleed .MarkLength}}" y2="{{.Height}}" />
<line x1="{{.Size}}" y1="{{add .Size .Bleed}}" x2="{{.Size}}" y2="{{addThree .Size .Bleed .MarkLength}}" />
<line x1="{{add .Size .Bleed}}" y1="{{.Size}}" x2="{{addThree .Size .Bleed .MarkLength}}" y2="{{.Size}}" />
<line x1="{{.Width}}" y1="{{add .Height .Bleed}}" x2="{{.Width}}" y2="{{addThree .Height .Bleed .MarkLength}}" />
<line x1="{{add .Width .Bleed}}" y1="{{.Height}}" x2="{{addThree .Width .Bleed .MarkLength}}" y2="{{.Height}}" />
</g>
</svg>
`

View File

@ -0,0 +1,20 @@
package svgtemplate
import (
"bytes"
"strings"
"testing"
)
func TestBuildSVGRootMatchesViewBox(t *testing.T) {
data := Build(10, 80, 1, 1, 1, 1)
var buf bytes.Buffer
if err := Write(&buf, data); err != nil {
t.Fatal(err)
}
s := buf.String()
want := `width="26mm" height="96mm" viewBox="-8 -8 26 96"`
if !strings.Contains(s, want) {
t.Fatalf("expected root %q in:\n%s", want, s)
}
}