Added the possibilty to not only make quadratic items
This commit is contained in:
parent
be58c5941d
commit
bca4fcc936
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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, '&').replace(/</g, '<');
|
||||
|
||||
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;
|
||||
|
||||
@ -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
|
||||
|
||||
30
internal/server/api/item_spec.go
Normal file
30
internal/server/api/item_spec.go
Normal file
@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import "printer.backend/internal/model"
|
||||
|
||||
type saveItemRequest struct {
|
||||
Name string `json:"name"`
|
||||
SizeMM float64 `json:"size_mm"`
|
||||
WidthMM float64 `json:"width_mm"`
|
||||
HeightMM float64 `json:"height_mm"`
|
||||
CornerRadiusMM float64 `json:"corner_radius_mm"`
|
||||
BleedMM float64 `json:"bleed_mm"`
|
||||
MarginMM float64 `json:"margin_mm"`
|
||||
PaddingMM float64 `json:"padding_mm"`
|
||||
}
|
||||
|
||||
func itemSpecFromRequest(req saveItemRequest) (model.ItemSpec, error) {
|
||||
spec := model.ItemSpec{
|
||||
SizeMM: req.SizeMM,
|
||||
WidthMM: req.WidthMM,
|
||||
HeightMM: req.HeightMM,
|
||||
CornerRadiusMM: req.CornerRadiusMM,
|
||||
BleedMM: req.BleedMM,
|
||||
MarginMM: req.MarginMM,
|
||||
PaddingMM: req.PaddingMM,
|
||||
}
|
||||
if err := spec.Normalize(); err != nil {
|
||||
return model.ItemSpec{}, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
`
|
||||
|
||||
20
internal/svgtemplate/template_test.go
Normal file
20
internal/svgtemplate/template_test.go
Normal file
@ -0,0 +1,20 @@
|
||||
package svgtemplate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildSVGRootMatchesViewBox(t *testing.T) {
|
||||
data := Build(10, 80, 1, 1, 1, 1)
|
||||
var buf bytes.Buffer
|
||||
if err := Write(&buf, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := buf.String()
|
||||
want := `width="26mm" height="96mm" viewBox="-8 -8 26 96"`
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("expected root %q in:\n%s", want, s)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user