From 936f8b68686ec8e3b99b7ef07231af13bd1c689a Mon Sep 17 00:00:00 2001 From: Eduardo Dorado Date: Fri, 22 May 2026 01:59:49 -0300 Subject: [PATCH] fix: tune offline zpl label rendering --- AGENTS.md | 21 +++++++++ server.js | 129 ++++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 132 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c2406f1..7bcbfe2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,3 +33,24 @@ C:\Users\Dorado\Desktop\Pampero\Proyectos\zpl-pdf-microservice\node-v24.15.0\nod ``` El default sigue siendo `127.0.0.1:3040` para uso local. También se puede configurar con variables de entorno `HOST` y `PORT`. + +## Parámetros de Render + +El endpoint `POST /convert` acepta, además de `zpl`, los parámetros `width`, `height`, `dpi`, `sourceDpi`, `fontWidthScale`, `code128WidthScale` y `qrSizeScale`. + +Para etiquetas Mercado Libre 4x6 con coordenadas Zebra a 203 DPI y PDF de salida a 300 DPI, usar como base: + +```json +{ + "width": 4, + "height": 6, + "dpi": 300, + "sourceDpi": 203, + "fontWidthScale": 0.82, + "code128WidthScale": 1.42, + "qrSizeScale": 0.5 +} +``` + +`code128WidthScale` ajusta el ancho visual de `^BC` porque `bwip-js` genera Code128 más angosto que Labelary/Zebra para algunas etiquetas ML con `^BY3`. +`qrSizeScale` ajusta el tamaño visual de `^BQ` porque `bwip-js` incluye una matriz QR más grande que la salida observada en etiquetas ML/Zebra para `^BQN`. diff --git a/server.js b/server.js index 1ee9487..48736b4 100644 --- a/server.js +++ b/server.js @@ -14,7 +14,10 @@ const DEFAULT_RENDER_OPTIONS = { dpi: 203, sourceDpi: 203, fontWidthScale: 0.82, + code128WidthScale: 1.42, + qrSizeScale: 0.5, }; +const RENDERER_VERSION = "2026-05-22-fixed-reverse-baseline"; let bwipPromise; @@ -103,6 +106,8 @@ function normalizeRenderOptions(options = {}) { 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, @@ -112,6 +117,12 @@ function normalizeRenderOptions(options = {}) { 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, }; } @@ -302,7 +313,7 @@ function parseZpl(zpl, renderContext) { if (state.barcode && state.barcode.type === "qrcode") { const qrText = data.replace(/^LA,/, ""); - const size = Math.max(120, state.barcode.magnification * 38); + const size = Math.max(80, state.barcode.magnification * 25); addOperation(operations, { type: "qr", x: state.x, @@ -516,12 +527,51 @@ function fillScaledText(ctx, text, x, y, font, renderContext) { ctx.restore(); } -function drawText(ctx, operation, renderContext) { +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); - ctx.fillStyle = operation.reverse ? "#FFFFFF" : "#000000"; + const solidBackground = operation.reverse ? findSolidBackground(operation, filledBoxes) : null; + ctx.fillStyle = solidBackground ? "#FFFFFF" : "#000000"; if (!operation.block) { - fillScaledText(ctx, operation.text, operation.x, operation.y, operation.font, renderContext); + 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; } @@ -540,12 +590,32 @@ function drawText(ctx, operation, renderContext) { x += Math.max(0, operation.block.width - width); } - fillScaledText(ctx, line, x, operation.y + (index * lineHeight), operation.font, renderContext); + 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"; @@ -559,14 +629,15 @@ function drawBox(ctx, operation) { return; } - if (operation.thickness >= Math.min(operation.width, operation.height)) { + if ((operation.thickness * 2) >= Math.min(operation.width, operation.height)) { ctx.fillRect(operation.x, operation.y, operation.width, operation.height); - return; + 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) { @@ -594,9 +665,9 @@ async function loadBwip() { return bwipPromise; } -async function drawQr(ctx, operation) { +async function drawQr(ctx, operation, renderContext) { const bwip = await loadBwip(); - const qrCanvas = createCanvas(operation.size, operation.size); + const qrCanvas = createCanvas(10, 10); bwip.render({ bcid: "qrcode", @@ -605,19 +676,24 @@ async function drawQr(ctx, operation) { backgroundcolor: "FFFFFF", }, bwip.drawingCanvas(qrCanvas)); - ctx.drawImage(qrCanvas, operation.x, operation.y, operation.size, operation.size); + 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) { +async function drawCode128(ctx, operation, renderContext) { const bwip = await loadBwip(); - const targetWidth = Math.max(260, operation.text.length * operation.moduleWidth * 11); + 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), + scaleX: Math.max(1, operation.moduleWidth * renderContext.code128WidthScale), scaleY: 2, height: Math.max(8, operation.height / 12), includetext: operation.printText, @@ -626,7 +702,7 @@ async function drawCode128(ctx, operation) { textxalign: "center", }, bwip.drawingCanvas(barcodeCanvas)); - ctx.drawImage(barcodeCanvas, operation.x, operation.y, targetWidth, targetHeight); + ctx.drawImage(barcodeCanvas, operation.x, operation.y, barcodeCanvas.width || targetWidth, targetHeight); } async function renderZplToCanvas(zpl, options = {}) { @@ -638,18 +714,26 @@ async function renderZplToCanvas(zpl, options = {}) { 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); + drawText(ctx, operation, renderContext, filledBoxes); } else if (operation.type === "box") { - drawBox(ctx, operation); + 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); + await drawQr(ctx, operation, renderContext); } else if (operation.type === "code128") { - await drawCode128(ctx, operation); + await drawCode128(ctx, operation, renderContext); } } @@ -694,6 +778,11 @@ function buildServer() { 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; @@ -711,6 +800,8 @@ function buildServer() { dpi: request.body.dpi, sourceDpi: request.body.sourceDpi, fontWidthScale: request.body.fontWidthScale, + code128WidthScale: request.body.code128WidthScale, + qrSizeScale: request.body.qrSizeScale, }); if (warnings.length > 0) { @@ -720,6 +811,7 @@ function buildServer() { 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) { @@ -756,4 +848,5 @@ module.exports = { buildServer, convertZplToPdf, getServerConfig, + RENDERER_VERSION, };