From bca4fcc936e4d9ad0e5bd710c97bccaba8a18be4 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 26 May 2026 17:35:50 +0200 Subject: [PATCH] Added the possibilty to not only make quadratic items --- README.md | 6 +- cmd/generate_template.go | 45 +++++++++---- internal/layout/layout.go | 44 ++++++------ internal/layout/layout_test.go | 43 +++++++++--- internal/model/configuration.go | 3 + internal/model/item.go | 71 +++++++++++++++++-- internal/platepdf/composite.go | 13 ++-- internal/platepdf/platepdf_test.go | 16 +++-- internal/server/admin/static/index.html | 51 +++++++++++--- internal/server/api/api.go | 29 +++----- internal/server/api/item_spec.go | 30 +++++++++ internal/store/item.go | 20 ++++-- internal/svgtemplate/data.go | 90 +++++++++++++++++-------- internal/svgtemplate/template.go | 24 +++---- internal/svgtemplate/template_test.go | 20 ++++++ 15 files changed, 371 insertions(+), 134 deletions(-) create mode 100644 internal/server/api/item_spec.go create mode 100644 internal/svgtemplate/template_test.go 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, ``, - pos.XMM, pos.YMM, itemW, itemH, itemB64, + pos.XMM, pos.YMM, canvasW, canvasH, itemB64, ) } diff --git a/internal/platepdf/platepdf_test.go b/internal/platepdf/platepdf_test.go index 3ea0a75..c8e9191 100644 --- a/internal/platepdf/platepdf_test.go +++ b/internal/platepdf/platepdf_test.go @@ -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) } diff --git a/internal/server/admin/static/index.html b/internal/server/admin/static/index.html index b2210d3..d22c40e 100644 --- a/internal/server/admin/static/index.html +++ b/internal/server/admin/static/index.html @@ -320,8 +320,16 @@
- - + + +
+
+ + +
+
+ +
@@ -399,7 +407,7 @@
Items passen Raster - Item + Item Zellenabstand Druckfläche
@@ -456,7 +464,7 @@
- +
@@ -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, '&').replace(/`; + const x = pos.x_mm * scale; + const y = pos.y_mm * scale; + items += ``; + items += ``; } const px = p.printable_x_mm * scale; diff --git a/internal/server/api/api.go b/internal/server/api/api.go index d2c8bf5..b3529f9 100644 --- a/internal/server/api/api.go +++ b/internal/server/api/api.go @@ -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 diff --git a/internal/server/api/item_spec.go b/internal/server/api/item_spec.go new file mode 100644 index 0000000..fdaa976 --- /dev/null +++ b/internal/server/api/item_spec.go @@ -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 +} diff --git a/internal/store/item.go b/internal/store/item.go index f9d4e5f..0af6887 100644 --- a/internal/store/item.go +++ b/internal/store/item.go @@ -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) } diff --git a/internal/svgtemplate/data.go b/internal/svgtemplate/data.go index 4d65352..3a82cd6 100644 --- a/internal/svgtemplate/data.go +++ b/internal/svgtemplate/data.go @@ -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, } } diff --git a/internal/svgtemplate/template.go b/internal/svgtemplate/template.go index aaa4724..58b396f 100644 --- a/internal/svgtemplate/template.go +++ b/internal/svgtemplate/template.go @@ -10,35 +10,35 @@ import ( ) const svgTemplate = ` - + - + - + - + - + - + - - + + - - + + - - + + ` diff --git a/internal/svgtemplate/template_test.go b/internal/svgtemplate/template_test.go new file mode 100644 index 0000000..c4d52bb --- /dev/null +++ b/internal/svgtemplate/template_test.go @@ -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) + } +}