diff --git a/package-lock.json b/package-lock.json index b8129e5..82a9c58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "pdfconvert-wasm": "^0.3.0", "pdfkit": "^0.20.2", "react": "^19.3.1", - "react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor#v19.0.0", + "react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor#v20.0.0", "selfsigned": "^5.5.0", "skelehash-wasm": "^0.1.1", "swagger-ui-dist": "^5.33.3", @@ -4379,8 +4379,8 @@ } }, "node_modules/react-canvas-label-editor": { - "version": "19.0.0", - "resolved": "git+ssh://git@github.com/sebseb7/react-canvas-label-editor.git#a71e1dfbd1d2d9a0341dfac508b728500222d165", + "version": "20.0.0", + "resolved": "git+ssh://git@github.com/sebseb7/react-canvas-label-editor.git#5dc9a981bbecf5d89861214fe5ff774e073e0c36", "dependencies": { "canvaskit-wasm": "^0.42.0", "jsbarcode": "^3.12.3", diff --git a/package.json b/package.json index d0d529c..d5bf38e 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "pdfconvert-wasm": "^0.3.0", "pdfkit": "^0.20.2", "react": "^19.3.1", - "react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor#v19.0.0", + "react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor#v20.0.0", "selfsigned": "^5.5.0", "skelehash-wasm": "^0.1.1", "swagger-ui-dist": "^5.33.3", @@ -57,7 +57,7 @@ "swagger-jsdoc": "^6.3.0" }, "allowScripts": { - "react-canvas-label-editor@19.0.0": true, + "react-canvas-label-editor@20.0.0": true, "better-sqlite3@13.0.3": true }, "overrides": { diff --git a/src/api/index.js b/src/api/index.js index 05c1b83..af50259 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -352,7 +352,27 @@ export async function runSelfTest() { apiLog.warn(`WASM image pipeline warning: ${err.message}`); } - // 5. Clean up store connection + // 5. Test WASM label rasterizer + try { + const { renderThumb } = await import('../services/labelThumb.js'); + const thumb = await renderThumb({ + width: 300, + height: 150, + objects: [ + { type: 'textbox', text: 'SELFTEST', x: 10, y: 10, font: 'outfit' }, + { type: 'barcode', format: 'EAN13', code: '4012345678901', x: 10, y: 40, scale: 2 }, + ], + }); + if (!thumb || thumb.length === 0) { + throw new Error('Empty thumbnail rendered'); + } + apiLog.info('WASM label rasterizer: OK'); + } catch (err) { + apiLog.error(`WASM label rasterizer FAILED: ${err.message}`); + throw err; + } + + // 6. Clean up store connection try { await closeStore(); } catch { diff --git a/src/api/routes/labeltemplate.js b/src/api/routes/labeltemplate.js index 321f0dc..7dde68a 100644 --- a/src/api/routes/labeltemplate.js +++ b/src/api/routes/labeltemplate.js @@ -1,7 +1,17 @@ -import { validateObjects } from 'react-canvas-label-editor'; +import fs from 'fs/promises'; +import path from 'path'; +import { renderLabel, validateObjects } from 'react-canvas-label-editor'; import * as db from '../../db/labeltemplate.js'; +import * as printerDb from '../../db/printer.js'; +import * as agentprinterDb from '../../db/agentprinter.js'; +import * as printerjobDb from '../../db/printerjob.js'; +import * as articleDb from '../../db/article.js'; import { renderThumb } from '../../services/labelThumb.js'; +import { pngToZpl } from '../../services/zplService.js'; +import { notifyAgentJob } from '../agent/index.js'; import { emit } from '../sse.js'; +import { applyZplCopies } from './zpljob.js'; +import { interpolateString } from './article/shared.js'; // #region Helpers @@ -482,6 +492,222 @@ export function register(app, basePath = '') { }); // #endregion + + /** + * @openapi + * /labeltemplate/testprint: + * post: + * tags: [LabelTemplate] + * summary: Test print label template + * description: | + * Dispatches a test print job for a label template or unsaved canvas objects to a specified ZPL printer. + * Placeholders in text and barcodes are interpolated using a sample article or the specified article SKU. + * security: + * - cookieAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * printerId: + * type: integer + * agentprinterId: + * type: integer + * templateId: + * type: integer + * objects: + * type: array + * width: + * type: integer + * height: + * type: integer + * dpi: + * type: integer + * sku: + * type: string + * quantity: + * type: integer + * responses: + * 200: + * description: Print job created + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * 400: + * description: Invalid parameters + * 404: + * description: Printer or template not found + * 500: + * description: Server error + */ + // #region POST /labeltemplate/testprint + + async function handleTestPrint(req, res) { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + const companyId = selected.companyId; + + const rawPrinterId = req.body?.printerId ?? req.body?.agentprinterId; + const parsedPrinterId = parsePositiveInt(rawPrinterId, 'printerId'); + if (parsedPrinterId.error) { + return res.status(400).json({ error: parsedPrinterId.error }); + } + + try { + let agentPrinter = null; + let printer = null; + + if (req.body?.agentprinterId != null) { + const parsedApId = parsePositiveInt(req.body.agentprinterId, 'agentprinterId'); + if (!parsedApId.error) { + agentPrinter = await agentprinterDb.getById(req.user.id, parsedApId.id); + if (agentPrinter && agentPrinter.company_id === companyId) { + printer = agentPrinter; + } else { + agentPrinter = null; + } + } + } + + if (!agentPrinter) { + printer = await printerDb.getById(req.user.id, parsedPrinterId.id); + if (!printer || printer.company_id !== companyId) { + agentPrinter = await agentprinterDb.getById(req.user.id, parsedPrinterId.id); + if (agentPrinter && agentPrinter.company_id === companyId) { + printer = agentPrinter; + } else { + return res.status(404).json({ error: 'Printer not found' }); + } + } else if (printer.printeragentId != null) { + agentPrinter = printer; + } + } + + if (printer.type && printer.type.toLowerCase() !== 'zpl') { + return res.status(400).json({ error: 'Printer is not a ZPL printer' }); + } + + const rawTemplateId = req.params?.id ?? req.body?.templateId; + let template = null; + if (rawTemplateId != null) { + const parsedTmplId = parsePositiveInt(rawTemplateId, 'templateId'); + if (!parsedTmplId.error) { + template = await db.getById(req.user.id, parsedTmplId.id); + if (!template || template.company_id !== companyId) { + return res.status(404).json({ error: 'Label template not found' }); + } + } + } + + const rawQuantity = req.body?.quantity ?? 1; + const quantity = Number(rawQuantity) > 0 ? Number(rawQuantity) : 1; + + let objects = req.body?.objects; + let width = req.body?.width != null ? Number(req.body.width) : template?.width; + let height = req.body?.height != null ? Number(req.body.height) : template?.height; + let content = req.body?.content ?? template?.content; + + // Resolve sample article for placeholder interpolation + let article = null; + if (req.body?.sku) { + try { + const result = await articleDb.getBySku(req.user.id, req.body.sku); + article = result?.article; + } catch { + // ignore + } + } + if (!article) { + article = { + name: req.body?.name || template?.name || 'Musterartikel', + sku: 'TEST-12345', + prices: [{ net: 19.99, taxRate: 19 }], + eans: ['4012345678901'], + }; + } + + let zplData; + if (typeof content === 'string' && content.includes('^XA')) { + let interpolated = interpolateString(content, article); + if (quantity > 1) { + interpolated = applyZplCopies(interpolated, quantity); + } + zplData = interpolated; + } else { + if (!objects && content) { + objects = Array.isArray(content?.objects) + ? content.objects + : Array.isArray(content) + ? content + : []; + } + if (!Array.isArray(objects)) { + objects = []; + } + + 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: width || 448, + height: height || 200, + objects: interpolatedObjects, + }); + + zplData = await pngToZpl(printPayload, quantity); + } + + if (agentPrinter) { + const job = await printerjobDb.create(req.user.id, { + agentprinterId: agentPrinter.id, + type: 'zpl', + data: zplData, + }); + + if (job.printeragentId) { + notifyAgentJob(job.printeragentId, 'created', job); + } + + return res.status(200).json({ success: true, printerjob: job }); + } + + // Fallback for legacy network printers with static IP / address + const spoolKey = printer.address ? `${printer.address}_${printer.type}` : `printer_${printer.id}_${printer.type || 'zpl'}`; + const printDir = path.join(process.cwd(), 'print_spool', spoolKey); + await fs.mkdir(printDir, { recursive: true }); + + const timestamp = Date.now(); + const filename = `${timestamp}.zpl`; + const filePath = path.join(printDir, filename); + await fs.writeFile(filePath, zplData, 'utf8'); + + return res.status(200).json({ success: true }); + } catch (error) { + console.error('Testprint label template failed:', error); + return res.status(500).json({ error: error.message }); + } + } + + app.post(`${basePath}/labeltemplate/testprint`, handleTestPrint); + app.post(`${basePath}/labeltemplate/:id/testprint`, handleTestPrint); + + // #endregion } /**