import { promisify } from 'node:util' import child_process from 'node:child_process' import { existsSync, mkdirSync } from 'node:fs' import { writeFile } from 'node:fs/promises' import { jsPDF } from 'jspdf' // Dedicated FBA-relabel product labels (sales-order edit page). // // Unlike /api/print/product (fixed per-printer layouts), this renders ONE // dynamic layout that adapts to any label size the user picks: // - CODE128 barcode of C_OrderLine.printLabelCode across the full width // - article name (small) + the code in cleartext // - bottom hint "Label auf SKU: " so staff know which product gets it // One page per unit (line qty), all pages at exactly [w, h] mm. // // Body: { // pathName, fileName, // printer, // CUPS queue name (from CUST_Printer registry) // printOptions?, // CUPS options, defaults to '-o fit-to-page' // size: { w, h }, // label size in mm // downloadOnly?, // true → return base64 PDF, skip the printer // labels: [{ code, product, sku, qty, barcode (PNG data URL) }] // } // Response: { status, content (base64), printResult?, printOk? } const SAFE_PRINTER = /^[A-Za-z0-9_.-]+$/ const SAFE_OPTIONS = /^[A-Za-z0-9=_.\- ]*$/ const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)) const truncateToWidth = (doc: any, text: string, maxWidth: number) => { const split = doc.splitTextToSize(String(text || ''), maxWidth) if (split.length <= 1) return split[0] || '' let first = String(split[0]) while (first.length > 1 && doc.getTextWidth(first + '…') > maxWidth) { first = first.slice(0, -1) } return first + '…' } const drawLabel = (doc: any, label: any, w: number, h: number) => { const m = Math.max(1.5, Math.min(w, h) * 0.04) const cw = w - 2 * m const ch = h - 2 * m // Barcode band: ~48% of the content height, capped so very tall labels // (e.g. 102×152) don't stretch the bars beyond scannable proportions. // Inset horizontally so the bars keep a proper quiet zone (≥ ~3 mm white // on both sides) — required for reliable CODE128 scanning. const bcQuiet = Math.max(3 - m, cw * 0.04) const bcW = cw - 2 * bcQuiet const bcH = Math.min(ch * 0.48, bcW * 0.45) doc.addImage(label.barcode, 'image/png', m + bcQuiet, m, bcW, bcH) const textZoneTop = m + bcH const textZone = h - m - textZoneTop const nameFs = clamp(ch * 0.28, 5, 9) const codeFs = clamp(ch * 0.42, 7, 16) const hintFs = clamp(ch * 0.24, 5, 8) // Code cleartext directly below the barcode (like Amazon's FNSKU labels) doc.setFontSize(codeFs) doc.setFont('helvetica', 'bold') doc.text(truncateToWidth(doc, label.code, cw), w / 2, textZoneTop + textZone * 0.26, { align: 'center' }) // Article name (small, one line) — left-aligned on the content margin so it // starts flush with the "NEU" line below it. doc.setFontSize(nameFs) doc.setFont('helvetica', 'normal') doc.text(truncateToWidth(doc, label.product, cw), m, textZoneTop + textZone * 0.60, { align: 'left' }) // Bottom line: item condition left + placement hint right. // "Neu" is an Amazon FNSKU-label requirement (barcode + product name + // condition) — this endpoint only prints FBA relabel labels, so it is fixed. const bottomY = textZoneTop + textZone * 0.92 doc.setFontSize(hintFs) doc.setFont('helvetica', 'bold') doc.text('NEU', m, bottomY) const neuW = doc.getTextWidth('NEU') doc.setFont('helvetica', 'normal') doc.text(truncateToWidth(doc, `Label auf SKU: ${label.sku || '-'}`, cw - neuW - 2), w - m, bottomY, { align: 'right' }) } export default defineEventHandler(async (event) => { let data: any = {} const exec = promisify(child_process.exec) const body = await readBody(event) const pathName = String(body.pathName || 'relabel-labels').replace(/[^A-Za-z0-9/_.-]/g, '_').replace(/\.\./g, '_') const fileName = String(body.fileName || 'relabel-labels.pdf').replace(/[^A-Za-z0-9_.-]/g, '_') const filePath = `/root/storage/${pathName}` const w = clamp(Number(body?.size?.w) || 57, 20, 300) const h = clamp(Number(body?.size?.h) || 32, 20, 300) const orientation = w >= h ? 'l' : 'p' const downloadOnly = body.downloadOnly === true const labels = (body.labels || []).filter((l: any) => l?.barcode && String(l?.code || '').trim()) if (!labels.length) { data['status'] = 400 data['message'] = 'No printable labels (missing codes or barcodes).' return data } const doc = new jsPDF({ orientation, unit: 'mm', format: [w, h], putOnlyUsedFonts: true, compress: true }) // jsPDF starts with one page already open, so the very first newPage() must // not add another one. let first = true const newPage = () => { if (!first) doc.addPage([w, h], orientation) first = false } for (let idx = 0; idx < labels.length; idx++) { const label = labels[idx] // ONE blank label between positions (never before the first, never after // the last) so staff can split the printed strip into per-article stacks. if (idx > 0) newPage() const qty = Math.max(1, Number(label.qty || 1)) for (let i = 0; i < qty; i++) { newPage() drawLabel(doc, label, w, h) } } const fileContent = doc.output('dataurlstring')?.replace('data:application/pdf;filename=generated.pdf;base64,', '') data['status'] = 200 data['content'] = fileContent data['name'] = fileName if (!downloadOnly) { // Whitelist the CUPS queue/options before shell interpolation (same rules // as /api/print/picklist) and report the TRUE outcome — a non-zero `lp` // exit means the labels did NOT print. const printer = SAFE_PRINTER.test(body.printer || '') ? body.printer : 'default' const printOptions = SAFE_OPTIONS.test(body.printOptions ?? '') && body.printOptions ? body.printOptions : '-o fit-to-page' const result: any = { ok: false, printer, stdout: '', stderr: '', error: '' } try { if (!existsSync(filePath)) { mkdirSync(filePath, { recursive: true }) } await writeFile(`${filePath}/${fileName}`, Buffer.from(fileContent, 'base64')) const { stdout, stderr } = await exec(`lp -d ${printer} ${printOptions} ${filePath}/${fileName}`) result.stdout = stdout || '' result.stderr = stderr || '' result.ok = true } catch (err: any) { result.error = err?.stderr || err?.message || 'Error when printing via server side' result.stderr = err?.stderr || '' result.ok = false } data['printResult'] = result data['printOk'] = result.ok } return data })