This commit is contained in:
seb
2026-09-28 06:50:00 +02:00
parent d2aa9ff8b1
commit e8e4265dc4
11 changed files with 747 additions and 116 deletions

View File

@@ -1,7 +1,12 @@
import fs from 'fs/promises';
import path from 'path';
import express from 'express';
import JSZip from 'jszip';
import { renderLabel } from 'react-canvas-label-editor';
import * as db from '../../db/article.js';
import * as picDb from '../../db/pic.js';
import * as printerDb from '../../db/printer.js';
import * as labeltemplateDb from '../../db/labeltemplate.js';
import { emit } from '../sse.js';
import { buildArtikelCsv, toWindows1252 } from '../../util/artikelCsv.js';
@@ -67,6 +72,22 @@ function articleErrorStatus(error) {
return 500;
}
function interpolateString(str, article) {
if (typeof str !== 'string') return str;
const net = article.net_price ?? article.prices?.[0]?.net;
const taxRate = article.prices?.[0]?.taxRate ?? 0;
const gross = net != null ? +(net * (1 + taxRate / 100)).toFixed(2) : null;
const ean = (article.eans && article.eans.length > 0) ? article.eans[0] : '';
return str
.replace(/\{\{(?:ARTICLE_NAME|article_name|name)\}\}/gi, article.name || '')
.replace(/\{\{SKU\}\}/gi, article.sku || '')
.replace(/\{\{(?:NET_PRICE|net_price)\}\}/gi, net != null ? net.toString() : '')
.replace(/\{\{(?:GROSS_PRICE|gross_price)\}\}/gi, gross != null ? gross.toString() : '')
.replace(/\{\{(?:PRICE|price)\}\}/gi, gross != null ? gross.toString() : (net != null ? net.toString() : ''))
.replace(/\{\{(?:EAN|ean)\}\}/gi, ean);
}
/**
* Registers article routes (requires auth middleware).
* @param {import('express').Application} app - Express app
@@ -1502,4 +1523,134 @@ export function register(app, basePath = '') {
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /article/{sku}/print:
* post:
* tags: [Article]
* summary: Print article label
* description: Prints a label for the article using a label template.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [templateId, printerId, quantity]
* properties:
* templateId:
* type: integer
* printerId:
* type: integer
* quantity:
* type: integer
* responses:
* 200:
* description: Print job dispatched
* 400:
* description: Invalid parameters
* 404:
* description: Printer, Template or Article not found
* 500:
* description: Server error
*/
app.post(`${basePath}/article/:sku/print`, express.json(), async (req, res) => {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return res.status(400).json({ error: 'No company selected' });
}
const { sku: parsedSku, error: skuError } = parseSku(req.params.sku);
if (skuError) return res.status(400).json({ error: skuError });
const sku = parsedSku;
const { templateId, printerId, quantity } = req.body;
const parsedTemplateId = parsePositiveInt(templateId, 'templateId');
if (parsedTemplateId.error) return res.status(400).json({ error: parsedTemplateId.error });
const parsedPrinterId = parsePositiveInt(printerId, 'printerId');
if (parsedPrinterId.error) return res.status(400).json({ error: parsedPrinterId.error });
const parsedQuantity = parsePositiveInt(quantity, 'quantity');
if (parsedQuantity.error) return res.status(400).json({ error: parsedQuantity.error });
try {
const printer = await printerDb.getById(req.user.id, parsedPrinterId.id);
if (!printer || printer.company_id !== companyId) {
return res.status(404).json({ error: 'Printer not found' });
}
if (printer.type !== 'zpl') {
return res.status(400).json({ error: 'Printer is not a ZPL printer' });
}
const template = await labeltemplateDb.getById(req.user.id, parsedTemplateId.id);
if (!template || template.company_id !== companyId) {
return res.status(404).json({ error: 'Label template not found' });
}
let article;
try {
const result = await db.getBySku(req.user.id, sku);
article = result?.article;
} catch (err) {
const status = articleErrorStatus(err);
if (status === 500) console.error('Get article by sku failed for print:', err);
return res.status(status).json({ error: err.message });
}
if (!article) {
return res.status(404).json({ error: 'Article not found' });
}
// Extract and interpolate canvas objects
const content = template.content;
const objects = Array.isArray(content?.objects)
? content.objects
: Array.isArray(content)
? content
: [];
const interpolatedObjects = objects.map(obj => {
const cloned = { ...obj };
if (typeof cloned.text === 'string') {
cloned.text = interpolateString(cloned.text, article);
}
if (typeof cloned.code === 'string') {
cloned.code = interpolateString(cloned.code, article);
}
return cloned;
});
const printPayload = await renderLabel({
width: template.width,
height: template.height,
objects: interpolatedObjects,
});
// Mock network dispatch
const printDir = path.join(process.cwd(), 'print_spool', `${printer.address}_${printer.type}`);
await fs.mkdir(printDir, { recursive: true });
for (let i = 0; i < parsedQuantity.id; i++) {
const timestamp = Date.now();
const filename = parsedQuantity.id > 1 ? `${timestamp}_${i + 1}.png` : `${timestamp}.png`;
const filePath = path.join(printDir, filename);
await fs.writeFile(filePath, printPayload);
}
res.status(200).json({ success: true });
} catch (error) {
console.error('Print article label failed:', error);
res.status(500).json({ error: error.message });
}
});
}

View File

@@ -44,9 +44,10 @@ export function register(app, basePath = '') {
* get:
* tags: [LabelTemplate]
* summary: List label templates
* description: Lists all ZPL label templates for the selected company. The `thumb` field is returned as a base64-encoded PNG string when present.
* description: Lists all ZPL label templates for the selected company. The `thumb` field is returned as a base64-encoded PNG string when present. `content.objects` is omitted to reduce payload size.
* security:
* - cookieAuth: []
* x-markdown: labeltemplate/list.md
* responses:
* 200:
* description: List of label templates
@@ -97,6 +98,7 @@ export function register(app, basePath = '') {
* description: Retrieves a single label template for the authenticated user's selected company.
* security:
* - cookieAuth: []
* x-markdown: labeltemplate/get.md
* parameters:
* - in: path
* name: id
@@ -169,6 +171,7 @@ export function register(app, basePath = '') {
* and stored with the template.
* security:
* - cookieAuth: []
* x-markdown: labeltemplate/create.md
* requestBody:
* required: true
* content:
@@ -271,6 +274,7 @@ export function register(app, basePath = '') {
* When `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered.
* security:
* - cookieAuth: []
* x-markdown: labeltemplate/update.md
* parameters:
* - in: path
* name: id
@@ -320,6 +324,7 @@ export function register(app, basePath = '') {
* When `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered.
* security:
* - cookieAuth: []
* x-markdown: labeltemplate/update.md
* parameters:
* - in: path
* name: id
@@ -374,6 +379,7 @@ export function register(app, basePath = '') {
* description: Deletes a label template for the authenticated user's selected company.
* security:
* - cookieAuth: []
* x-markdown: labeltemplate/delete.md
* parameters:
* - in: path
* name: id

View File

@@ -1,23 +1,28 @@
import sharp from 'sharp';
import { renderLabel } from 'react-canvas-label-editor';
const THUMB_SIZE = 50;
const THUMB_W = 150;
const THUMB_H = 80;
/**
* Renders a 50×50 1-bit indexed PNG thumbnail for a label template.
* Renders a 150×80 thumbnail for a label template.
*
* Workflow:
* 1. Render the label at its native pixel resolution via renderLabel().
* 2. Resize to 50×50 using Lanczos3 (covers).
* 3. Convert to 1-bit PNG:
* - Greyscale → threshold at 128 → 1-bit PNG.
* - Pixels outside the label area (transparent in the RGBA source) are
* rendered WHITE (255) so they read as 0 in the final 1-bit output,
* keeping the file as small as possible.
* 2. Resize to fit within 150×80 preserving aspect ratio (fit: 'contain').
* Letterbox areas remain transparent.
* 3. Process raw RGBA pixels:
* - Transparent letterbox padding (alpha < 128) → fully transparent (0,0,0,0).
* - Label pixels (alpha >= 128) → fully opaque (alpha=255) with 4-bit greyscale
* (16 levels: 0, 17, 34, ..., 255) for smooth anti-aliased text and barcodes.
* 4. Output as palette PNG with transparency support.
*
* The result is a "4-bit grey + 1-bit alpha" image:
* letterbox area is transparent, and the label area is anti-aliased 4-bit greyscale.
*
* @param {object} opts
* @param {number} opts.width - Label width in pixels.
* @param {number} opts.height - Label height in pixels.
* @param {number} opts.width - Label width in pixels.
* @param {number} opts.height - Label height in pixels.
* @param {Array} opts.objects - Label editor objects array.
* @returns {Promise<Buffer>} PNG buffer.
*/
@@ -25,19 +30,47 @@ export async function renderThumb({ width, height, objects }) {
// 1. Render at native resolution → RGBA PNG buffer
const nativePng = await renderLabel({ width, height, objects });
// 2. Resize to 50×50, convert to greyscale, flatten transparency to white,
// then threshold to 1-bit and output as PNG.
const thumb = await sharp(nativePng)
.resize(THUMB_SIZE, THUMB_SIZE, {
// 2. Resize to fit within 150×80, preserving aspect ratio.
// Transparent background for letterbox padding.
const { data, info } = await sharp(nativePng)
.ensureAlpha()
.resize(THUMB_W, THUMB_H, {
fit: 'contain',
background: { r: 255, g: 255, b: 255, alpha: 1 },
background: { r: 0, g: 0, b: 0, alpha: 0 },
kernel: sharp.kernel.lanczos3,
})
.greyscale()
// Threshold: pixels ≥ 128 → white (0 in 1-bit), < 128 → black (1 in 1-bit)
.threshold(128)
// PNG with 1-bit colour depth; compression level 9 for smallest file
.png({ colours: 2, compressionLevel: 9, adaptiveFiltering: false })
.raw()
.toBuffer({ resolveWithObject: true });
const { width: w, height: h } = info;
// 3. Per-pixel: transparent letterbox remains transparent (alpha=0),
// label area is opaque (alpha=255) with 4-bit greyscale (16 levels).
const rgba = Buffer.alloc(w * h * 4);
for (let i = 0; i < w * h; i++) {
const base = i * 4;
const a = data[base + 3];
if (a < 128) {
// Transparent letterbox area — leave as (0,0,0,0)
} else {
const factor = a === 255 ? 1 : 255 / a;
const r = Math.min(255, Math.round(data[base] * factor));
const g = Math.min(255, Math.round(data[base + 1] * factor));
const b = Math.min(255, Math.round(data[base + 2] * factor));
const grey = 0.299 * r + 0.587 * g + 0.114 * b;
// Quantize to 4-bit grey (16 levels: 0, 17, 34, ..., 255)
const qGrey = Math.round(grey / 17) * 17;
rgba[base] = qGrey;
rgba[base + 1] = qGrey;
rgba[base + 2] = qGrey;
rgba[base + 3] = 255;
}
}
// 4. Encode as palette PNG with transparency support (16 greys + 1 transparent entry).
const thumb = await sharp(rgba, { raw: { width: w, height: h, channels: 4 } })
.png({ palette: true, colours: 17, compressionLevel: 9 })
.toBuffer();
return thumb;