Files
ZPLify/cmd/web/main.go

572 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; }
.grid3 { display: grid; grid-template-columns: 1fr 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; }
/* unit toggle */
.unit-row {
display: flex; align-items: center; gap: 0.5rem;
margin-bottom: 0.75rem;
}
.unit-row span { font-size: 0.8rem; color: #a0aec0; font-weight: 600; }
.toggle-group { display: flex; border: 1px solid #3a4060; border-radius: 6px; overflow: hidden; }
.toggle-group button {
flex: 1; background: #252a3d; color: #a0aec0;
border: none; border-radius: 0; padding: 0.3rem 0.8rem;
font-size: 0.8rem; font-weight: 600; cursor: pointer; width: auto;
transition: background .15s, color .15s;
}
.toggle-group button.active { background: #6366f1; color: #fff; }
.sub-label { font-size: 0.7rem; color: #718096; font-weight: 400; margin-top: 0.15rem; }
.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>
<!-- Unit toggle + DPI -->
<div class="unit-row">
<span>Dimensions in</span>
<div class="toggle-group">
<button id="btnPx" class="active" onclick="setUnit('px')">px</button>
<button id="btnCm" onclick="setUnit('cm')">cm</button>
</div>
<span style="margin-left:auto">Printer DPI</span>
<select id="dpi" style="width:110px">
<option value="203" selected>203 dpi</option>
<option value="300">300 dpi</option>
<option value="600">600 dpi</option>
</select>
</div>
<div class="grid">
<div>
<label id="labelW">Width (px)</label>
<input type="number" id="width" min="0" step="1" value="0" placeholder="0">
<div class="sub-label" id="subW"></div>
</div>
<div>
<label id="labelH">Height (px)</label>
<input type="number" id="height" min="0" step="1" value="0" placeholder="0">
<div class="sub-label" id="subH"></div>
</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;
let currentUnit = 'px'; // 'px' or 'cm'
let origPxW = 0, origPxH = 0; // image native pixel dimensions
function getDPI() { return parseInt(document.getElementById('dpi').value, 10); }
// cm ↔ px helpers
function pxToCm(px) { return +(px * 2.54 / getDPI()).toFixed(3); }
function cmToPx(cm) { return Math.round(cm * getDPI() / 2.54); }
function setUnit(unit) {
const wEl = document.getElementById('width');
const hEl = document.getElementById('height');
const curW = parseFloat(wEl.value) || 0;
const curH = parseFloat(hEl.value) || 0;
if (unit === 'cm' && currentUnit === 'px') {
wEl.step = '0.01';
hEl.step = '0.01';
wEl.value = curW > 0 ? pxToCm(curW) : 0;
hEl.value = curH > 0 ? pxToCm(curH) : 0;
} else if (unit === 'px' && currentUnit === 'cm') {
wEl.step = '1';
hEl.step = '1';
wEl.value = curW > 0 ? cmToPx(curW) : 0;
hEl.value = curH > 0 ? cmToPx(curH) : 0;
}
currentUnit = unit;
document.getElementById('btnPx').classList.toggle('active', unit === 'px');
document.getElementById('btnCm').classList.toggle('active', unit === 'cm');
updateLabels();
}
function updateLabels() {
const unit = currentUnit;
document.getElementById('labelW').textContent = 'Width (' + unit + ')' + (unit === 'px' ? ' — 0 = auto' : '');
document.getElementById('labelH').textContent = 'Height (' + unit + ')' + (unit === 'px' ? ' — 0 = auto' : '');
updateSubLabels();
}
function updateSubLabels() {
if (!origPxW) return;
if (currentUnit === 'px') {
document.getElementById('subW').textContent = pxToCm(origPxW) + ' cm at ' + getDPI() + ' dpi';
document.getElementById('subH').textContent = pxToCm(origPxH) + ' cm at ' + getDPI() + ' dpi';
} else {
document.getElementById('subW').textContent = origPxW + ' px native';
document.getElementById('subH').textContent = origPxH + ' px native';
}
}
// Recalc sub-labels when DPI changes
document.getElementById('dpi').addEventListener('change', () => {
if (currentUnit === 'cm') {
// re-fill cm fields based on original px
if (origPxW) {
document.getElementById('width').value = pxToCm(origPxW);
document.getElementById('height').value = pxToCm(origPxH);
}
}
updateSubLabels();
});
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 => {
origPxW = info.width;
origPxH = info.height;
const wEl = document.getElementById('width');
const hEl = document.getElementById('height');
if (currentUnit === 'px') {
wEl.value = origPxW;
hEl.value = origPxH;
} else {
wEl.value = pxToCm(origPxW);
hEl.value = pxToCm(origPxH);
}
const cmW = pxToCm(origPxW), cmH = pxToCm(origPxH);
previewInfo.textContent =
file.name + ' — ' + origPxW + ' × ' + origPxH + ' px' +
' (' + cmW + ' × ' + cmH + ' cm at ' + getDPI() + ' dpi)';
updateSubLabels();
convertBtn.disabled = false;
})
.catch(() => {
previewInfo.textContent = file.name;
convertBtn.disabled = false;
});
}
// Returns pixel values regardless of current unit
function getPixelDimensions() {
const w = parseFloat(document.getElementById('width').value) || 0;
const h = parseFloat(document.getElementById('height').value) || 0;
if (currentUnit === 'cm') {
return { w: cmToPx(w), h: cmToPx(h) };
}
return { w: Math.round(w), h: Math.round(h) };
}
convertBtn.addEventListener('click', async () => {
if (!selectedFile) return;
errorEl.style.display = 'none';
convertBtn.style.display = 'none';
spinner.style.display = 'block';
output.style.display = 'none';
const { w, h } = getPixelDimensions();
const edits = [...document.querySelectorAll('input[name=edit]:checked')].map(c => c.value);
const fd = new FormData();
fd.append('file', selectedFile);
fd.append('width', w);
fd.append('height', h);
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();
});
// init labels
updateLabels();
</script>
</body>
</html>
`