Added Printjobs

This commit is contained in:
2026-05-26 18:27:37 +02:00
parent 929969d03a
commit 2432a34198
17 changed files with 1255 additions and 8 deletions
+57
View File
@@ -0,0 +1,57 @@
package itemimage
import (
"encoding/base64"
"fmt"
"net/http"
"regexp"
"strings"
"printer.backend/internal/model"
"printer.backend/internal/svgtemplate"
)
var maskGroupRE = regexp.MustCompile(`(?s)(<g clip-path="url\(#item-mask\)">).*?(</g>)`)
// Embed replaces the green placeholder in an item SVG with the given raster image,
// clipped to the product mask (same area as the green preview rect).
func Embed(itemSVG []byte, spec model.ItemSpec, imageData []byte, contentType string) ([]byte, error) {
if len(imageData) == 0 {
return nil, fmt.Errorf("empty image data")
}
if err := spec.Normalize(); err != nil {
return nil, err
}
d := svgtemplate.Build(
spec.WidthMM, spec.HeightMM,
spec.BleedMM, spec.MarginMM, spec.PaddingMM,
spec.CornerRadiusMM,
)
prepared, mime, err := prepareRaster(imageData, d)
if err != nil {
return nil, err
}
if mime == "" {
mime = normalizeMIME(contentType, prepared)
}
href := fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(prepared))
inner := fmt.Sprintf(
`<image x="%.4f" y="%.4f" width="%.4f" height="%.4f" preserveAspectRatio="xMidYMid slice" href="%s"/>`,
d.OuterOffsetX, d.OuterOffsetY, d.OuterWidth, d.OuterHeight, href,
)
out := maskGroupRE.ReplaceAll(itemSVG, []byte("${1}"+inner+"${2}"))
if string(out) == string(itemSVG) {
return nil, fmt.Errorf("item svg has no printable mask group")
}
return out, nil
}
func normalizeMIME(contentType string, data []byte) string {
if ct := strings.TrimSpace(strings.Split(contentType, ";")[0]); ct != "" {
return ct
}
return http.DetectContentType(data)
}
+78
View File
@@ -0,0 +1,78 @@
package itemimage
import (
"bytes"
"image"
"image/color"
"image/png"
"strings"
"testing"
"printer.backend/internal/model"
)
const sampleItemSVG = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-13 -13 106 106">
<defs>
<clipPath id="item-mask">
<rect x="0" y="0" width="80" height="80" rx="5" ry="5" />
</clipPath>
</defs>
<g clip-path="url(#item-mask)">
<rect x="-2" y="-2" width="84" height="84" fill="#00FF00" opacity="0.7" />
</g>
</svg>`
func testPNG(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
}
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
func TestEmbedReplacesGreenMask(t *testing.T) {
spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3}
out, err := Embed([]byte(sampleItemSVG), spec, testPNG(1, 1), "image/png")
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "#00FF00") || strings.Contains(s, "#00ff00") {
t.Fatal("green placeholder should be replaced")
}
if !strings.Contains(s, `<image `) || !strings.Contains(s, `preserveAspectRatio="xMidYMid slice"`) {
t.Fatalf("expected embedded image, got: %s", s)
}
if !strings.Contains(s, `x="-2.0000"`) {
t.Fatal("expected bleed-aligned image placement")
}
if !strings.Contains(s, "data:image/jpeg;base64,") {
t.Fatal("expected downscaled jpeg data uri")
}
}
func TestEmbedDownscalesLargeImage(t *testing.T) {
spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3}
huge := testPNG(4000, 3000)
out, err := Embed([]byte(sampleItemSVG), spec, huge, "image/png")
if err != nil {
t.Fatal(err)
}
const maxSVG = 600_000 // ~600 KB embedded payload is plenty for rsvg
if len(out) > maxSVG {
t.Fatalf("embedded svg too large: %d bytes", len(out))
}
}
func TestEmbedInvalidSVG(t *testing.T) {
spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5}
_, err := Embed([]byte("<svg></svg>"), spec, testPNG(1, 1), "image/png")
if err == nil {
t.Fatal("expected error")
}
}
+84
View File
@@ -0,0 +1,84 @@
package itemimage
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"math"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"printer.backend/internal/svgtemplate"
)
// EmbedDPI matches plate PDF rasterization; embedded pixels need not exceed this resolution.
const EmbedDPI = 300
const jpegEmbedQuality = 88
// prepareRaster decodes and downscales image data so the resulting SVG stays small enough for rsvg-convert.
func prepareRaster(data []byte, d svgtemplate.Data) ([]byte, string, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, "", fmt.Errorf("decode image: %w", err)
}
maxW := mmToPx(d.OuterWidth)
maxH := mmToPx(d.OuterHeight)
if maxW < 1 {
maxW = 1
}
if maxH < 1 {
maxH = 1
}
covered := scaleCover(img, maxW, maxH)
rgba := image.NewNRGBA(image.Rect(0, 0, maxW, maxH))
draw.CatmullRom.Scale(rgba, rgba.Bounds(), covered, covered.Bounds(), draw.Over, nil)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, rgba, &jpeg.Options{Quality: jpegEmbedQuality}); err != nil {
return nil, "", fmt.Errorf("encode jpeg: %w", err)
}
return buf.Bytes(), "image/jpeg", nil
}
func mmToPx(mm float64) int {
return int(math.Ceil(mm / 25.4 * EmbedDPI))
}
// scaleCover returns an image scaled to cover dw×dh (center crop), for preserveAspectRatio slice.
func scaleCover(src image.Image, dw, dh int) image.Image {
sb := src.Bounds()
sw, sh := sb.Dx(), sb.Dy()
if sw <= 0 || sh <= 0 {
return image.NewNRGBA(image.Rect(0, 0, dw, dh))
}
scale := math.Max(float64(dw)/float64(sw), float64(dh)/float64(sh))
nw := int(math.Ceil(float64(sw) * scale))
nh := int(math.Ceil(float64(sh) * scale))
scaled := image.NewNRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(scaled, scaled.Bounds(), src, sb, draw.Over, nil)
x0 := (nw - dw) / 2
y0 := (nh - dh) / 2
if x0 < 0 {
x0 = 0
}
if y0 < 0 {
y0 = 0
}
if nw < dw {
dw = nw
}
if nh < dh {
dh = nh
}
cropped := image.NewNRGBA(image.Rect(0, 0, dw, dh))
draw.Draw(cropped, cropped.Bounds(), scaled, image.Point{x0, y0}, draw.Src)
return cropped
}