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