feat: initial zpl pdf microservice
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
.cache/
|
||||||
|
node-v24.15.0/
|
||||||
|
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# ZPL to PDF Microservice - Agent Orchestration
|
||||||
|
|
||||||
|
Este microservicio actúa como un sidecar local para el ecosistema GT:Sync / Pampero. Resuelve la incompatibilidad de versiones de Node.js en el backend principal (Next.js con Node 12), renderizando etiquetas ZPL a formato PDF de forma aislada y 100% offline utilizando un entorno gráfico simulado en memoria.
|
||||||
|
|
||||||
|
## Infraestructura y Ejecución
|
||||||
|
|
||||||
|
- **Puerto del servicio:** `3040`
|
||||||
|
- **Host:** `127.0.0.1` (Localhost)
|
||||||
|
- **Motor Node:** Binario aislado (v24.15.0) para no afectar el PATH global.
|
||||||
|
- **Core Stack:** `fastify` (Web Server), `zpl-js` (Parser), `canvas` (Render gráfico), `jspdf` (Exportación binaria).
|
||||||
|
|
||||||
|
## Control De Versiones
|
||||||
|
|
||||||
|
- **Repositorio oficial Gitea:** `https://gitea.globaltechscm.com/gt-microservices/zpl-pdf-microservice.git`
|
||||||
|
- Este microservicio usa Gitea, no Bonobo. Para este proyecto, configurar siempre `origin` contra el remoto Gitea anterior.
|
||||||
|
- No versionar `node_modules/`, `.cache/` ni el runtime local `node-v24.15.0/`. El runtime aislado se instala/restaura localmente en la ruta documentada abajo.
|
||||||
|
|
||||||
|
### Ruta Absoluta del Intérprete
|
||||||
|
Para ejecutar el servidor o instalar paquetes a través de PM2 o consola, se DEBE utilizar obligatoriamente la siguiente ruta absoluta:
|
||||||
|
`C:\Users\Dorado\Desktop\Pampero\Proyectos\zpl-pdf-microservice\node-v24.15.0\node.exe`
|
||||||
|
|
||||||
|
### Comandos de Gestión (Usar CMD/PowerShell)
|
||||||
|
|
||||||
|
**Levantar el servicio manualmente:**
|
||||||
|
```powershell
|
||||||
|
C:\Users\Dorado\Desktop\Pampero\Proyectos\zpl-pdf-microservice\node-v24.15.0\node.exe server.js
|
||||||
|
```
|
||||||
Binary file not shown.
Generated
+1319
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "zpl-pdf-microservice",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"check": "node --check server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"canvas": "^3.2.3",
|
||||||
|
"fastify": "^5.8.5",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
|
"zpl-js": "^0.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fastify = require("fastify");
|
||||||
|
const { createCanvas } = require("canvas");
|
||||||
|
const { jsPDF } = require("jspdf");
|
||||||
|
const { TextDecoder } = require("util");
|
||||||
|
|
||||||
|
const HOST = "127.0.0.1";
|
||||||
|
const 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 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();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ host: HOST, port: PORT });
|
||||||
|
} catch (error) {
|
||||||
|
app.log.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildServer,
|
||||||
|
convertZplToPdf,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user