Compare commits
10 Commits
50e677c729
...
2432a34198
| Author | SHA1 | Date | |
|---|---|---|---|
| 2432a34198 | |||
| 929969d03a | |||
| 8bc4691992 | |||
| 9467ff5f6b | |||
| aeeefc89c1 | |||
| bca4fcc936 | |||
| be58c5941d | |||
| 66543b75d4 | |||
| af4c944de7 | |||
| c5d2e32355 |
110
README.md
Normal file
110
README.md
Normal 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`.
|
||||||
@ -11,18 +11,38 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
sizeFlag float64
|
sizeFlag float64
|
||||||
bleedFlag float64
|
widthFlag float64
|
||||||
marginFlag float64
|
heightFlag float64
|
||||||
paddingFlag float64
|
cornerRadiusFlag float64
|
||||||
outputFlag string
|
bleedFlag float64
|
||||||
|
marginFlag float64
|
||||||
|
paddingFlag float64
|
||||||
|
outputFlag string
|
||||||
)
|
)
|
||||||
|
|
||||||
var generateTemplateCmd = &cobra.Command{
|
var generateTemplateCmd = &cobra.Command{
|
||||||
Use: "template",
|
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 {
|
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)
|
base := filepath.Base(outputFlag)
|
||||||
outPath := filepath.Join(svgtemplate.OutputDir, base)
|
outPath := filepath.Join(svgtemplate.OutputDir, base)
|
||||||
@ -30,12 +50,6 @@ var generateTemplateCmd = &cobra.Command{
|
|||||||
return fmt.Errorf("write svg: %w", err)
|
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))
|
name := strings.TrimSuffix(base, filepath.Ext(base))
|
||||||
item, err := svgtemplate.WriteMeta(base, spec, name)
|
item, err := svgtemplate.WriteMeta(base, spec, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -51,7 +65,10 @@ var generateTemplateCmd = &cobra.Command{
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(generateTemplateCmd)
|
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(&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(&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")
|
generateTemplateCmd.Flags().Float64VarP(&paddingFlag, "padding", "p", 3.0, "Inner padding (safety margin) in mm")
|
||||||
|
|||||||
7
go.mod
7
go.mod
@ -2,10 +2,13 @@ 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
|
||||||
|
golang.org/x/image v0.41.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
57
internal/itemimage/embed.go
Normal file
57
internal/itemimage/embed.go
Normal 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)
|
||||||
|
}
|
||||||
78
internal/itemimage/embed_test.go
Normal file
78
internal/itemimage/embed_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
84
internal/itemimage/prepare.go
Normal file
84
internal/itemimage/prepare.go
Normal 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
70
internal/layout/layout.go
Normal 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
|
||||||
|
}
|
||||||
71
internal/layout/layout_test.go
Normal file
71
internal/layout/layout_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/model/configuration.go
Normal file
43
internal/model/configuration.go
Normal 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"`
|
||||||
|
}
|
||||||
@ -1,13 +1,74 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// ItemSpec holds mask parameters (from template CLI).
|
// ItemSpec holds mask parameters (from template CLI).
|
||||||
type ItemSpec struct {
|
type ItemSpec struct {
|
||||||
SizeMM float64 `json:"size_mm"`
|
// SizeMM is legacy: square product when width_mm/height_mm are unset.
|
||||||
BleedMM float64 `json:"bleed_mm"`
|
SizeMM float64 `json:"size_mm,omitempty"`
|
||||||
MarginMM float64 `json:"margin_mm"`
|
WidthMM float64 `json:"width_mm,omitempty"`
|
||||||
PaddingMM float64 `json:"padding_mm"`
|
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.
|
// Item is a printable product type (SVG mask) placed on a plate.
|
||||||
|
|||||||
24
internal/model/order.go
Normal file
24
internal/model/order.go
Normal 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"`
|
||||||
|
}
|
||||||
36
internal/model/printjob.go
Normal file
36
internal/model/printjob.go
Normal 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"`
|
||||||
|
}
|
||||||
@ -2,7 +2,10 @@ package paths
|
|||||||
|
|
||||||
// Runtime data directories (relative to process working directory).
|
// Runtime data directories (relative to process working directory).
|
||||||
const (
|
const (
|
||||||
DataDir = "data"
|
DataDir = "data"
|
||||||
PlatesDir = "data/plates"
|
PlatesDir = "data/plates"
|
||||||
SVGTemplateDir = "data/svg_template"
|
SVGTemplateDir = "data/svg_template"
|
||||||
|
ConfigurationsDir = "data/configurations"
|
||||||
|
OrdersDir = "data/orders"
|
||||||
|
PrintJobsDir = "data/print_jobs"
|
||||||
)
|
)
|
||||||
|
|||||||
66
internal/platepdf/composite.go
Normal file
66
internal/platepdf/composite.go
Normal 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
|
||||||
|
}
|
||||||
49
internal/platepdf/platepdf.go
Normal file
49
internal/platepdf/platepdf.go
Normal 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
|
||||||
|
}
|
||||||
119
internal/platepdf/platepdf_test.go
Normal file
119
internal/platepdf/platepdf_test.go
Normal 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
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
internal/printjob/assign.go
Normal file
63
internal/printjob/assign.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
56
internal/printjob/assign_test.go
Normal file
56
internal/printjob/assign_test.go
Normal 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
124
internal/printjob/pdf.go
Normal 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
@ -15,24 +15,32 @@ import (
|
|||||||
func NewHandler() http.Handler {
|
func NewHandler() http.Handler {
|
||||||
plates := store.NewPlateStore("")
|
plates := store.NewPlateStore("")
|
||||||
items := store.NewItemStore("")
|
items := store.NewItemStore("")
|
||||||
|
configs := store.NewConfigurationStore("")
|
||||||
|
orders := store.NewOrderStore("")
|
||||||
|
printJobs := store.NewPrintJobStore("")
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /health", health)
|
mux.HandleFunc("GET /health", health)
|
||||||
mux.HandleFunc("GET /", root)
|
mux.HandleFunc("GET /", root)
|
||||||
mux.HandleFunc("GET /plates", listPlates(plates))
|
mux.HandleFunc("GET /plates", listPlates(plates))
|
||||||
mux.HandleFunc("POST /plates", savePlate(plates))
|
mux.HandleFunc("POST /plates", savePlate(plates))
|
||||||
|
mux.HandleFunc("PUT /plates/{id}", updatePlate(plates))
|
||||||
mux.HandleFunc("DELETE /plates/{id}", deletePlate(plates))
|
mux.HandleFunc("DELETE /plates/{id}", deletePlate(plates))
|
||||||
mux.HandleFunc("GET /items", listItems(items))
|
mux.HandleFunc("GET /items", listItems(items))
|
||||||
mux.HandleFunc("POST /items", saveItem(items))
|
mux.HandleFunc("POST /items", saveItem(items))
|
||||||
|
mux.HandleFunc("PUT /items/{id}", updateItem(items))
|
||||||
mux.HandleFunc("DELETE /items/{id}", deleteItem(items))
|
mux.HandleFunc("DELETE /items/{id}", deleteItem(items))
|
||||||
mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(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)
|
return withCORS(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
func withCORS(next http.Handler) http.Handler {
|
func withCORS(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
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")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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 {
|
func deletePlate(s *store.PlateStore) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
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 {
|
func saveItem(s *store.ItemStore) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req saveItemRequest
|
var req saveItemRequest
|
||||||
@ -153,11 +187,10 @@ func saveItem(s *store.ItemStore) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusBadRequest, err)
|
writeError(w, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
spec := model.ItemSpec{
|
spec, err := itemSpecFromRequest(req)
|
||||||
SizeMM: req.SizeMM,
|
if err != nil {
|
||||||
BleedMM: req.BleedMM,
|
writeError(w, http.StatusBadRequest, err)
|
||||||
MarginMM: req.MarginMM,
|
return
|
||||||
PaddingMM: req.PaddingMM,
|
|
||||||
}
|
}
|
||||||
saved, err := s.Create(req.Name, spec)
|
saved, err := s.Create(req.Name, spec)
|
||||||
if err != nil {
|
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 {
|
func deleteItem(s *store.ItemStore) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
|
|||||||
270
internal/server/api/configuration.go
Normal file
270
internal/server/api/configuration.go
Normal 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
|
||||||
|
}
|
||||||
30
internal/server/api/item_spec.go
Normal file
30
internal/server/api/item_spec.go
Normal 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
|
||||||
|
}
|
||||||
225
internal/server/api/order.go
Normal file
225
internal/server/api/order.go
Normal 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
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)
|
||||||
|
}
|
||||||
255
internal/server/api/printjob.go
Normal file
255
internal/server/api/printjob.go
Normal 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
|
||||||
|
}
|
||||||
80
internal/store/configuration.go
Normal file
80
internal/store/configuration.go
Normal 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))
|
||||||
|
}
|
||||||
@ -49,12 +49,16 @@ func (s *ItemStore) Get(id string) (model.Item, error) {
|
|||||||
|
|
||||||
// Create generates SVG + metadata for a new item.
|
// Create generates SVG + metadata for a new item.
|
||||||
func (s *ItemStore) Create(name string, spec model.ItemSpec) (model.Item, error) {
|
func (s *ItemStore) Create(name string, spec model.ItemSpec) (model.Item, error) {
|
||||||
if spec.SizeMM <= 0 {
|
if err := spec.Normalize(); err != nil {
|
||||||
return model.Item{}, fmt.Errorf("size_mm must be positive")
|
return model.Item{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
basename := svgBasename(name)
|
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 {
|
if err := svgtemplate.WriteFile(basename, data); err != nil {
|
||||||
return model.Item{}, fmt.Errorf("write svg: %w", err)
|
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)
|
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.
|
// Delete removes an item's SVG and metadata by ID.
|
||||||
func (s *ItemStore) Delete(id string) error {
|
func (s *ItemStore) Delete(id string) error {
|
||||||
item, err := s.Get(id)
|
item, err := s.Get(id)
|
||||||
|
|||||||
257
internal/store/order.go
Normal file
257
internal/store/order.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,21 @@ func (s *PlateStore) Save(p model.Plate) (model.Plate, error) {
|
|||||||
return p, nil
|
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.
|
// Delete removes a plate by ID.
|
||||||
func (s *PlateStore) Delete(id string) error {
|
func (s *PlateStore) Delete(id string) error {
|
||||||
path := filepath.Join(s.dir, id+".json")
|
path := filepath.Join(s.dir, id+".json")
|
||||||
|
|||||||
97
internal/store/printjob.go
Normal file
97
internal/store/printjob.go
Normal 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))
|
||||||
|
}
|
||||||
@ -1,41 +1,77 @@
|
|||||||
package svgtemplate
|
package svgtemplate
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
// Data holds dynamic values for the SVG mask template.
|
// Data holds dynamic values for the SVG mask template.
|
||||||
type Data struct {
|
type Data struct {
|
||||||
Size float64
|
Width, Height float64
|
||||||
Bleed float64
|
CornerRadius float64
|
||||||
Margin float64
|
Bleed float64
|
||||||
Padding float64
|
Margin float64
|
||||||
ViewBoxMin float64
|
Padding float64
|
||||||
ViewBoxSize float64
|
ViewBoxMin float64
|
||||||
MaskSize float64
|
ViewBoxWidth float64
|
||||||
OuterSize float64
|
ViewBoxHeight float64
|
||||||
OuterOffset float64
|
MaskWidth float64
|
||||||
InnerSize float64
|
MaskHeight float64
|
||||||
InnerOffset float64
|
OuterWidth float64
|
||||||
MarkLength float64
|
OuterHeight float64
|
||||||
|
OuterOffsetX float64
|
||||||
|
OuterOffsetY float64
|
||||||
|
InnerWidth float64
|
||||||
|
InnerHeight float64
|
||||||
|
InnerOffsetX float64
|
||||||
|
InnerOffsetY float64
|
||||||
|
InnerRadius float64
|
||||||
|
MarkLength float64
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultMarkLength = 6.0 // mm
|
const defaultMarkLength = 6.0 // mm
|
||||||
|
|
||||||
// Build computes template data from product dimensions.
|
// 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
|
offset := bleed + margin + defaultMarkLength
|
||||||
viewBoxMin := -offset
|
viewBoxW := width + (offset * 2)
|
||||||
viewBoxSize := size + (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{
|
return Data{
|
||||||
Size: size,
|
Width: width,
|
||||||
Bleed: bleed,
|
Height: height,
|
||||||
Margin: margin,
|
CornerRadius: cornerRadius,
|
||||||
Padding: padding,
|
Bleed: bleed,
|
||||||
ViewBoxMin: viewBoxMin,
|
Margin: margin,
|
||||||
ViewBoxSize: viewBoxSize,
|
Padding: padding,
|
||||||
MarkLength: defaultMarkLength,
|
ViewBoxMin: -offset,
|
||||||
MaskSize: size,
|
ViewBoxWidth: viewBoxW,
|
||||||
OuterSize: size + (bleed * 2),
|
ViewBoxHeight: viewBoxH,
|
||||||
OuterOffset: -bleed,
|
MarkLength: defaultMarkLength,
|
||||||
InnerSize: size - (padding * 2),
|
MaskWidth: width,
|
||||||
InnerOffset: padding,
|
MaskHeight: height,
|
||||||
|
OuterWidth: width + (bleed * 2),
|
||||||
|
OuterHeight: height + (bleed * 2),
|
||||||
|
OuterOffsetX: -bleed,
|
||||||
|
OuterOffsetY: -bleed,
|
||||||
|
InnerWidth: innerW,
|
||||||
|
InnerHeight: innerH,
|
||||||
|
InnerOffsetX: padding,
|
||||||
|
InnerOffsetY: padding,
|
||||||
|
InnerRadius: innerR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,35 +10,35 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const svgTemplate = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
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>
|
<defs>
|
||||||
<clipPath id="item-mask">
|
<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>
|
</clipPath>
|
||||||
</defs>
|
</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)">
|
<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>
|
</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">
|
<g stroke="#000000" stroke-width="0.3">
|
||||||
<line x1="0" y1="-{{.Bleed}}" x2="0" y2="-{{appendBleed .Bleed .MarkLength}}" />
|
<line x1="0" y1="-{{.Bleed}}" x2="0" y2="-{{appendBleed .Bleed .MarkLength}}" />
|
||||||
<line x1="-{{.Bleed}}" y1="0" x2="-{{appendBleed .Bleed .MarkLength}}" y2="0" />
|
<line x1="-{{.Bleed}}" y1="0" x2="-{{appendBleed .Bleed .MarkLength}}" y2="0" />
|
||||||
|
|
||||||
<line x1="{{.Size}}" y1="-{{.Bleed}}" x2="{{.Size}}" y2="-{{appendBleed .Bleed .MarkLength}}" />
|
<line x1="{{.Width}}" y1="-{{.Bleed}}" x2="{{.Width}}" y2="-{{appendBleed .Bleed .MarkLength}}" />
|
||||||
<line x1="{{add .Size .Bleed}}" y1="0" x2="{{addThree .Size .Bleed .MarkLength}}" y2="0" />
|
<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="0" y1="{{add .Height .Bleed}}" x2="0" y2="{{addThree .Height .Bleed .MarkLength}}" />
|
||||||
<line x1="-{{.Bleed}}" y1="{{.Size}}" x2="-{{appendBleed .Bleed .MarkLength}}" y2="{{.Size}}" />
|
<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="{{.Width}}" y1="{{add .Height .Bleed}}" x2="{{.Width}}" y2="{{addThree .Height .Bleed .MarkLength}}" />
|
||||||
<line x1="{{add .Size .Bleed}}" y1="{{.Size}}" x2="{{addThree .Size .Bleed .MarkLength}}" y2="{{.Size}}" />
|
<line x1="{{add .Width .Bleed}}" y1="{{.Height}}" x2="{{addThree .Width .Bleed .MarkLength}}" y2="{{.Height}}" />
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
`
|
`
|
||||||
|
|||||||
20
internal/svgtemplate/template_test.go
Normal file
20
internal/svgtemplate/template_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user