diff --git a/Dockerfile b/Dockerfile index 884652a..147e595 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ 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 +RUN apk add --no-cache ca-certificates poppler-utils COPY --from=builder /zplgfa-web /usr/local/bin/zplgfa-web diff --git a/cmd/web/main.go b/cmd/web/main.go index e677692..2c5bf6a 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -7,11 +7,14 @@ import ( "image/color" _ "image/gif" _ "image/jpeg" - _ "image/png" + "image/png" "io" "log" "math" "net/http" + "os" + "os/exec" + "path/filepath" "strconv" "strings" "time" @@ -26,6 +29,71 @@ import ( const maxUploadSize = 20 << 20 // 20 MB +func isPDF(data []byte) bool { + return len(data) >= 4 && string(data[:4]) == "%PDF" +} + +// pdfPageToImage renders one page of a PDF to an image using pdftoppm. +// dpi controls the render resolution (use 150 for thumbnails, 300 for conversion). +// Returns the image, total page count, and any error. +func pdfPageToImage(data []byte, page, dpi int) (image.Image, int, error) { + tmpDir, err := os.MkdirTemp("", "zplgfa-*") + if err != nil { + return nil, 0, err + } + defer os.RemoveAll(tmpDir) + + pdfPath := filepath.Join(tmpDir, "input.pdf") + if err := os.WriteFile(pdfPath, data, 0600); err != nil { + return nil, 0, err + } + + // Get total page count via pdfinfo + pageCount := 1 + if out, err := exec.Command("pdfinfo", pdfPath).Output(); err == nil { + for _, line := range strings.Split(string(out), "\n") { + if strings.HasPrefix(line, "Pages:") { + if n, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "Pages:"))); err == nil { + pageCount = n + } + } + } + } + + if page < 1 { + page = 1 + } + if page > pageCount { + page = pageCount + } + + // Convert the requested page to PNG + outBase := filepath.Join(tmpDir, "out") + cmd := exec.Command("pdftoppm", + "-png", + "-r", strconv.Itoa(dpi), + "-f", strconv.Itoa(page), + "-l", strconv.Itoa(page), + pdfPath, outBase) + if out, err := cmd.CombinedOutput(); err != nil { + return nil, pageCount, fmt.Errorf("pdftoppm: %s", string(out)) + } + + matches, err := filepath.Glob(filepath.Join(tmpDir, "out-*.png")) + if err != nil || len(matches) == 0 { + return nil, pageCount, fmt.Errorf("pdftoppm produced no output") + } + + f, err := os.Open(matches[0]) + if err != nil { + return nil, pageCount, err + } + defer f.Close() + + img, _, err := image.Decode(f) + return img, pageCount, err +} + func getGraphicType(t string) zplgfa.GraphicType { switch strings.ToUpper(t) { case "ASCII": @@ -111,12 +179,31 @@ func convertHandler(w http.ResponseWriter, r *http.Request) { } defer file.Close() - img, _, err := image.Decode(file) + data, err := io.ReadAll(file) if err != nil { - http.Error(w, fmt.Sprintf("cannot decode image: %s", err), http.StatusBadRequest) + http.Error(w, "failed to read file", http.StatusBadRequest) return } + var img image.Image + if isPDF(data) { + page := 1 + if p, e2 := strconv.Atoi(r.FormValue("page")); e2 == nil && p > 0 { + page = p + } + img, _, err = pdfPageToImage(data, page, 300) + if err != nil { + http.Error(w, "PDF conversion failed: "+err.Error(), http.StatusBadRequest) + return + } + } else { + img, _, err = image.Decode(bytes.NewReader(data)) + 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) @@ -156,13 +243,72 @@ func previewHandler(w http.ResponseWriter, r *http.Request) { var buf bytes.Buffer buf.ReadFrom(file) - cfg, _, err := image.DecodeConfig(bytes.NewReader(buf.Bytes())) + data := buf.Bytes() + + if isPDF(data) { + page := 1 + if p, e2 := strconv.Atoi(r.FormValue("page")); e2 == nil && p > 0 { + page = p + } + img, pageCount, err := pdfPageToImage(data, page, 150) + if err != nil { + http.Error(w, "PDF conversion failed: "+err.Error(), http.StatusBadRequest) + return + } + b := img.Bounds() + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"width":%d,"height":%d,"pages":%d,"isPDF":true}`, b.Dx(), b.Dy(), pageCount) + return + } + + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) 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) + fmt.Fprintf(w, `{"width":%d,"height":%d,"pages":1,"isPDF":false}`, cfg.Width, cfg.Height) +} + +func pageThumbHandler(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() + + data, err := io.ReadAll(file) + if err != nil { + http.Error(w, "failed to read file", http.StatusBadRequest) + return + } + if !isPDF(data) { + http.Error(w, "not a PDF", http.StatusBadRequest) + return + } + + page := 1 + if p, e2 := strconv.Atoi(r.FormValue("page")); e2 == nil && p > 0 { + page = p + } + + img, _, err := pdfPageToImage(data, page, 150) + if err != nil { + http.Error(w, "PDF conversion failed: "+err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "image/png") + png.Encode(w, img) } var labelaryClient = &http.Client{Timeout: 15 * time.Second} @@ -219,6 +365,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("/convert", convertHandler) mux.HandleFunc("/preview", previewHandler) + mux.HandleFunc("/page-thumb", pageThumbHandler) mux.HandleFunc("/render", renderHandler) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -398,13 +545,22 @@ const indexHTML = `

Convert images to ZPL Graphic Fields for Zebra label printers

- +
🖼️
Drop an image here or click to browse
-
PNG, JPEG, GIF — max 20 MB
+
PNG, JPEG, GIF, PDF — max 20 MB
+ + +
Image Preview
@@ -496,6 +652,8 @@ let selectedFile = null; let currentUnit = 'px'; // 'px' or 'cm' let origPxW = 0, origPxH = 0; // image native pixel dimensions let imgObjectURL = null; +let fileIsPDF = false; +let pdfPageCount = 1; function getDPI() { return parseInt(document.getElementById('dpi').value, 10); } @@ -570,12 +728,16 @@ function setFile(file) { selectedFile = file; previewInfo.textContent = 'Loading…'; convertBtn.disabled = true; + fileIsPDF = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'); - // Show image preview immediately - if (imgObjectURL) URL.revokeObjectURL(imgObjectURL); - imgObjectURL = URL.createObjectURL(file); - document.getElementById('imgPreviewEl').src = imgObjectURL; - document.getElementById('imgPreviewPanel').style.display = 'block'; + // For non-PDFs show the image immediately; for PDFs we wait for the server thumb + if (!fileIsPDF) { + if (imgObjectURL) URL.revokeObjectURL(imgObjectURL); + imgObjectURL = URL.createObjectURL(file); + document.getElementById('imgPreviewEl').src = imgObjectURL; + document.getElementById('imgPreviewPanel').style.display = 'block'; + } + document.getElementById('pdfPageRow').style.display = fileIsPDF ? 'block' : 'none'; const fd = new FormData(); fd.append('file', file); @@ -585,6 +747,14 @@ function setFile(file) { origPxW = info.width; origPxH = info.height; + if (info.isPDF) { + pdfPageCount = info.pages; + document.getElementById('pdfPageMax').textContent = '(1 – ' + info.pages + ')'; + document.getElementById('pdfPage').max = info.pages; + document.getElementById('pdfPage').value = 1; + loadPDFPageThumb(1); + } + const wEl = document.getElementById('width'); const hEl = document.getElementById('height'); if (currentUnit === 'px') { @@ -596,8 +766,9 @@ function setFile(file) { } const cmW = pxToCm(origPxW), cmH = pxToCm(origPxH); + const pageInfo = info.isPDF ? ' — ' + info.pages + ' page' + (info.pages !== 1 ? 's' : '') : ''; previewInfo.textContent = - file.name + ' — ' + origPxW + ' × ' + origPxH + ' px' + + file.name + pageInfo + ' — ' + origPxW + ' × ' + origPxH + ' px' + ' (' + cmW + ' × ' + cmH + ' cm at ' + getDPI() + ' dpi)'; document.getElementById('imgPreviewMeta').textContent = @@ -612,6 +783,33 @@ function setFile(file) { }); } +async function loadPDFPageThumb(page) { + if (!selectedFile) return; + const fd = new FormData(); + fd.append('file', selectedFile); + fd.append('page', page); + document.getElementById('imgPreviewPanel').style.display = 'block'; + document.getElementById('imgPreviewMeta').textContent = 'Loading page ' + page + '…'; + try { + const res = await fetch('/page-thumb', { method: 'POST', body: fd }); + if (!res.ok) throw new Error(await res.text()); + const blob = await res.blob(); + if (imgObjectURL) URL.revokeObjectURL(imgObjectURL); + imgObjectURL = URL.createObjectURL(blob); + document.getElementById('imgPreviewEl').src = imgObjectURL; + const cmW = pxToCm(origPxW), cmH = pxToCm(origPxH); + document.getElementById('imgPreviewMeta').textContent = + 'Page ' + page + ' — ' + origPxW + ' × ' + origPxH + ' px | ' + cmW + ' × ' + cmH + ' cm at ' + getDPI() + ' dpi'; + } catch(e) { + document.getElementById('imgPreviewMeta').textContent = 'Preview failed: ' + e.message; + } +} + +document.getElementById('pdfPageBtn').addEventListener('click', () => { + const page = parseInt(document.getElementById('pdfPage').value, 10) || 1; + loadPDFPageThumb(page); +}); + // Returns pixel values regardless of current unit function getPixelDimensions() { const w = parseFloat(document.getElementById('width').value) || 0; @@ -671,6 +869,9 @@ convertBtn.addEventListener('click', async () => { fd.append('height', h); fd.append('type', document.getElementById('enctype').value); fd.append('edit', edits.join(',')); + if (fileIsPDF) { + fd.append('page', document.getElementById('pdfPage').value || '1'); + } try { const res = await fetch('/convert', { method: 'POST', body: fd });