diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..884652a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /src + +# Copy module files and download deps +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source +COPY . . + +# Build the web server +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /zplgfa-web ./cmd/web + +# ─── Final stage ──────────────────────────────────────────────────────────── +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates + +COPY --from=builder /zplgfa-web /usr/local/bin/zplgfa-web + +EXPOSE 4543 + +ENTRYPOINT ["/usr/local/bin/zplgfa-web"] diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 0000000..1da1295 --- /dev/null +++ b/cmd/web/main.go @@ -0,0 +1,452 @@ +package main + +import ( + "bytes" + "fmt" + "image" + "image/color" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "log" + "math" + "net/http" + "strconv" + "strings" + + "github.com/anthonynsimon/bild/blur" + "github.com/anthonynsimon/bild/effect" + "github.com/anthonynsimon/bild/segment" + "github.com/nfnt/resize" + + "simonwaldherr.de/go/zplgfa" +) + +const maxUploadSize = 20 << 20 // 20 MB + +func getGraphicType(t string) zplgfa.GraphicType { + switch strings.ToUpper(t) { + case "ASCII": + return zplgfa.ASCII + case "BINARY": + return zplgfa.Binary + default: + return zplgfa.CompressedASCII + } +} + +func invertImage(img image.Image) image.Image { + b := img.Bounds() + out := image.NewNRGBA(b) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bv, a := img.At(x, y).RGBA() + out.Set(x, y, color.RGBA{ + R: uint8((65535 - r) >> 8), + G: uint8((65535 - g) >> 8), + B: uint8((65535 - bv) >> 8), + A: uint8(a >> 8), + }) + } + } + return out +} + +func monochromeImage(img image.Image) image.Image { + b := img.Bounds() + out := image.NewNRGBA(b) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bv, a := img.At(x, y).RGBA() + var v uint8 + if r > math.MaxUint16/2 || g > math.MaxUint16/2 || bv > math.MaxUint16/2 { + v = 255 + } + out.Set(x, y, color.RGBA{R: v, G: v, B: v, A: uint8(a >> 8)}) + } + } + return out +} + +func processImage(img image.Image, editFlag string, width, height uint) image.Image { + if strings.Contains(editFlag, "monochrome") { + img = monochromeImage(img) + } + bounds := img.Bounds() + if strings.Contains(editFlag, "blur") { + img = blur.Gaussian(img, float64(bounds.Dx())/300) + } + if strings.Contains(editFlag, "edge") { + img = effect.Sobel(img) + } + if strings.Contains(editFlag, "segment") { + img = segment.Threshold(img, 128) + } + if strings.Contains(editFlag, "invert") { + img = invertImage(img) + } + if width > 0 || height > 0 { + img = resize.Resize(width, height, img, resize.MitchellNetravali) + } + return img +} + +func convertHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + http.Error(w, "file too large", http.StatusBadRequest) + return + } + + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, "missing file", http.StatusBadRequest) + return + } + defer file.Close() + + img, _, err := image.Decode(file) + if err != nil { + http.Error(w, fmt.Sprintf("cannot decode image: %s", err), http.StatusBadRequest) + return + } + + var w2, h2 uint + if v, err := strconv.ParseUint(r.FormValue("width"), 10, 32); err == nil { + w2 = uint(v) + } + if v, err := strconv.ParseUint(r.FormValue("height"), 10, 32); err == nil { + h2 = uint(v) + } + + editFlag := r.FormValue("edit") + graphicType := getGraphicType(r.FormValue("type")) + + img = processImage(img, editFlag, w2, h2) + flat := zplgfa.FlattenImage(img) + zpl := zplgfa.ConvertToZPL(flat, graphicType) + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="label.zpl"`) + fmt.Fprint(w, zpl) +} + +func previewHandler(w http.ResponseWriter, r *http.Request) { + // Returns image info (dimensions) before conversion + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + http.Error(w, "file too large", http.StatusBadRequest) + return + } + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, "missing file", http.StatusBadRequest) + return + } + defer file.Close() + + var buf bytes.Buffer + buf.ReadFrom(file) + cfg, _, err := image.DecodeConfig(bytes.NewReader(buf.Bytes())) + if err != nil { + http.Error(w, "cannot decode image", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"width":%d,"height":%d}`, cfg.Width, cfg.Height) +} + +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/convert", convertHandler) + mux.HandleFunc("/preview", previewHandler) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(indexHTML)) + }) + + addr := ":4543" + log.Printf("zplgfa web UI listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +const indexHTML = ` + +
+ + +Convert images to ZPL Graphic Fields for Zebra label printers
+ +