Added Printjobs
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user