Compare commits

...

10 Commits

Author SHA1 Message Date
05e60cdfe3 feat: add web UI and Docker setup for ZPLify
- HTTP server wrapping zplgfa library (port 4543)
- File upload with drag-and-drop, width/height resize, encoding selection, image edits
- /convert endpoint returns ZPL output; /preview returns image dimensions
- Dockerfile (multi-stage, Alpine) and docker-compose.yml
2026-03-26 09:52:13 +01:00
Copilot
b89d47a378 Refine ZPL repeat code generation for CompressedASCII (#10) 2026-02-22 00:36:45 +02:00
Simon Waldherr
98464cac0a Add DOI to README.md 2025-04-27 15:41:35 +02:00
Simon Waldherr
57eda7da14 refactor zplgfa.go 2024-08-17 12:25:27 +02:00
Simon Waldherr
755efa31c4 refactor and gh-actions 2024-06-16 15:35:26 +02:00
Simon Waldherr
c0d018ffa9 Merge pull request #6 from pic4xiu/master
Program crashes when processing certain maliciously crafted images
2023-06-17 22:20:03 +02:00
pic4xiu
0c8c6c6ddc Update zplgfa.go 2023-06-17 14:37:13 +08:00
Simon Waldherr
8e9af9e508 Update README.md 2023-04-18 09:57:17 +02:00
Simon Waldherr
5917c8fb9d Merge pull request #5
Update README.md
2022-08-17 13:20:52 +02:00
simonwaldherrZITEC
ab2a54235c Update README.md 2022-08-17 13:19:45 +02:00
12 changed files with 849 additions and 303 deletions

28
.github/workflows/go.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'
- name: Build
run: go build -v ./cmd/zplgfa
- name: Test
run: go test -v ./...

24
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -1,13 +1,16 @@
*since I am currently working exclusively in the home office and no longer have to do with labels professionally (only as a hobby), I can unfortunately no longer work on this project.*
*But if someone would like to provide me a [@Zebra](https://github.com/Zebra) printer, I would be happy to develop it further.*
*Of course, pull requests are still welcome.*
# ZPLGFA Golang Package
*convert pictures to ZPL compatible ^GF-elements*
[![DOI](https://zenodo.org/badge/153820885.svg)](https://doi.org/10.5281/zenodo.15291211)
[![GoDoc](https://godoc.org/github.com/SimonWaldherr/zplgfa?status.svg)](https://godoc.org/github.com/SimonWaldherr/zplgfa)
[![Build Status](https://travis-ci.org/SimonWaldherr/zplgfa.svg?branch=master)](https://travis-ci.org/SimonWaldherr/zplgfa)
[![Coverage Status](https://coveralls.io/repos/github/SimonWaldherr/zplgfa/badge.svg?branch=master)](https://coveralls.io/github/SimonWaldherr/zplgfa?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/SimonWaldherr/zplgfa)](https://goreportcard.com/report/github.com/SimonWaldherr/zplgfa)
[![codebeat badge](https://codebeat.co/badges/28d795af-6f9b-453a-94c2-4fafb8b5b0d5)](https://codebeat.co/projects/github-com-simonwaldherr-zplgfa-master)
[![BCH compliance](https://bettercodehub.com/edge/badge/SimonWaldherr/zplgfa?branch=master)](https://bettercodehub.com/results/SimonWaldherr/zplgfa)
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FSimonWaldherr%2Fzplgfa.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FSimonWaldherr%2Fzplgfa?ref=badge_shield)
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/SimonWaldherr/zplgfa/master/LICENSE)
@@ -83,9 +86,6 @@ If you have dozens of label printers in use and need to fill and print label tem
[![SimonWaldherr/ups - GitHub](https://gh-card.dev/repos/SimonWaldherr/ups.svg?fullname)](https://github.com/SimonWaldherr/ups)
## Is it any good?
[Yes](https://news.ycombinator.com/item?id=3067434)
## License

452
cmd/web/main.go Normal file
View File

@@ -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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>zplgfa Image to ZPL Converter</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #0f1117;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.card {
background: #1e2130;
border: 1px solid #2d3348;
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 640px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.25rem; }
.subtitle { color: #718096; font-size: 0.875rem; margin-bottom: 1.75rem; }
.drop-zone {
border: 2px dashed #3a4060;
border-radius: 8px;
padding: 2.5rem 1rem;
text-align: center;
cursor: pointer;
transition: border-color .2s, background .2s;
margin-bottom: 1.5rem;
}
.drop-zone:hover, .drop-zone.over { border-color: #6366f1; background: #1a1e2e; }
.drop-zone input { display: none; }
.drop-zone .icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
.drop-zone .hint { color: #718096; font-size: 0.85rem; }
.preview-info { margin-top: 0.75rem; font-size: 0.8rem; color: #a0aec0; min-height: 1.2em; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
label { display: block; font-size: 0.8rem; font-weight: 600; color: #a0aec0; margin-bottom: 0.35rem; }
input[type=number], select {
width: 100%;
background: #252a3d;
border: 1px solid #3a4060;
border-radius: 6px;
color: #e2e8f0;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
outline: none;
transition: border-color .2s;
}
input[type=number]:focus, select:focus { border-color: #6366f1; }
.checks { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem; }
.checks label {
display: flex; align-items: center; gap: 0.4rem;
background: #252a3d; border: 1px solid #3a4060;
border-radius: 6px; padding: 0.35rem 0.7rem;
cursor: pointer; font-size: 0.8rem; font-weight: 500; color: #cbd5e0;
margin-bottom: 0;
transition: border-color .2s, background .2s;
}
.checks label:hover { border-color: #6366f1; }
.checks input[type=checkbox] { accent-color: #6366f1; width: 14px; height: 14px; }
button {
width: 100%;
background: #6366f1;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background .2s, opacity .2s;
}
button:hover { background: #4f52d0; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.output {
margin-top: 1.5rem;
display: none;
}
.output-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 0.5rem;
}
.output-header span { font-size: 0.8rem; font-weight: 600; color: #a0aec0; }
.copy-btn {
background: #252a3d; border: 1px solid #3a4060; border-radius: 6px;
color: #a0aec0; font-size: 0.75rem; padding: 0.25rem 0.6rem;
width: auto; font-weight: 500;
}
.copy-btn:hover { background: #2d3348; }
textarea {
width: 100%; height: 220px;
background: #0f1117; border: 1px solid #2d3348; border-radius: 6px;
color: #7dd3fc; font-family: monospace; font-size: 0.75rem;
padding: 0.75rem; resize: vertical; outline: none;
}
.dl-btn {
margin-top: 0.75rem;
background: #2d6a4f;
}
.dl-btn:hover { background: #215040; }
.error { color: #fc8181; font-size: 0.85rem; margin-top: 0.75rem; display: none; }
.spinner {
display: none; width: 18px; height: 18px;
border: 2px solid #ffffff40; border-top-color: #fff;
border-radius: 50%; animation: spin .6s linear infinite;
margin: 0 auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="card">
<h1>zplgfa</h1>
<p class="subtitle">Convert images to ZPL Graphic Fields for Zebra label printers</p>
<div class="drop-zone" id="dropZone">
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif">
<div class="icon">🖼️</div>
<div>Drop an image here or <strong>click to browse</strong></div>
<div class="hint">PNG, JPEG, GIF — max 20 MB</div>
<div class="preview-info" id="previewInfo"></div>
</div>
<div class="grid">
<div>
<label for="width">Width (px) <span style="font-weight:400;color:#718096">0 = auto</span></label>
<input type="number" id="width" min="0" value="0" placeholder="0">
</div>
<div>
<label for="height">Height (px) <span style="font-weight:400;color:#718096">0 = auto</span></label>
<input type="number" id="height" min="0" value="0" placeholder="0">
</div>
</div>
<div class="grid" style="margin-bottom:1rem">
<div>
<label for="enctype">Encoding</label>
<select id="enctype">
<option value="CompressedASCII" selected>CompressedASCII (default)</option>
<option value="ASCII">ASCII</option>
<option value="Binary">Binary</option>
</select>
</div>
</div>
<div class="checks">
<label><input type="checkbox" name="edit" value="monochrome"> Monochrome</label>
<label><input type="checkbox" name="edit" value="invert"> Invert</label>
<label><input type="checkbox" name="edit" value="blur"> Blur</label>
<label><input type="checkbox" name="edit" value="edge"> Edge detect</label>
<label><input type="checkbox" name="edit" value="segment"> Segment</label>
</div>
<button id="convertBtn" disabled>Convert to ZPL</button>
<div class="spinner" id="spinner"></div>
<div class="error" id="error"></div>
<div class="output" id="output">
<div class="output-header">
<span>ZPL Output</span>
<button class="copy-btn" id="copyBtn">Copy</button>
</div>
<textarea id="zplOutput" readonly></textarea>
<button class="dl-btn" id="dlBtn">⬇ Download .zpl</button>
</div>
</div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const previewInfo = document.getElementById('previewInfo');
const convertBtn = document.getElementById('convertBtn');
const spinner = document.getElementById('spinner');
const output = document.getElementById('output');
const zplOutput = document.getElementById('zplOutput');
const errorEl = document.getElementById('error');
let selectedFile = null;
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('over');
if (e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => { if (fileInput.files[0]) setFile(fileInput.files[0]); });
function setFile(file) {
selectedFile = file;
previewInfo.textContent = 'Loading…';
convertBtn.disabled = true;
const fd = new FormData();
fd.append('file', file);
fetch('/preview', { method: 'POST', body: fd })
.then(r => r.ok ? r.json() : Promise.reject())
.then(info => {
previewInfo.textContent = file.name + ' — ' + info.width + ' × ' + info.height + ' px';
// Pre-fill dimensions
document.getElementById('width').value = info.width;
document.getElementById('height').value = info.height;
convertBtn.disabled = false;
})
.catch(() => {
previewInfo.textContent = file.name;
convertBtn.disabled = false;
});
}
convertBtn.addEventListener('click', async () => {
if (!selectedFile) return;
errorEl.style.display = 'none';
convertBtn.style.display = 'none';
spinner.style.display = 'block';
output.style.display = 'none';
const edits = [...document.querySelectorAll('input[name=edit]:checked')].map(c => c.value);
const fd = new FormData();
fd.append('file', selectedFile);
fd.append('width', document.getElementById('width').value);
fd.append('height', document.getElementById('height').value);
fd.append('type', document.getElementById('enctype').value);
fd.append('edit', edits.join(','));
try {
const res = await fetch('/convert', { method: 'POST', body: fd });
if (!res.ok) {
const msg = await res.text();
throw new Error(msg);
}
const text = await res.text();
zplOutput.value = text;
output.style.display = 'block';
} catch (e) {
errorEl.textContent = 'Error: ' + e.message;
errorEl.style.display = 'block';
} finally {
spinner.style.display = 'none';
convertBtn.style.display = 'block';
}
});
document.getElementById('copyBtn').addEventListener('click', () => {
navigator.clipboard.writeText(zplOutput.value);
document.getElementById('copyBtn').textContent = 'Copied!';
setTimeout(() => document.getElementById('copyBtn').textContent = 'Copy', 1500);
});
document.getElementById('dlBtn').addEventListener('click', () => {
const blob = new Blob([zplOutput.value], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (selectedFile ? selectedFile.name.replace(/\.[^.]+$/, '') : 'label') + '.zpl';
a.click();
});
</script>
</body>
</html>
`

View File

@@ -19,154 +19,146 @@ import (
"simonwaldherr.de/go/zplgfa"
)
func specialCmds(zebraCmdFlag, networkIpFlag, networkPortFlag string) bool {
var cmdSent bool
if networkIpFlag == "" {
return cmdSent
func handleZebraCommands(cmd, ip, port string) bool {
if ip == "" {
return false
}
if strings.Contains(zebraCmdFlag, "cancel") {
if err := sendCancelCmdToZebra(networkIpFlag, networkPortFlag); err == nil {
cmdSent = true
// Define a map of command strings to functions that return error only
cmdActionsErr := map[string]func(string, string) error{
"cancel": sendCancelCmdToZebra,
"calib": sendCalibCmdToZebra,
"feed": sendFeedCmdToZebra,
}
// Define a map of command strings to functions that return (string, error)
cmdActionsStr := map[string]func(string, string) (string, error){
"info": getInfoFromZebra,
"config": getConfigFromZebra,
"diag": getDiagFromZebra,
}
for key, action := range cmdActionsErr {
if strings.Contains(cmd, key) {
if err := action(ip, port); err == nil {
return true
}
}
}
if strings.Contains(zebraCmdFlag, "calib") {
if err := sendCalibCmdToZebra(networkIpFlag, networkPortFlag); err == nil {
cmdSent = true
for key, action := range cmdActionsStr {
if strings.Contains(cmd, key) {
result, err := action(ip, port)
if err == nil {
fmt.Println(result)
return true
}
}
}
if strings.Contains(zebraCmdFlag, "feed") {
if err := sendFeedCmdToZebra(networkIpFlag, networkPortFlag); err == nil {
cmdSent = true
}
return false
}
func parseFlags() (string, string, string, string, string, string, float64) {
var filename, zebraCmd, graphicType, imageEdit, ip, port string
var resizeFactor float64
flag.StringVar(&filename, "file", "", "filename to convert to zpl")
flag.StringVar(&zebraCmd, "cmd", "", "send special command to printer [cancel,calib,feed,info,config,diag]")
flag.StringVar(&graphicType, "type", "CompressedASCII", "type of graphic field encoding")
flag.StringVar(&imageEdit, "edit", "", "manipulate the image [invert,monochrome]")
flag.StringVar(&ip, "ip", "", "send zpl to printer")
flag.StringVar(&port, "port", "9100", "network port of printer")
flag.Float64Var(&resizeFactor, "resize", 1.0, "zoom/resize the image")
flag.Parse()
return filename, zebraCmd, graphicType, imageEdit, ip, port, resizeFactor
}
func openImageFile(filename string) (image.Image, image.Config, error) {
file, err := os.Open(filename)
if err != nil {
return nil, image.Config{}, fmt.Errorf("could not open the file \"%s\": %s", filename, err)
}
if strings.Contains(zebraCmdFlag, "info") {
info, err := getInfoFromZebra(networkIpFlag, networkPortFlag)
if err == nil {
fmt.Println(info)
cmdSent = true
}
defer file.Close()
config, format, err := image.DecodeConfig(file)
if err != nil {
return nil, config, fmt.Errorf("image not compatible, format: %s, config: %v, error: %s", format, config, err)
}
if strings.Contains(zebraCmdFlag, "config") {
info, err := getConfigFromZebra(networkIpFlag, networkPortFlag)
if err == nil {
fmt.Println(info)
cmdSent = true
}
file.Seek(0, 0)
img, _, err := image.Decode(file)
if err != nil {
return nil, config, fmt.Errorf("could not decode the file, %s", err)
}
if strings.Contains(zebraCmdFlag, "diag") {
info, err := getDiagFromZebra(networkIpFlag, networkPortFlag)
if err == nil {
fmt.Println(info)
cmdSent = true
}
return img, config, nil
}
func processImage(img image.Image, editFlag string, resizeFactor float64, config image.Config) image.Image {
if strings.Contains(editFlag, "monochrome") {
img = editImageMonochrome(img)
}
if strings.Contains(editFlag, "blur") {
img = blur.Gaussian(img, float64(config.Width)/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 = editImageInvert(img)
}
if resizeFactor != 1.0 {
img = resize.Resize(uint(float64(config.Width)*resizeFactor), uint(float64(config.Height)*resizeFactor), img, resize.MitchellNetravali)
}
return img
}
func getGraphicType(typeFlag string) zplgfa.GraphicType {
switch strings.ToUpper(typeFlag) {
case "ASCII":
return zplgfa.ASCII
case "BINARY":
return zplgfa.Binary
case "COMPRESSEDASCII":
return zplgfa.CompressedASCII
default:
return zplgfa.CompressedASCII
}
return cmdSent
}
func main() {
var filenameFlag string
var zebraCmdFlag string
var graphicTypeFlag string
var imageEditFlag string
var networkIpFlag string
var networkPortFlag string
var imageResizeFlag float64
var graphicType zplgfa.GraphicType
filename, zebraCmd, graphicTypeFlag, imageEdit, ip, port, resizeFactor := parseFlags()
flag.StringVar(&filenameFlag, "file", "", "filename to convert to zpl")
flag.StringVar(&zebraCmdFlag, "cmd", "", "send special command to printer [cancel,calib,feed,info,config,diag]")
flag.StringVar(&graphicTypeFlag, "type", "CompressedASCII", "type of graphic field encoding")
flag.StringVar(&imageEditFlag, "edit", "", "manipulate the image [invert,monochrome]")
flag.StringVar(&networkIpFlag, "ip", "", "send zpl to printer")
flag.StringVar(&networkPortFlag, "port", "9100", "network port of printer")
flag.Float64Var(&imageResizeFlag, "resize", 1.0, "zoom/resize the image")
if handleZebraCommands(zebraCmd, ip, port) && filename == "" {
return
}
// load flag input arguments
flag.Parse()
// send special commands to printer
cmdSent := specialCmds(zebraCmdFlag, networkIpFlag, networkPortFlag)
// check input parameter
if filenameFlag == "" {
if cmdSent {
return
}
if filename == "" {
log.Printf("Warning: no input file specified\n")
return
}
// open file
file, err := os.Open(filenameFlag)
img, config, err := openImageFile(filename)
if err != nil {
log.Printf("Warning: could not open the file \"%s\": %s\n", filenameFlag, err)
log.Printf("Warning: %s\n", err)
return
}
// close file when complete
defer file.Close()
img = processImage(img, imageEdit, resizeFactor, config)
// load image head information
config, format, err := image.DecodeConfig(file)
if err != nil {
log.Printf("Warning: image not compatible, format: %s, config: %v, error: %s\n", format, config, err)
}
// reset file pointer to the beginning of the file
file.Seek(0, 0)
// load and decode image
img, _, err := image.Decode(file)
if err != nil {
log.Printf("Warning: could not decode the file, %s\n", err)
return
}
// select graphic field type
switch strings.ToUpper(graphicTypeFlag) {
case "ASCII":
graphicType = zplgfa.ASCII
case "BINARY":
graphicType = zplgfa.Binary
case "COMPRESSEDASCII":
graphicType = zplgfa.CompressedASCII
default:
graphicType = zplgfa.CompressedASCII
}
// apply image manipulation functions
if strings.Contains(imageEditFlag, "monochrome") {
img = editImageMonochrome(img)
}
if strings.Contains(imageEditFlag, "blur") {
img = blur.Gaussian(img, float64(config.Width)/300)
}
if strings.Contains(imageEditFlag, "edge") {
img = effect.Sobel(img)
}
if strings.Contains(imageEditFlag, "segment") {
img = segment.Threshold(img, 128)
}
if strings.Contains(imageEditFlag, "invert") {
img = editImageInvert(img)
}
// resize image
if imageResizeFlag != 1.0 {
img = resize.Resize(uint(float64(config.Width)*imageResizeFlag), uint(float64(config.Height)*imageResizeFlag), img, resize.MitchellNetravali)
}
// flatten image
flat := zplgfa.FlattenImage(img)
gfimg := zplgfa.ConvertToZPL(flat, getGraphicType(graphicTypeFlag))
// convert image to zpl compatible type
gfimg := zplgfa.ConvertToZPL(flat, graphicType)
if networkIpFlag != "" {
// send zpl to printer
sendDataToZebra(networkIpFlag, networkPortFlag, gfimg)
if ip != "" {
sendDataToZebra(ip, port, gfimg)
} else {
// output zpl with graphic field data to stdout
fmt.Println(gfimg)
}
}

6
docker-compose.yml Normal file
View File

@@ -0,0 +1,6 @@
services:
zplgfa:
build: .
ports:
- "4543:4543"
restart: unless-stopped

View File

@@ -52,5 +52,5 @@ func ExampleConvertToZPL() {
fmt.Println(zplstr)
// Output: ^XA,^FS^FO0,0^GFA,52,51,3,FFFF00::FE3F00::FFFF00FFE300::FFFF00E22300::FFFF00::^FS,^XZ
// Output: ^XA,^FS^FO0,0^GFA,52,51,3,FFFF80::FE3F80::FFFF80FFE380::FFFF80E22380::FFFF80::^FS,^XZ
}

8
go.mod Normal file
View File

@@ -0,0 +1,8 @@
module simonwaldherr.de/go/zplgfa
go 1.22
require (
github.com/anthonynsimon/bild v0.13.0
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
)

35
go.sum Normal file
View File

@@ -0,0 +1,35 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8=
github.com/anthonynsimon/bild v0.13.0/go.mod h1:tpzzp0aYkAsMi1zmfhimaDyX1xjn2OUc1AJZK/TF0AE=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

File diff suppressed because one or more lines are too long

204
zplgfa.go
View File

@@ -21,179 +21,173 @@ const (
CompressedASCII
)
// ConvertToZPL is just a wrapper for ConvertToGraphicField which also includes the ZPL
// starting code ^XA and ending code ^XZ, as well as a Field Separator and Field Origin.
// ConvertToZPL wraps ConvertToGraphicField, adding ZPL start and end codes.
func ConvertToZPL(img image.Image, graphicType GraphicType) string {
if img.Bounds().Size().X/8 == 0 {
return ""
}
return fmt.Sprintf("^XA,^FS\n^FO0,0\n%s^FS,^XZ\n", ConvertToGraphicField(img, graphicType))
}
// FlattenImage optimizes an image for the converting process
// FlattenImage optimizes an image for the converting process.
func FlattenImage(source image.Image) *image.NRGBA {
size := source.Bounds().Size()
background := color.White
target := image.NewNRGBA(source.Bounds())
background := color.White
for y := 0; y < size.Y; y++ {
for x := 0; x < size.X; x++ {
p := source.At(x, y)
flat := flatten(p, background)
target.Set(x, y, flat)
target.Set(x, y, flatten(p, background))
}
}
return target
}
func flatten(input color.Color, background color.Color) color.Color {
source := color.NRGBA64Model.Convert(input).(color.NRGBA64)
r, g, b, a := source.RGBA()
// flatten blends a pixel with the background color based on its alpha value.
func flatten(input, background color.Color) color.Color {
src := color.NRGBA64Model.Convert(input).(color.NRGBA64)
r, g, b, a := src.RGBA()
bgR, bgG, bgB, _ := background.RGBA()
alpha := float32(a) / 0xffff
conv := func(c uint32, bg uint32) uint8 {
val := 0xffff - uint32((float32(bg) * alpha))
val = val | uint32(float32(c)*alpha)
blend := func(c, bg uint32) uint8 {
val := 0xffff - uint32(float32(bg)*alpha)
val |= uint32(float32(c) * alpha)
return uint8(val >> 8)
}
c := color.NRGBA{
conv(r, bgR),
conv(g, bgG),
conv(b, bgB),
uint8(0xff),
return color.NRGBA{
R: blend(r, bgR),
G: blend(g, bgG),
B: blend(b, bgB),
A: 0xff,
}
return c
}
// getRepeatCode generates ZPL repeat codes for character compression.
func getRepeatCode(repeatCount int, char string) string {
repeatStr := ""
if repeatCount > 419 {
repeatCount -= 419
repeatStr += getRepeatCode(repeatCount, char)
repeatCount = 419
}
high := repeatCount / 20
low := repeatCount % 20
lowString := " GHIJKLMNOPQRSTUVWXY"
const maxRepeat = 419
highString := " ghijklmnopqrstuvwxyz"
lowString := " GHIJKLMNOPQRSTUVWXY"
if high > 0 {
repeatStr += string(highString[high])
}
if low > 0 {
repeatStr += string(lowString[low])
encode := func(count int) string {
var singleRepeatStr strings.Builder
high := count / 20
low := count % 20
if high > 0 {
singleRepeatStr.WriteByte(highString[high])
}
if low > 0 {
singleRepeatStr.WriteByte(lowString[low])
}
singleRepeatStr.WriteString(char)
return singleRepeatStr.String()
}
repeatStr += char
if repeatCount > maxRepeat {
var repeatStr strings.Builder
remainder := repeatCount % maxRepeat
quotient := repeatCount / maxRepeat
return repeatStr
if remainder > 0 {
repeatStr.WriteString(encode(remainder))
}
maxEncoding := encode(maxRepeat)
for i := 0; i < quotient; i++ {
repeatStr.WriteString(maxEncoding)
}
return repeatStr.String()
}
return encode(repeatCount)
}
// CompressASCII compresses the ASCII data of a ZPL Graphic Field using RLE
func CompressASCII(in string) string {
var curChar string
// CompressASCII compresses the ASCII data of a ZPL Graphic Field using RLE.
func CompressASCII(input string) string {
if input == "" {
return ""
}
var output strings.Builder
var lastChar string
var lastCharSince int
var output string
var repCode string
for i := 0; i < len(in)+1; i++ {
if i == len(in) {
curChar = ""
if lastCharSince == 0 {
switch lastChar {
case "0":
output = ","
return output
case "F":
output = "!"
return output
}
}
} else {
curChar = string(in[i])
for i := 0; i <= len(input); i++ {
curChar := ""
if i < len(input) {
curChar = string(input[i])
}
if lastChar != curChar {
if i-lastCharSince > 4 {
repCode = getRepeatCode(i-lastCharSince, lastChar)
output += repCode
output.WriteString(getRepeatCode(i-lastCharSince, lastChar))
} else {
for j := 0; j < i-lastCharSince; j++ {
output += lastChar
}
output.WriteString(strings.Repeat(lastChar, i-lastCharSince))
}
lastChar = curChar
lastCharSince = i
}
if curChar == "" && lastCharSince == 0 {
switch lastChar {
case "0":
return ","
case "F":
return "!"
}
}
}
if output == "" {
output += getRepeatCode(len(in), lastChar)
}
return output
return output.String()
}
// ConvertToGraphicField converts an image.Image picture to a ZPL compatible Graphic Field.
// The ZPL ^GF (Graphic Field) supports various data formats, this package supports the
// normal ASCII encoded, as well as a RLE compressed ASCII format. It also supports the
// Binary Graphic Field format. The encoding can be chosen by the second argument.
// ConvertToGraphicField converts an image.Image to a ZPL compatible Graphic Field.
func ConvertToGraphicField(source image.Image, graphicType GraphicType) string {
var gfType string
var lastLine string
var gfType, graphicFieldData string
size := source.Bounds().Size()
width := size.X / 8
width := (size.X + 7) / 8 // round up division
height := size.Y
if size.Y%8 != 0 {
width = width + 1
}
var lastLine string
var GraphicFieldData string
for y := 0; y < size.Y; y++ {
for y := 0; y < height; y++ {
line := make([]uint8, width)
lineIndex := 0
index := uint8(0)
currentByte := line[lineIndex]
for x := 0; x < size.X; x++ {
index = index + 1
p := source.At(x, y)
lum := color.Gray16Model.Convert(p).(color.Gray16)
if lum.Y < math.MaxUint16/2 {
currentByte = currentByte | (1 << (8 - index))
if x%8 == 0 {
line[x/8] = 0
}
if index >= 8 {
line[lineIndex] = currentByte
lineIndex++
if lineIndex < len(line) {
currentByte = line[lineIndex]
}
index = 0
if lum := color.Gray16Model.Convert(source.At(x, y)).(color.Gray16).Y; lum < math.MaxUint16/2 {
line[x/8] |= 1 << (7 - uint(x)%8)
}
}
hexstr := strings.ToUpper(hex.EncodeToString(line))
hexStr := strings.ToUpper(hex.EncodeToString(line))
switch graphicType {
case ASCII:
GraphicFieldData += fmt.Sprintln(hexstr)
graphicFieldData += fmt.Sprintln(hexStr)
case CompressedASCII:
curLine := CompressASCII(hexstr)
curLine := CompressASCII(hexStr)
if lastLine == curLine {
GraphicFieldData += ":"
graphicFieldData += ":"
} else {
GraphicFieldData += curLine
graphicFieldData += curLine
}
lastLine = curLine
case Binary:
GraphicFieldData += fmt.Sprintf("%s", line)
graphicFieldData += string(line)
}
}
if graphicType == ASCII || graphicType == CompressedASCII {
switch graphicType {
case ASCII, CompressedASCII:
gfType = "A"
} else if graphicType == Binary {
case Binary:
gfType = "B"
}
return fmt.Sprintf("^GF%s,%d,%d,%d,\n%s", gfType, len(GraphicFieldData), width*height, width, GraphicFieldData)
return fmt.Sprintf("^GF%s,%d,%d,%d,\n%s", gfType, len(graphicFieldData), width*height, width, graphicFieldData)
}

View File

@@ -23,73 +23,80 @@ type zplTest struct {
var zplTests []zplTest
func init() {
jsonstr, _ := ioutil.ReadFile("./tests/tests.json")
json.Unmarshal(jsonstr, &zplTests)
jsonstr, err := ioutil.ReadFile("./tests/tests.json")
if err != nil {
log.Fatalf("Failed to read test cases: %s", err)
}
if err := json.Unmarshal(jsonstr, &zplTests); err != nil {
log.Fatalf("Failed to unmarshal test cases: %s", err)
}
}
func Test_CompressASCII(t *testing.T) {
if str := CompressASCII("FFFFFFFF000000"); str != "NFL0" {
t.Fatalf("CompressASCII failed")
t.Fatalf("CompressASCII failed: got %s, want NFL0", str)
}
}
func Test_ConvertToZPL(t *testing.T) {
var graphicType GraphicType
for i, testcase := range zplTests {
filename, zplstring, graphictype := testcase.Filename, testcase.Zplstring, testcase.Graphictype
// open file
file, err := os.Open(filename)
if err != nil {
log.Printf("Warning: could not open the file \"%s\": %s\n", filename, err)
return
}
defer file.Close()
// load image head information
config, format, err := image.DecodeConfig(file)
if err != nil {
log.Printf("Warning: image not compatible, format: %s, config: %v, error: %s\n", format, config, err)
}
// reset file pointer to the beginning of the file
file.Seek(0, 0)
// load and decode image
img, _, err := image.Decode(file)
if err != nil {
log.Printf("Warning: could not decode the file, %s\n", err)
return
}
// flatten image
flat := FlattenImage(img)
// convert image to zpl compatible type
switch graphictype {
case "ASCII":
graphicType = ASCII
case "Binary":
graphicType = Binary
case "CompressedASCII":
graphicType = CompressedASCII
default:
graphicType = CompressedASCII
}
gfimg := ConvertToZPL(flat, graphicType)
if graphictype == "Binary" {
gfimg = base64.StdEncoding.EncodeToString([]byte(gfimg))
} else {
// remove whitespace - only for the test
gfimg = strings.Replace(gfimg, " ", "", -1)
gfimg = strings.Replace(gfimg, "\n", "", -1)
}
if gfimg != zplstring {
log.Printf("ConvertToZPL Test for file \"%s\" failed, wanted: \n%s\ngot: \n%s\n", filename, zplstring, gfimg)
t.Fatalf("Testcase %d ConvertToZPL failed", i)
}
t.Run(testcase.Filename, func(t *testing.T) {
testConvertToZPL(t, testcase, i)
})
}
}
func testConvertToZPL(t *testing.T, testcase zplTest, index int) {
file, err := os.Open(testcase.Filename)
if err != nil {
t.Fatalf("Failed to open file %s: %s", testcase.Filename, err)
}
defer file.Close()
_, _, err = image.DecodeConfig(file)
if err != nil {
t.Fatalf("Failed to decode config for file %s: %s", testcase.Filename, err)
}
if _, err := file.Seek(0, 0); err != nil {
t.Fatalf("Failed to reset file pointer for %s: %s", testcase.Filename, err)
}
img, _, err := image.Decode(file)
if err != nil {
t.Fatalf("Failed to decode image for file %s: %s", testcase.Filename, err)
}
flat := FlattenImage(img)
graphicType := parseGraphicType(testcase.Graphictype)
gfimg := ConvertToZPL(flat, graphicType)
if graphicType == Binary {
gfimg = base64.StdEncoding.EncodeToString([]byte(gfimg))
} else {
gfimg = cleanZPLString(gfimg)
}
if gfimg != testcase.Zplstring {
t.Fatalf("Testcase %d ConvertToZPL failed for file %s: \nExpected: \n%s\nGot: \n%s\n", index, testcase.Filename, testcase.Zplstring, gfimg)
}
}
func parseGraphicType(graphicTypeStr string) GraphicType {
switch graphicTypeStr {
case "ASCII":
return ASCII
case "Binary":
return Binary
case "CompressedASCII":
return CompressedASCII
default:
return CompressedASCII
}
}
func cleanZPLString(zpl string) string {
zpl = strings.ReplaceAll(zpl, " ", "")
zpl = strings.ReplaceAll(zpl, "\n", "")
return zpl
}