fix: tune offline zpl label rendering

This commit is contained in:
Eduardo Dorado
2026-05-22 01:59:49 -03:00
parent ddc81d482e
commit 936f8b6868
2 changed files with 132 additions and 18 deletions
+111 -18
View File
@@ -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,
};