diff --git a/README.md b/README.md
index 977ea92..b703b04 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/cmd/generate_template.go b/cmd/generate_template.go
index 9bbcb7f..fd90aef 100644
--- a/cmd/generate_template.go
+++ b/cmd/generate_template.go
@@ -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")
diff --git a/internal/layout/layout.go b/internal/layout/layout.go
index 5bacd70..a13d5ef 100644
--- a/internal/layout/layout.go
+++ b/internal/layout/layout.go
@@ -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,
}
}
diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go
index b278777..84f19ec 100644
--- a/internal/layout/layout_test.go
+++ b/internal/layout/layout_test.go
@@ -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}
diff --git a/internal/model/configuration.go b/internal/model/configuration.go
index e341bda..cf3db29 100644
--- a/internal/model/configuration.go
+++ b/internal/model/configuration.go
@@ -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"`
diff --git a/internal/model/item.go b/internal/model/item.go
index 3814698..5c48a9c 100644
--- a/internal/model/item.go
+++ b/internal/model/item.go
@@ -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.
diff --git a/internal/platepdf/composite.go b/internal/platepdf/composite.go
index 5a9bc4f..5eac0e4 100644
--- a/internal/platepdf/composite.go
+++ b/internal/platepdf/composite.go
@@ -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,
`