Added the possibilty to not only make quadratic items

This commit is contained in:
simon 2026-05-26 17:35:50 +02:00
parent be58c5941d
commit bca4fcc936
15 changed files with 371 additions and 134 deletions

View File

@ -92,8 +92,10 @@ Directories are created automatically when you save plates, items, or configurat
# Print version
./printer-backend version
# Generate a square SVG item template (writes to data/svg_template/)
./printer-backend template -o my_item.svg -s 80 -b 2 -m 5 -p 3
# 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

View File

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

View File

@ -5,13 +5,17 @@ import (
)
// Pack computes the maximum grid of items that fit on the plate.
// Item spacing uses the SVG viewport size (spec.SizeMM); bleed/margin/padding live inside the template.
// 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()
itemW := spec.SizeMM
cw := itemW + spacingMM
ch := itemW + spacingMM
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
@ -30,21 +34,23 @@ func Pack(plate model.Plate, spec model.ItemSpec, spacingMM float64) model.Layou
}
return model.LayoutPreview{
PlateWidthMM: plate.WidthMM,
PlateHeightMM: plate.HeightMM,
PrintableXMM: ox,
PrintableYMM: oy,
PrintableWMM: pw,
PrintableHMM: ph,
CellWidthMM: cw,
CellHeightMM: ch,
FootprintWMM: itemW,
FootprintHMM: itemW,
SpacingMM: spacingMM,
Columns: cols,
Rows: rows,
Count: count,
Positions: positions,
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,
}
}

View File

@ -15,29 +15,52 @@ func TestPack(t *testing.T) {
MarginBottom: 10,
MarginLeft: 10,
}
spec := model.ItemSpec{SizeMM: 80, BleedMM: 2, MarginMM: 5}
// viewport 80mm, cell = 80+2 = 82, printable 280x380 -> cols=3, rows=4 -> 12
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 != 3 {
t.Fatalf("columns: got %d want 3", preview.Columns)
if preview.Columns != 2 {
t.Fatalf("columns: got %d want 2", preview.Columns)
}
if preview.Rows != 4 {
t.Fatalf("rows: got %d want 4", preview.Rows)
if preview.Rows != 3 {
t.Fatalf("rows: got %d want 3", preview.Rows)
}
if preview.Count != 12 {
t.Fatalf("count: got %d want 12", preview.Count)
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 len(preview.Positions) != 12 {
t.Fatalf("positions: got %d want 12", len(preview.Positions))
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}

View File

@ -33,6 +33,9 @@ type LayoutPreview struct {
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"`

View File

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

View File

@ -13,12 +13,11 @@ const renderDPI = 300
// BuildCompositeSVG assembles a plate-sized SVG by embedding each item as a PNG
// rendered by rsvg-convert (identical to the standalone item file).
func BuildCompositeSVG(plate model.Plate, spec model.ItemSpec, itemSVG []byte, preview model.LayoutPreview) ([]byte, error) {
itemW, itemH, err := svgViewportMM(itemSVG)
if err != nil {
return nil, err
}
if itemW <= 0 || itemH <= 0 {
itemW, itemH = spec.SizeMM, spec.SizeMM
canvasW := preview.CanvasWidthMM
canvasH := preview.CanvasHeightMM
if canvasW <= 0 || canvasH <= 0 {
canvasW = spec.CanvasWidthMM()
canvasH = spec.CanvasHeightMM()
}
itemPNG, err := svgToPNGViaRsvg(itemSVG, renderDPI)
@ -41,7 +40,7 @@ func BuildCompositeSVG(plate model.Plate, spec model.ItemSpec, itemSVG []byte, p
for _, pos := range preview.Positions {
fmt.Fprintf(&b,
`<image x="%.4f" y="%.4f" width="%.4f" height="%.4f" href="data:image/png;base64,%s"/>`,
pos.XMM, pos.YMM, itemW, itemH, itemB64,
pos.XMM, pos.YMM, canvasW, canvasH, itemB64,
)
}

View File

@ -2,6 +2,7 @@ package platepdf
import (
"bytes"
"fmt"
"os"
"strings"
"testing"
@ -38,7 +39,7 @@ func TestBuildCompositeSVG(t *testing.T) {
WidthMM: 300, HeightMM: 400,
MarginTop: 10, MarginRight: 10, MarginBottom: 10, MarginLeft: 10,
}
spec := model.ItemSpec{SizeMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3}
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)
@ -53,7 +54,10 @@ func TestBuildCompositeSVG(t *testing.T) {
t.Fatal("expected embedded item images")
}
if preview.Count > 0 && !strings.Contains(s, `x="10.0000"`) {
t.Fatal("expected items at plate margin (footprint origin)")
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")
}
}
@ -73,11 +77,15 @@ func TestGeneratePDF(t *testing.T) {
WidthMM: 200, HeightMM: 200,
MarginLeft: 10, MarginTop: 10, MarginRight: 10, MarginBottom: 10,
}
spec := model.ItemSpec{SizeMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3}
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.SizeMM, spec.BleedMM, spec.MarginMM, spec.PaddingMM)); err != nil {
if err := svgtemplate.Write(&buf, svgtemplate.Build(
spec.WidthMM, spec.HeightMM,
spec.BleedMM, spec.MarginMM, spec.PaddingMM,
spec.CornerRadiusMM,
)); err != nil {
t.Fatal(err)
}

View File

@ -320,8 +320,16 @@
<input type="text" x-model="itemForm.name" placeholder="z.B. sticker_80" :required="!itemEditingId">
</div>
<div>
<label>Größe (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.size_mm" required>
<label>Breite (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.width_mm" required>
</div>
<div>
<label>Höhe (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.height_mm" required>
</div>
<div>
<label>Eckenradius (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.corner_radius_mm">
</div>
<div>
<label>Bleed (mm)</label>
@ -399,7 +407,7 @@
<div class="layout-stats">
<span><strong x-text="layoutPreview.count"></strong> Items passen</span>
<span><strong x-text="layoutPreview.columns + ' × ' + layoutPreview.rows"></strong> Raster</span>
<span>Item <strong x-text="layoutPreview.footprint_width_mm.toFixed(1) + ' mm'"></strong></span>
<span>Item <strong x-text="layoutPreview.footprint_width_mm.toFixed(1) + ' × ' + layoutPreview.footprint_height_mm.toFixed(1) + ' mm'"></strong></span>
<span>Zellenabstand <strong x-text="layoutPreview.cell_width_mm.toFixed(1) + ' mm'"></strong></span>
<span>Druckfläche <strong x-text="layoutPreview.printable_width_mm.toFixed(1) + ' × ' + layoutPreview.printable_height_mm.toFixed(1) + ' mm'"></strong></span>
</div>
@ -456,7 +464,7 @@
</div>
<div class="item-body">
<strong x-text="labelItem(it)"></strong>
<span x-text="it.spec.size_mm + ' mm · bleed ' + it.spec.bleed_mm + ' · margin ' + it.spec.margin_mm"></span>
<span x-text="itemSizeLabel(it)"></span>
<span><code x-text="it.svg_template"></code></span>
</div>
<div class="item-actions row-actions">
@ -500,7 +508,9 @@
itemEditingId: null,
itemForm: {
name: '',
size_mm: 80,
width_mm: 80,
height_mm: 80,
corner_radius_mm: 5,
bleed_mm: 2,
margin_mm: 5,
padding_mm: 3,
@ -723,18 +733,33 @@
defaultItemForm() {
return {
name: '',
size_mm: 80,
width_mm: 80,
height_mm: 80,
corner_radius_mm: 5,
bleed_mm: 2,
margin_mm: 5,
padding_mm: 3,
};
},
itemSizeLabel(it) {
const w = it.spec.width_mm || it.spec.size_mm;
const h = it.spec.height_mm || it.spec.size_mm;
let s = w + ' × ' + h + ' mm';
if (it.spec.corner_radius_mm) s += ' · r ' + it.spec.corner_radius_mm;
s += ' · bleed ' + it.spec.bleed_mm + ' · margin ' + it.spec.margin_mm;
return s;
},
editItem(it) {
this.itemEditingId = it.id;
const w = it.spec.width_mm || it.spec.size_mm;
const h = it.spec.height_mm || it.spec.size_mm;
this.itemForm = {
name: it.name || '',
size_mm: it.spec.size_mm,
width_mm: w,
height_mm: h,
corner_radius_mm: it.spec.corner_radius_mm || 0,
bleed_mm: it.spec.bleed_mm,
margin_mm: it.spec.margin_mm,
padding_mm: it.spec.padding_mm,
@ -756,7 +781,9 @@
this.itemSuccess = '';
const body = {
name: this.itemForm.name,
size_mm: Number(this.itemForm.size_mm),
width_mm: Number(this.itemForm.width_mm),
height_mm: Number(this.itemForm.height_mm),
corner_radius_mm: Number(this.itemForm.corner_radius_mm) || 0,
bleed_mm: Number(this.itemForm.bleed_mm) || 0,
margin_mm: Number(this.itemForm.margin_mm) || 0,
padding_mm: Number(this.itemForm.padding_mm) || 0,
@ -979,10 +1006,16 @@
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
let items = '';
const trim = p.trim_offset_mm || 0;
const canvasW = p.canvas_width_mm || p.footprint_width_mm;
const canvasH = p.canvas_height_mm || p.footprint_height_mm;
const fpW = p.footprint_width_mm;
const fpH = p.footprint_height_mm;
for (const pos of p.positions || []) {
items += `<rect x="${pos.x_mm * scale}" y="${pos.y_mm * scale}" width="${fpW * scale}" height="${fpH * scale}" rx="3" fill="#22c55e" fill-opacity="0.35" stroke="#22c55e" stroke-width="1"/>`;
const x = pos.x_mm * scale;
const y = pos.y_mm * scale;
items += `<rect x="${x}" y="${y}" width="${canvasW * scale}" height="${canvasH * scale}" fill="none" stroke="#8b9cb3" stroke-width="0.8" stroke-dasharray="3,2"/>`;
items += `<rect x="${(pos.x_mm + trim) * scale}" y="${(pos.y_mm + trim) * scale}" width="${fpW * scale}" height="${fpH * scale}" rx="3" fill="#22c55e" fill-opacity="0.35" stroke="#22c55e" stroke-width="1"/>`;
}
const px = p.printable_x_mm * scale;

View File

@ -176,14 +176,6 @@ func listItems(s *store.ItemStore) http.HandlerFunc {
}
}
type saveItemRequest struct {
Name string `json:"name"`
SizeMM float64 `json:"size_mm"`
BleedMM float64 `json:"bleed_mm"`
MarginMM float64 `json:"margin_mm"`
PaddingMM float64 `json:"padding_mm"`
}
func saveItem(s *store.ItemStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req saveItemRequest
@ -191,11 +183,10 @@ func saveItem(s *store.ItemStore) http.HandlerFunc {
writeError(w, http.StatusBadRequest, err)
return
}
spec := model.ItemSpec{
SizeMM: req.SizeMM,
BleedMM: req.BleedMM,
MarginMM: req.MarginMM,
PaddingMM: req.PaddingMM,
spec, err := itemSpecFromRequest(req)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Create(req.Name, spec)
if err != nil {
@ -218,12 +209,12 @@ func updateItem(s *store.ItemStore) http.HandlerFunc {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Update(id, req.Name, model.ItemSpec{
SizeMM: req.SizeMM,
BleedMM: req.BleedMM,
MarginMM: req.MarginMM,
PaddingMM: req.PaddingMM,
})
spec, err := itemSpecFromRequest(req)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Update(id, req.Name, spec)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return

View File

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

View File

@ -49,12 +49,16 @@ func (s *ItemStore) Get(id string) (model.Item, error) {
// Create generates SVG + metadata for a new item.
func (s *ItemStore) Create(name string, spec model.ItemSpec) (model.Item, error) {
if spec.SizeMM <= 0 {
return model.Item{}, fmt.Errorf("size_mm must be positive")
if err := spec.Normalize(); err != nil {
return model.Item{}, err
}
basename := svgBasename(name)
data := svgtemplate.Build(spec.SizeMM, spec.BleedMM, spec.MarginMM, spec.PaddingMM)
data := svgtemplate.Build(
spec.WidthMM, spec.HeightMM,
spec.BleedMM, spec.MarginMM, spec.PaddingMM,
spec.CornerRadiusMM,
)
if err := svgtemplate.WriteFile(basename, data); err != nil {
return model.Item{}, fmt.Errorf("write svg: %w", err)
}
@ -67,14 +71,18 @@ func (s *ItemStore) Create(name string, spec model.ItemSpec) (model.Item, error)
// 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 spec.SizeMM <= 0 {
return model.Item{}, fmt.Errorf("size_mm must be positive")
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.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(item.SVGTemplate, data); err != nil {
return model.Item{}, fmt.Errorf("write svg: %w", err)
}

View File

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

View File

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

View File

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