"use strict"; const fastify = require("fastify"); const { createCanvas } = require("canvas"); const { jsPDF } = require("jspdf"); const { TextDecoder } = require("util"); const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = 3040; const PAGE_PADDING_DOTS = 24; const DEFAULT_RENDER_OPTIONS = { width: 4, height: 6, dpi: 203, sourceDpi: 203, fontWidthScale: 0.82, }; let bwipPromise; function getCliOption(name) { const prefix = `--${name}=`; const option = process.argv.find((argument) => argument.startsWith(prefix)); return option ? option.slice(prefix.length) : undefined; } function getServerConfig() { const port = Number(getCliOption("port") || process.env.PORT || DEFAULT_PORT); return { host: getCliOption("host") || process.env.HOST || DEFAULT_HOST, port: Number.isFinite(port) && port > 0 ? port : DEFAULT_PORT, }; } function normalizeZpl(zpl) { return zpl.replace(/\\n/g, "\n").replace(/\r\n/g, "\n"); } function nextCommandIndex(zpl, start) { const caret = zpl.indexOf("^", start); const tilde = zpl.indexOf("~", start); if (caret === -1) return tilde; if (tilde === -1) return caret; return Math.min(caret, tilde); } function tokenizeZpl(zpl) { const tokens = []; let index = 0; while (index < zpl.length) { const markerIndex = nextCommandIndex(zpl, index); if (markerIndex === -1 || markerIndex + 2 >= zpl.length) break; const marker = zpl[markerIndex]; let code; let paramsStart; if (marker === "^" && zpl[markerIndex + 1] === "A" && zpl[markerIndex + 2] !== "@") { code = "^A"; paramsStart = markerIndex + 2; } else { code = marker + zpl.slice(markerIndex + 1, markerIndex + 3); paramsStart = markerIndex + 3; } const paramsEnd = nextCommandIndex(zpl, paramsStart); const rawParams = zpl.slice(paramsStart, paramsEnd === -1 ? zpl.length : paramsEnd); tokens.push({ code, params: rawParams.replace(/\n/g, "") }); index = paramsEnd === -1 ? zpl.length : paramsEnd; } return tokens; } function splitParams(value) { return String(value || "").split(","); } function parseInteger(value, fallback = 0) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? parsed : fallback; } function parseFont(params, currentFont) { const match = String(params || "").match(/^([A-Z0-9])?([NRIB])?,?(\d+)?,?(\d+)?/i); return { name: match && match[1] ? match[1] : currentFont.name, orientation: match && match[2] ? match[2].toUpperCase() : "N", height: match && match[3] ? parseInteger(match[3], currentFont.height) : currentFont.height, width: match && match[4] ? parseInteger(match[4], currentFont.width) : currentFont.width, }; } function normalizeRenderOptions(options = {}) { const width = Number(options.width ?? DEFAULT_RENDER_OPTIONS.width); const height = Number(options.height ?? DEFAULT_RENDER_OPTIONS.height); const dpi = Number(options.dpi ?? DEFAULT_RENDER_OPTIONS.dpi); const sourceDpi = Number(options.sourceDpi ?? DEFAULT_RENDER_OPTIONS.sourceDpi); const fontWidthScale = Number(options.fontWidthScale ?? DEFAULT_RENDER_OPTIONS.fontWidthScale); return { width: Number.isFinite(width) && width > 0 ? width : DEFAULT_RENDER_OPTIONS.width, height: Number.isFinite(height) && height > 0 ? height : DEFAULT_RENDER_OPTIONS.height, dpi: Number.isFinite(dpi) && dpi > 0 ? dpi : DEFAULT_RENDER_OPTIONS.dpi, sourceDpi: Number.isFinite(sourceDpi) && sourceDpi > 0 ? sourceDpi : DEFAULT_RENDER_OPTIONS.sourceDpi, fontWidthScale: Number.isFinite(fontWidthScale) && fontWidthScale > 0 ? fontWidthScale : DEFAULT_RENDER_OPTIONS.fontWidthScale, }; } function createRenderContext(options = {}) { const normalized = normalizeRenderOptions(options); return { ...normalized, widthDots: Math.round(normalized.width * normalized.dpi), minHeightDots: Math.round(normalized.height * normalized.dpi), sourceWidthDots: Math.round(normalized.width * normalized.sourceDpi), scale: normalized.dpi / normalized.sourceDpi, }; } function decodeFieldHex(value) { const decoder = new TextDecoder("utf-8"); let output = ""; let bytes = []; const flush = () => { if (bytes.length > 0) { output += decoder.decode(Uint8Array.from(bytes)); bytes = []; } }; for (let index = 0; index < value.length; index += 1) { if (value[index] === "_" && /^[0-9A-Fa-f]{2}$/.test(value.slice(index + 1, index + 3))) { bytes.push(Number.parseInt(value.slice(index + 1, index + 3), 16)); index += 2; continue; } flush(); output += value[index]; } flush(); return output; } function decodeFieldData(value, fieldHex) { return fieldHex ? decodeFieldHex(value) : value; } function createInitialState() { return { homeX: 0, homeY: 0, x: 0, y: 0, font: { name: "0", orientation: "N", height: 30, width: 30 }, block: null, fieldHex: false, barcode: null, barcodeDefaults: { moduleWidth: 2, ratio: 3, height: 10 }, }; } function addOperation(operations, operation) { operations.push(operation); } function estimateTextHeight(operation) { const maxLines = operation.block ? operation.block.maxLines : 1; return Math.max(operation.font.height, operation.font.height * maxLines * 1.15); } function estimateGraphicHeight(operation) { if (operation.type === "box") { return Math.max(operation.height, operation.thickness || 1); } if (operation.type === "graphic") { return operation.rows; } if (operation.type === "qr") { return operation.size; } return estimateTextHeight(operation); } function parseZpl(zpl, renderContext) { const tokens = tokenizeZpl(normalizeZpl(zpl)); const state = createInitialState(); const operations = []; const warnings = []; for (const token of tokens) { const params = token.params; switch (token.code) { case "^XA": case "^XZ": case "^FS": case "^CI": case "^MC": if (token.code === "^FS") { state.block = null; state.fieldHex = false; state.barcode = null; } break; case "^LH": { const [x, y] = splitParams(params); state.homeX = parseInteger(x); state.homeY = parseInteger(y); break; } case "^FO": case "^FT": { const [x, y] = splitParams(params); state.x = state.homeX + parseInteger(x); state.y = state.homeY + parseInteger(y); break; } case "^A": state.font = parseFont(params, state.font); break; case "^CF": { const [name, height, width] = splitParams(params); state.font = { name: name || state.font.name, orientation: "N", height: parseInteger(height, state.font.height), width: parseInteger(width, state.font.width), }; break; } case "^FB": { const [width, maxLines, lineSpacing, justification] = splitParams(params); state.block = { width: parseInteger(width, renderContext.sourceWidthDots - state.x), maxLines: Math.max(1, parseInteger(maxLines, 1)), lineSpacing: parseInteger(lineSpacing, 0), justification: (justification || "L").toUpperCase(), }; break; } case "^FH": state.fieldHex = true; break; case "^BY": { const [moduleWidth, ratio, height] = splitParams(params); state.barcodeDefaults = { moduleWidth: parseInteger(moduleWidth, state.barcodeDefaults.moduleWidth), ratio: parseInteger(ratio, state.barcodeDefaults.ratio), height: parseInteger(height, state.barcodeDefaults.height), }; break; } case "^BQ": { const [, model, magnification] = splitParams(params); state.barcode = { type: "qrcode", model: parseInteger(model, 2), magnification: parseInteger(magnification, 6), }; break; } case "^FD": { const data = decodeFieldData(params, state.fieldHex); if (state.barcode && state.barcode.type === "qrcode") { const qrText = data.replace(/^LA,/, ""); const size = Math.max(120, state.barcode.magnification * 38); addOperation(operations, { type: "qr", x: state.x, y: state.y, text: qrText, scale: state.barcode.magnification, size, }); } else { addOperation(operations, { type: "text", x: state.x, y: state.y, text: data, font: { ...state.font }, block: state.block ? { ...state.block } : null, }); } state.fieldHex = false; state.block = null; state.barcode = null; break; } case "^GB": { const [width, height, thickness] = splitParams(params); addOperation(operations, { type: "box", x: state.x, y: state.y, width: parseInteger(width), height: parseInteger(height), thickness: Math.max(1, parseInteger(thickness, 1)), }); break; } case "^GF": { const graphicParams = params.startsWith("A,") ? params.slice(2) : params; const graphic = parseGraphicField(graphicParams, warnings); if (graphic) { addOperation(operations, { type: "graphic", x: state.x, y: state.y, ...graphic, }); } break; } default: if (/^[\^~]/.test(token.code)) { warnings.push(`Unsupported command ignored: ${token.code}`); } break; } } if (operations.length === 0) { const error = new Error("Label contains no printable items"); error.statusCode = 422; throw error; } return { operations, warnings }; } function zplRepeatCount(char) { const code = char.charCodeAt(0); if (char >= "G" && char <= "Z") return code - 70; if (char >= "g" && char <= "z") return (code - 102) * 20; return 0; } function decodeGraphicRows(data, rowBytes) { const rows = []; let current = ""; let repeat = 0; let previous = "0".repeat(rowBytes * 2); const finishRow = () => { const row = (current + "0".repeat(rowBytes * 2)).slice(0, rowBytes * 2); rows.push(row); previous = row; current = ""; repeat = 0; }; for (const char of data.replace(/\s/g, "")) { if (char === ",") { finishRow(); continue; } if (char === ":") { rows.push(previous); current = ""; repeat = 0; continue; } if (char === "!") { current += "F".repeat(Math.max(0, rowBytes * 2 - current.length)); continue; } const repeatValue = zplRepeatCount(char); if (repeatValue > 0) { repeat += repeatValue; continue; } if (/^[0-9A-Fa-f]$/.test(char)) { current += char.toUpperCase().repeat(repeat || 1); repeat = 0; } } if (current.length > 0) { finishRow(); } return rows; } function parseGraphicField(params, warnings) { const parts = params.split(","); const totalBytes = parseInteger(parts[0]); const rowBytes = parseInteger(parts[2]); const data = parts.slice(3).join(","); if (!totalBytes || !rowBytes || !data) { warnings.push("Invalid ^GFA graphic ignored"); return null; } const rows = decodeGraphicRows(data, rowBytes); return { rowBytes, rows: rows.length, hexRows: rows, }; } function inferCanvasHeight(operations, renderContext) { const maxY = operations.reduce((highest, operation) => { return Math.max(highest, operation.y + estimateGraphicHeight(operation)); }, 0); return Math.max(renderContext.minHeightDots, Math.ceil((maxY + PAGE_PADDING_DOTS) * renderContext.scale)); } function configureTextContext(ctx, font) { const height = Math.max(8, font.height); ctx.fillStyle = "#000000"; ctx.textBaseline = "top"; ctx.font = `${height}px "Arial Narrow", "Arial", sans-serif`; } function textScaleX(font, renderContext) { const widthRatio = font.height > 0 ? font.width / font.height : 1; return Math.max(0.35, widthRatio * renderContext.fontWidthScale); } function measureTextWidth(ctx, text, font, renderContext) { return ctx.measureText(text).width * textScaleX(font, renderContext); } function wrapText(ctx, text, maxWidth, font, renderContext) { const words = String(text).split(/\s+/); const lines = []; let current = ""; for (const word of words) { const candidate = current ? `${current} ${word}` : word; if (current && measureTextWidth(ctx, candidate, font, renderContext) > maxWidth) { lines.push(current); current = word; } else { current = candidate; } } if (current) { lines.push(current); } return lines.length > 0 ? lines : [String(text)]; } function fillScaledText(ctx, text, x, y, font, renderContext) { const scaleX = textScaleX(font, renderContext); ctx.save(); ctx.translate(x, y); ctx.scale(scaleX, 1); ctx.fillText(text, 0, 0); ctx.restore(); } function drawText(ctx, operation, renderContext) { configureTextContext(ctx, operation.font); if (!operation.block) { fillScaledText(ctx, operation.text, operation.x, operation.y, operation.font, renderContext); return; } const lines = wrapText(ctx, operation.text, operation.block.width, operation.font, renderContext) .slice(0, operation.block.maxLines); const lineHeight = Math.max(1, operation.font.height + operation.block.lineSpacing); lines.forEach((line, index) => { let x = operation.x; const width = measureTextWidth(ctx, line, operation.font, renderContext); if (operation.block.justification === "C") { x += Math.max(0, (operation.block.width - width) / 2); } else if (operation.block.justification === "R") { x += Math.max(0, operation.block.width - width); } fillScaledText(ctx, line, x, operation.y + (index * lineHeight), operation.font, renderContext); }); } function drawBox(ctx, operation) { ctx.fillStyle = "#000000"; if (operation.width === 0 || operation.height === 0) { ctx.fillRect( operation.x, operation.y, Math.max(operation.width, operation.thickness), Math.max(operation.height, operation.thickness) ); return; } if (operation.width === operation.thickness && operation.height === operation.thickness) { ctx.fillRect(operation.x, operation.y, operation.width, operation.height); return; } ctx.lineWidth = operation.thickness; ctx.strokeStyle = "#000000"; ctx.strokeRect(operation.x, operation.y, operation.width, operation.height); } function drawGraphic(ctx, operation) { ctx.fillStyle = "#000000"; operation.hexRows.forEach((row, rowIndex) => { for (let byteIndex = 0; byteIndex < operation.rowBytes; byteIndex += 1) { const byte = Number.parseInt(row.slice(byteIndex * 2, (byteIndex * 2) + 2), 16); if (!Number.isFinite(byte)) continue; for (let bit = 0; bit < 8; bit += 1) { if (byte & (0x80 >> bit)) { ctx.fillRect(operation.x + (byteIndex * 8) + bit, operation.y + rowIndex, 1, 1); } } } }); } async function loadBwip() { if (!bwipPromise) { bwipPromise = import("@bwip-js/browser"); } return bwipPromise; } async function drawQr(ctx, operation) { const bwip = await loadBwip(); const qrCanvas = createCanvas(operation.size, operation.size); bwip.render({ bcid: "qrcode", text: operation.text, scale: Math.max(2, operation.scale), backgroundcolor: "FFFFFF", }, bwip.drawingCanvas(qrCanvas)); ctx.drawImage(qrCanvas, operation.x, operation.y, operation.size, operation.size); } async function renderZplToCanvas(zpl, options = {}) { const renderContext = createRenderContext(options); const { operations, warnings } = parseZpl(zpl, renderContext); const canvas = createCanvas(renderContext.widthDots, inferCanvasHeight(operations, renderContext)); const ctx = canvas.getContext("2d"); ctx.fillStyle = "#FFFFFF"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.scale(renderContext.scale, renderContext.scale); for (const operation of operations) { if (operation.type === "text") { drawText(ctx, operation, renderContext); } else if (operation.type === "box") { drawBox(ctx, operation); } else if (operation.type === "graphic") { drawGraphic(ctx, operation); } else if (operation.type === "qr") { await drawQr(ctx, operation); } } return { canvas, warnings }; } function canvasToPdfBuffer(canvas, renderOptions = {}) { const renderContext = createRenderContext(renderOptions); const labelHeightInches = canvas.height / renderContext.dpi; const pdf = new jsPDF({ orientation: "portrait", unit: "in", format: [renderContext.width, labelHeightInches], compress: true, }); pdf.addImage( canvas.toDataURL("image/png"), "PNG", 0, 0, renderContext.width, labelHeightInches ); return Buffer.from(pdf.output("arraybuffer")); } async function convertZplToPdf(zpl, options = {}) { const renderOptions = normalizeRenderOptions(options); const { canvas, warnings } = await renderZplToCanvas(zpl, renderOptions); return { buffer: canvasToPdfBuffer(canvas, renderOptions), warnings, }; } function buildServer() { const app = fastify({ logger: true, bodyLimit: 5 * 1024 * 1024, }); app.post("/convert", async (request, reply) => { try { const zpl = request.body && request.body.zpl; if (typeof zpl !== "string" || zpl.trim() === "") { return reply.code(400).send({ error: "Bad Request", message: 'Body JSON must include a non-empty "zpl" string.', }); } const { buffer, warnings } = await convertZplToPdf(zpl, { width: request.body.width, height: request.body.height, dpi: request.body.dpi, sourceDpi: request.body.sourceDpi, fontWidthScale: request.body.fontWidthScale, }); if (warnings.length > 0) { reply.header("X-ZPL-Parser-Warnings", String(warnings.length)); } return reply .code(200) .header("Content-Type", "application/pdf") .header("Content-Disposition", 'inline; filename="etiqueta.pdf"') .send(buffer); } catch (error) { request.log.error({ err: error }, "ZPL to PDF conversion failed"); const statusCode = Number.isInteger(error.statusCode) ? error.statusCode : 500; return reply.code(statusCode).send({ error: statusCode === 500 ? "Internal Server Error" : "Conversion Error", message: error.message || "Unable to convert ZPL to PDF.", }); } }); return app; } async function start() { const app = buildServer(); const { host, port } = getServerConfig(); try { await app.listen({ host, port }); } catch (error) { app.log.error(error); process.exit(1); } } if (require.main === module) { start(); } module.exports = { buildServer, convertZplToPdf, getServerConfig, };