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 # Print version
./printer-backend version ./printer-backend version
# Generate a square SVG item template (writes to data/svg_template/) # Generate an SVG item template (writes to data/svg_template/)
./printer-backend template -o my_item.svg -s 80 -b 2 -m 5 -p 3 ./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 ## Quick check

View File

@ -12,6 +12,9 @@ import (
var ( var (
sizeFlag float64 sizeFlag float64
widthFlag float64
heightFlag float64
cornerRadiusFlag float64
bleedFlag float64 bleedFlag float64
marginFlag float64 marginFlag float64
paddingFlag float64 paddingFlag float64
@ -20,9 +23,26 @@ var (
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")

View File

@ -5,13 +5,17 @@ import (
) )
// Pack computes the maximum grid of items that fit on the plate. // 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 { func Pack(plate model.Plate, spec model.ItemSpec, spacingMM float64) model.LayoutPreview {
pw := plate.PrintableWidth() pw := plate.PrintableWidth()
ph := plate.PrintableHeight() ph := plate.PrintableHeight()
itemW := spec.SizeMM footW := spec.ViewportWidth()
cw := itemW + spacingMM footH := spec.ViewportHeight()
ch := itemW + spacingMM canvasW := spec.CanvasWidthMM()
canvasH := spec.CanvasHeightMM()
trim := spec.TrimOffsetMM()
cw := canvasW + spacingMM
ch := canvasH + spacingMM
cols, rows := gridCount(pw, ph, cw, ch) cols, rows := gridCount(pw, ph, cw, ch)
count := cols * rows count := cols * rows
@ -38,9 +42,11 @@ func Pack(plate model.Plate, spec model.ItemSpec, spacingMM float64) model.Layou
PrintableHMM: ph, PrintableHMM: ph,
CellWidthMM: cw, CellWidthMM: cw,
CellHeightMM: ch, CellHeightMM: ch,
FootprintWMM: itemW, FootprintWMM: footW,
FootprintHMM: itemW, FootprintHMM: footH,
SpacingMM: spacingMM, TrimOffsetMM: trim,
CanvasWidthMM: canvasW,
CanvasHeightMM: canvasH,
Columns: cols, Columns: cols,
Rows: rows, Rows: rows,
Count: count, Count: count,

View File

@ -15,29 +15,52 @@ func TestPack(t *testing.T) {
MarginBottom: 10, MarginBottom: 10,
MarginLeft: 10, MarginLeft: 10,
} }
spec := model.ItemSpec{SizeMM: 80, BleedMM: 2, MarginMM: 5} spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5}
// viewport 80mm, cell = 80+2 = 82, printable 280x380 -> cols=3, rows=4 -> 12 // canvas 106mm (80+2*13), cell 108, printable 280×380 → cols=2, rows=3
preview := Pack(plate, spec, 2) preview := Pack(plate, spec, 2)
if preview.Columns != 3 { if preview.Columns != 2 {
t.Fatalf("columns: got %d want 3", preview.Columns) t.Fatalf("columns: got %d want 2", preview.Columns)
} }
if preview.Rows != 4 { if preview.Rows != 3 {
t.Fatalf("rows: got %d want 4", preview.Rows) t.Fatalf("rows: got %d want 3", preview.Rows)
} }
if preview.Count != 12 { if preview.Count != 6 {
t.Fatalf("count: got %d want 12", preview.Count) t.Fatalf("count: got %d want 6", preview.Count)
} }
if preview.FootprintWMM != 80 { if preview.FootprintWMM != 80 {
t.Fatalf("footprint width: got %v want 80", preview.FootprintWMM) t.Fatalf("footprint width: got %v want 80", preview.FootprintWMM)
} }
if len(preview.Positions) != 12 { if preview.CanvasWidthMM != 106 {
t.Fatalf("positions: got %d want 12", len(preview.Positions)) 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 { 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) 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) { func TestPackZeroCell(t *testing.T) {
plate := model.Plate{WidthMM: 10, HeightMM: 10} plate := model.Plate{WidthMM: 10, HeightMM: 10}
spec := model.ItemSpec{SizeMM: 100} spec := model.ItemSpec{SizeMM: 100}

View File

@ -33,6 +33,9 @@ type LayoutPreview struct {
CellHeightMM float64 `json:"cell_height_mm"` CellHeightMM float64 `json:"cell_height_mm"`
FootprintWMM float64 `json:"footprint_width_mm"` FootprintWMM float64 `json:"footprint_width_mm"`
FootprintHMM float64 `json:"footprint_height_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"` Columns int `json:"columns"`
Rows int `json:"rows"` Rows int `json:"rows"`
Count int `json:"count"` Count int `json:"count"`

View File

@ -1,15 +1,76 @@
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.
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"` BleedMM float64 `json:"bleed_mm"`
MarginMM float64 `json:"margin_mm"` MarginMM float64 `json:"margin_mm"`
PaddingMM float64 `json:"padding_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.
type Item struct { type Item struct {
ID string `json:"id"` ID string `json:"id"`

View File

@ -13,12 +13,11 @@ const renderDPI = 300
// BuildCompositeSVG assembles a plate-sized SVG by embedding each item as a PNG // BuildCompositeSVG assembles a plate-sized SVG by embedding each item as a PNG
// rendered by rsvg-convert (identical to the standalone item file). // 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) { func BuildCompositeSVG(plate model.Plate, spec model.ItemSpec, itemSVG []byte, preview model.LayoutPreview) ([]byte, error) {
itemW, itemH, err := svgViewportMM(itemSVG) canvasW := preview.CanvasWidthMM
if err != nil { canvasH := preview.CanvasHeightMM
return nil, err if canvasW <= 0 || canvasH <= 0 {
} canvasW = spec.CanvasWidthMM()
if itemW <= 0 || itemH <= 0 { canvasH = spec.CanvasHeightMM()
itemW, itemH = spec.SizeMM, spec.SizeMM
} }
itemPNG, err := svgToPNGViaRsvg(itemSVG, renderDPI) 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 { for _, pos := range preview.Positions {
fmt.Fprintf(&b, fmt.Fprintf(&b,
`<image x="%.4f" y="%.4f" width="%.4f" height="%.4f" href="data:image/png;base64,%s"/>`, `<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 ( import (
"bytes" "bytes"
"fmt"
"os" "os"
"strings" "strings"
"testing" "testing"
@ -38,7 +39,7 @@ func TestBuildCompositeSVG(t *testing.T) {
WidthMM: 300, HeightMM: 400, WidthMM: 300, HeightMM: 400,
MarginTop: 10, MarginRight: 10, MarginBottom: 10, MarginLeft: 10, 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) preview := layout.Pack(plate, spec, 2)
out, err := BuildCompositeSVG(plate, spec, []byte(sampleItemSVG), preview) out, err := BuildCompositeSVG(plate, spec, []byte(sampleItemSVG), preview)
@ -53,7 +54,10 @@ func TestBuildCompositeSVG(t *testing.T) {
t.Fatal("expected embedded item images") t.Fatal("expected embedded item images")
} }
if preview.Count > 0 && !strings.Contains(s, `x="10.0000"`) { 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, WidthMM: 200, HeightMM: 200,
MarginLeft: 10, MarginTop: 10, MarginRight: 10, MarginBottom: 10, 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) preview := layout.Pack(plate, spec, 2)
var buf bytes.Buffer 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) t.Fatal(err)
} }

View File

@ -320,8 +320,16 @@
<input type="text" x-model="itemForm.name" placeholder="z.B. sticker_80" :required="!itemEditingId"> <input type="text" x-model="itemForm.name" placeholder="z.B. sticker_80" :required="!itemEditingId">
</div> </div>
<div> <div>
<label>Größe (mm)</label> <label>Breite (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.size_mm" required> <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>
<div> <div>
<label>Bleed (mm)</label> <label>Bleed (mm)</label>
@ -399,7 +407,7 @@
<div class="layout-stats"> <div class="layout-stats">
<span><strong x-text="layoutPreview.count"></strong> Items passen</span> <span><strong x-text="layoutPreview.count"></strong> Items passen</span>
<span><strong x-text="layoutPreview.columns + ' × ' + layoutPreview.rows"></strong> Raster</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>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> <span>Druckfläche <strong x-text="layoutPreview.printable_width_mm.toFixed(1) + ' × ' + layoutPreview.printable_height_mm.toFixed(1) + ' mm'"></strong></span>
</div> </div>
@ -456,7 +464,7 @@
</div> </div>
<div class="item-body"> <div class="item-body">
<strong x-text="labelItem(it)"></strong> <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> <span><code x-text="it.svg_template"></code></span>
</div> </div>
<div class="item-actions row-actions"> <div class="item-actions row-actions">
@ -500,7 +508,9 @@
itemEditingId: null, itemEditingId: null,
itemForm: { itemForm: {
name: '', name: '',
size_mm: 80, width_mm: 80,
height_mm: 80,
corner_radius_mm: 5,
bleed_mm: 2, bleed_mm: 2,
margin_mm: 5, margin_mm: 5,
padding_mm: 3, padding_mm: 3,
@ -723,18 +733,33 @@
defaultItemForm() { defaultItemForm() {
return { return {
name: '', name: '',
size_mm: 80, width_mm: 80,
height_mm: 80,
corner_radius_mm: 5,
bleed_mm: 2, bleed_mm: 2,
margin_mm: 5, margin_mm: 5,
padding_mm: 3, 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) { editItem(it) {
this.itemEditingId = it.id; 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 = { this.itemForm = {
name: it.name || '', 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, bleed_mm: it.spec.bleed_mm,
margin_mm: it.spec.margin_mm, margin_mm: it.spec.margin_mm,
padding_mm: it.spec.padding_mm, padding_mm: it.spec.padding_mm,
@ -756,7 +781,9 @@
this.itemSuccess = ''; this.itemSuccess = '';
const body = { const body = {
name: this.itemForm.name, 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, bleed_mm: Number(this.itemForm.bleed_mm) || 0,
margin_mm: Number(this.itemForm.margin_mm) || 0, margin_mm: Number(this.itemForm.margin_mm) || 0,
padding_mm: Number(this.itemForm.padding_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;'); const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
let items = ''; 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 fpW = p.footprint_width_mm;
const fpH = p.footprint_height_mm; const fpH = p.footprint_height_mm;
for (const pos of p.positions || []) { 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; 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 { 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
@ -191,11 +183,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 {
@ -218,12 +209,12 @@ func updateItem(s *store.ItemStore) http.HandlerFunc {
writeError(w, http.StatusBadRequest, err) writeError(w, http.StatusBadRequest, err)
return return
} }
saved, err := s.Update(id, req.Name, 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.Update(id, req.Name, spec)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err) writeError(w, http.StatusBadRequest, err)
return 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. // 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)
} }
@ -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). // 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) { func (s *ItemStore) Update(id string, 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
} }
item, err := s.Get(id) item, err := s.Get(id)
if err != nil { if err != nil {
return model.Item{}, err 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 { if err := svgtemplate.WriteFile(item.SVGTemplate, data); err != nil {
return model.Item{}, fmt.Errorf("write svg: %w", err) return model.Item{}, fmt.Errorf("write svg: %w", err)
} }

View File

@ -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
CornerRadius float64
Bleed float64 Bleed float64
Margin float64 Margin float64
Padding float64 Padding float64
ViewBoxMin float64 ViewBoxMin float64
ViewBoxSize float64 ViewBoxWidth float64
MaskSize float64 ViewBoxHeight float64
OuterSize float64 MaskWidth float64
OuterOffset float64 MaskHeight float64
InnerSize float64 OuterWidth float64
InnerOffset float64 OuterHeight float64
OuterOffsetX float64
OuterOffsetY float64
InnerWidth float64
InnerHeight float64
InnerOffsetX float64
InnerOffsetY float64
InnerRadius float64
MarkLength 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,
Height: height,
CornerRadius: cornerRadius,
Bleed: bleed, Bleed: bleed,
Margin: margin, Margin: margin,
Padding: padding, Padding: padding,
ViewBoxMin: viewBoxMin, ViewBoxMin: -offset,
ViewBoxSize: viewBoxSize, ViewBoxWidth: viewBoxW,
ViewBoxHeight: viewBoxH,
MarkLength: defaultMarkLength, MarkLength: defaultMarkLength,
MaskSize: size, MaskWidth: width,
OuterSize: size + (bleed * 2), MaskHeight: height,
OuterOffset: -bleed, OuterWidth: width + (bleed * 2),
InnerSize: size - (padding * 2), OuterHeight: height + (bleed * 2),
InnerOffset: padding, 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"?> 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>
` `

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