Files
2026-05-22 01:59:49 -03:00

853 lines
24 KiB
JavaScript

"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,
code128WidthScale: 1.42,
qrSizeScale: 0.5,
};
const RENDERER_VERSION = "2026-05-22-fixed-reverse-baseline";
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);
const code128WidthScale = Number(options.code128WidthScale ?? DEFAULT_RENDER_OPTIONS.code128WidthScale);
const qrSizeScale = Number(options.qrSizeScale ?? DEFAULT_RENDER_OPTIONS.qrSizeScale);
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,
code128WidthScale: Number.isFinite(code128WidthScale) && code128WidthScale > 0
? code128WidthScale
: DEFAULT_RENDER_OPTIONS.code128WidthScale,
qrSizeScale: Number.isFinite(qrSizeScale) && qrSizeScale > 0
? qrSizeScale
: DEFAULT_RENDER_OPTIONS.qrSizeScale,
};
}
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,
fieldReverse: 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;
}
if (operation.type === "code128") {
return operation.height;
}
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 "^FX":
case "^MC":
if (token.code === "^FS") {
state.block = null;
state.fieldHex = false;
state.fieldReverse = 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 "^FR":
state.fieldReverse = 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 "^BC": {
const [, height, printText, textAbove, checkDigit] = splitParams(params);
state.barcode = {
type: "code128",
height: parseInteger(height, 120),
printText: String(printText || "Y").toUpperCase() === "Y",
textAbove: String(textAbove || "N").toUpperCase() === "Y",
checkDigit: String(checkDigit || "N").toUpperCase() === "Y",
moduleWidth: state.barcodeDefaults.moduleWidth,
};
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(80, state.barcode.magnification * 25);
addOperation(operations, {
type: "qr",
x: state.x,
y: state.y,
text: qrText,
scale: state.barcode.magnification,
size,
});
} else if (state.barcode && state.barcode.type === "code128") {
addOperation(operations, {
type: "code128",
x: state.x,
y: state.y,
text: data.replace(/^>[:;>]?/, ""),
height: state.barcode.height,
printText: state.barcode.printText,
textAbove: state.barcode.textAbove,
moduleWidth: state.barcode.moduleWidth,
});
} else {
addOperation(operations, {
type: "text",
x: state.x,
y: state.y,
text: data,
font: { ...state.font },
block: state.block ? { ...state.block } : null,
reverse: state.fieldReverse,
});
}
state.fieldHex = false;
state.fieldReverse = 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 rectanglesIntersect(a, b) {
return a.x < b.x + b.width
&& a.x + a.width > b.x
&& a.y < b.y + b.height
&& a.y + a.height > b.y;
}
function isFilledBox(operation) {
return operation.width > 0
&& operation.height > 0
&& (operation.thickness * 2) >= Math.min(operation.width, operation.height);
}
function hasSolidBackground(operation, filledBoxes) {
return Boolean(findSolidBackground(operation, filledBoxes));
}
function findSolidBackground(operation, filledBoxes) {
const height = operation.block
? Math.max(operation.font.height, operation.font.height * operation.block.maxLines)
: operation.font.height;
const width = operation.block
? operation.block.width
: Math.max(1, operation.text.length * operation.font.width);
const textBox = {
x: operation.x,
y: operation.y,
width,
height,
};
return filledBoxes.find((box) => rectanglesIntersect(textBox, box));
}
function drawText(ctx, operation, renderContext, filledBoxes = []) {
configureTextContext(ctx, operation.font);
const solidBackground = operation.reverse ? findSolidBackground(operation, filledBoxes) : null;
ctx.fillStyle = solidBackground ? "#FFFFFF" : "#000000";
if (!operation.block) {
if (solidBackground) {
fillReverseTextInBox(ctx, operation.text, operation.x, solidBackground, operation.font, renderContext);
} else {
fillScaledText(ctx, operation.text, operation.x, operation.y, operation.font, renderContext);
}
ctx.fillStyle = "#000000";
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);
}
if (solidBackground && lines.length === 1) {
fillReverseTextInBox(ctx, line, x, solidBackground, operation.font, renderContext);
} else {
fillScaledText(ctx, line, x, operation.y + (index * lineHeight), operation.font, renderContext);
}
});
ctx.fillStyle = "#000000";
}
function fillReverseTextInBox(ctx, text, x, box, font, renderContext) {
ctx.save();
ctx.textBaseline = "alphabetic";
const metrics = ctx.measureText(text);
const ascent = Math.max(0, metrics.actualBoundingBoxAscent || (font.height * 0.75));
const descent = Math.max(0, metrics.actualBoundingBoxDescent || (font.height * 0.2));
const baselineY = box.y + ((box.height - ascent - descent) / 2) + ascent;
ctx.translate(x, baselineY);
ctx.scale(textScaleX(font, renderContext), 1);
ctx.fillText(text, 0, 0);
ctx.restore();
ctx.textBaseline = "top";
}
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.thickness * 2) >= Math.min(operation.width, operation.height)) {
ctx.fillRect(operation.x, operation.y, operation.width, operation.height);
return true;
}
ctx.lineWidth = operation.thickness;
ctx.strokeStyle = "#000000";
ctx.strokeRect(operation.x, operation.y, operation.width, operation.height);
return false;
}
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, renderContext) {
const bwip = await loadBwip();
const qrCanvas = createCanvas(10, 10);
bwip.render({
bcid: "qrcode",
text: operation.text,
scale: Math.max(2, operation.scale),
backgroundcolor: "FFFFFF",
}, bwip.drawingCanvas(qrCanvas));
const targetSize = Math.max(80, Math.round(qrCanvas.width * renderContext.qrSizeScale));
const availableWidth = renderContext.sourceWidthDots - operation.x;
const clippedSize = Math.max(20, Math.min(targetSize, availableWidth));
ctx.drawImage(qrCanvas, operation.x, operation.y, clippedSize, clippedSize);
}
async function drawCode128(ctx, operation, renderContext) {
const bwip = await loadBwip();
const naturalWidth = Math.max(260, operation.text.length * operation.moduleWidth * 11);
const targetWidth = Math.round(naturalWidth * renderContext.code128WidthScale);
const targetHeight = operation.height;
const barcodeCanvas = createCanvas(targetWidth, targetHeight);
bwip.render({
bcid: "code128",
text: operation.text,
scaleX: Math.max(1, operation.moduleWidth * renderContext.code128WidthScale),
scaleY: 2,
height: Math.max(8, operation.height / 12),
includetext: operation.printText,
paddingwidth: 0,
paddingheight: 0,
textxalign: "center",
}, bwip.drawingCanvas(barcodeCanvas));
ctx.drawImage(barcodeCanvas, operation.x, operation.y, barcodeCanvas.width || targetWidth, targetHeight);
}
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);
const filledBoxes = [];
for (const operation of operations) {
if (operation.type === "text") {
drawText(ctx, operation, renderContext, filledBoxes);
} else if (operation.type === "box") {
if (drawBox(ctx, operation) && isFilledBox(operation)) {
filledBoxes.push({
x: operation.x,
y: operation.y,
width: operation.width,
height: operation.height,
});
}
} else if (operation.type === "graphic") {
drawGraphic(ctx, operation);
} else if (operation.type === "qr") {
await drawQr(ctx, operation, renderContext);
} else if (operation.type === "code128") {
await drawCode128(ctx, operation, renderContext);
}
}
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.get("/health", async () => ({
ok: true,
rendererVersion: RENDERER_VERSION,
}));
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,
code128WidthScale: request.body.code128WidthScale,
qrSizeScale: request.body.qrSizeScale,
});
if (warnings.length > 0) {
reply.header("X-ZPL-Parser-Warnings", String(warnings.length));
}
return reply
.code(200)
.header("Content-Type", "application/pdf")
.header("X-ZPL-Renderer-Version", RENDERER_VERSION)
.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,
RENDERER_VERSION,
};