diff --git a/.gitignore b/.gitignore index c13293f..4ea6047 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ .env data/ -logs/ \ No newline at end of file +logs/ +print_spool/ \ No newline at end of file diff --git a/docs/api/README.md b/docs/api/README.md index 9314a7f..42c0d36 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -43,6 +43,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/article/delete-image.md`](routes/article/delete-image.md) | `DELETE /article/{sku}/image/{fullHash}` | | [`routes/article/order-images.md`](routes/article/order-images.md) | `PUT /article/{sku}/images/order` | | [`routes/article/set-prices.md`](routes/article/set-prices.md) | `PUT /article/{sku}/prices` | +| [`routes/article/:sku/print.md`](routes/article/:sku/print.md) | `POST /article/{sku}/print` | | [`routes/article/supplier/list.md`](routes/article/supplier/list.md) | `GET /article/{sku}/supplier` | | [`routes/article/supplier/create.md`](routes/article/supplier/create.md) | `POST /article/{sku}/supplier` | | [`routes/article/supplier/order.md`](routes/article/supplier/order.md) | `PUT /article/{sku}/supplier/order` | @@ -103,6 +104,12 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/events.md`](routes/events.md) | `GET /events` | | [`routes/health.md`](routes/health.md) | `GET /health` | | [`routes/image/get.md`](routes/image/get.md) | `GET /image/{fullHash}` | +| [`routes/labeltemplate/list.md`](routes/labeltemplate/list.md) | `GET /labeltemplate` | +| [`routes/labeltemplate/create.md`](routes/labeltemplate/create.md) | `POST /labeltemplate` | +| [`routes/labeltemplate/delete.md`](routes/labeltemplate/delete.md) | `DELETE /labeltemplate/{id}` | +| [`routes/labeltemplate/get.md`](routes/labeltemplate/get.md) | `GET /labeltemplate/{id}` | +| [`routes/labeltemplate/update.md`](routes/labeltemplate/update.md) | `PATCH /labeltemplate/{id}` | +| [`routes/labeltemplate/update.md`](routes/labeltemplate/update.md) | `PUT /labeltemplate/{id}` | | [`routes/ledger.get.md`](routes/ledger.get.md) | `GET /ledger` | | [`routes/ledger.post.md`](routes/ledger.post.md) | `POST /ledger` | | [`routes/ledger/manifestarticle/:manifestarticleId.md`](routes/ledger/manifestarticle/:manifestarticleId.md) | `GET /ledger/manifestarticle/{manifestarticleId}` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index b16f1e5..9f0f06d 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -4624,6 +4624,415 @@ } } }, + "/labeltemplate": { + "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. `content.objects` is omitted to reduce payload size.", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "labeltemplate/list.md", + "responses": { + "200": { + "description": "List of label templates", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "labeltemplates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelTemplate" + } + } + } + } + } + } + }, + "400": { + "description": "No company selected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "tags": [ + "LabelTemplate" + ], + "summary": "Create label template", + "description": "Creates a new ZPL label template for the selected company.\nA 50×50 1-bit PNG thumbnail is automatically rendered from the content\nand stored with the template.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "labeltemplate/create.md", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabelTemplateCreateInput" + } + } + } + }, + "responses": { + "201": { + "description": "Created label template", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "labeltemplate": { + "$ref": "#/components/schemas/LabelTemplate" + } + } + } + } + } + }, + "400": { + "description": "Invalid input or missing fields", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/labeltemplate/{id}": { + "get": { + "tags": [ + "LabelTemplate" + ], + "summary": "Get label template by ID", + "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", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Label template ID" + } + ], + "responses": { + "200": { + "description": "Label template details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "labeltemplate": { + "$ref": "#/components/schemas/LabelTemplate" + } + } + } + } + } + }, + "400": { + "description": "Invalid ID or no company selected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Label template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "LabelTemplate" + ], + "summary": "Update label template (partial)", + "description": "Updates specified fields of an existing label template.\nWhen `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "labeltemplate/update.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Label template ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabelTemplateUpdateInput" + } + } + } + }, + "responses": { + "200": { + "description": "Updated label template", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "labeltemplate": { + "$ref": "#/components/schemas/LabelTemplate" + } + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Label template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "tags": [ + "LabelTemplate" + ], + "summary": "Update label template", + "description": "Replaces an existing label template.\nWhen `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "labeltemplate/update.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Label template ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabelTemplateUpdateInput" + } + } + } + }, + "responses": { + "200": { + "description": "Updated label template", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "labeltemplate": { + "$ref": "#/components/schemas/LabelTemplate" + } + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Label template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "LabelTemplate" + ], + "summary": "Delete label template", + "description": "Deletes a label template for the authenticated user's selected company.", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "labeltemplate/delete.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Label template ID" + } + ], + "responses": { + "204": { + "description": "Label template deleted" + }, + "400": { + "description": "Invalid ID or no company selected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Label template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/health": { "get": { "tags": [ @@ -11200,6 +11609,70 @@ } } }, + "/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" + } + } + } + }, "/auth/selected-company": { "put": { "tags": [ diff --git a/docs/api/routes/article/:sku/print.md b/docs/api/routes/article/:sku/print.md new file mode 100644 index 0000000..dbcd39b --- /dev/null +++ b/docs/api/routes/article/:sku/print.md @@ -0,0 +1,39 @@ +# `POST /article/{sku}/print` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Print article label + +Prints a label for the article using a label template. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `sku` | yes | string | | + +## Body (JSON) + +```json +{ + "templateId": 1, + "printerId": 1, + "quantity": 1 +} +``` + +## Response `200` + +Print job dispatched + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid parameters | +| 404 | Printer, Template or Article not found | +| 500 | Server error | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/labeltemplate/create.md b/docs/api/routes/labeltemplate/create.md index 02b037f..5776a23 100644 --- a/docs/api/routes/labeltemplate/create.md +++ b/docs/api/routes/labeltemplate/create.md @@ -5,54 +5,26 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). Create label template Creates a new ZPL label template for the selected company. -A 50×50 1-bit PNG thumbnail is automatically rendered from the content and stored with the template. +A 50×50 1-bit PNG thumbnail is automatically rendered from the content +and stored with the template. + ## Body (JSON) ```json -{ - "name": "…", - "width": 448, - "height": 200, - "dpi": 203, - "type": "zpl", - "content": { - "objects": [] - } -} +{} ``` -| Field | Required | Type | Description | -|-------|----------|------|-------------| -| `name` | yes | string | Template name | -| `width` | yes | integer | Canvas width in pixels | -| `height` | yes | integer | Canvas height in pixels | -| `dpi` | yes | integer | Printer DPI — `203` or `300` | -| `type` | yes | string | Template type — `"zpl"` | -| `content` | yes | object | Label editor JSON content | - ## Response `201` Created label template ```json { - "labeltemplate": { - "id": 1, - "company_id": 1, - "name": "…", - "width": 448, - "height": 200, - "dpi": 203, - "type": "zpl", - "content": {}, - "thumb": "…" - } + "labeltemplate": {} } ``` -`thumb` is a base64-encoded 50×50 1-bit PNG string, or `null` if rendering failed. - ## Errors | Status | Description | diff --git a/docs/api/routes/labeltemplate/get.md b/docs/api/routes/labeltemplate/get.md index e93de87..73f1c4a 100644 --- a/docs/api/routes/labeltemplate/get.md +++ b/docs/api/routes/labeltemplate/get.md @@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). Get label template by ID -Retrieves a single label template for the authenticated user's selected company. The `thumb` field is always included inline as a base64-encoded 50×50 1-bit PNG string (`null` if not yet rendered). +Retrieves a single label template for the authenticated user's selected company. ## Path parameters @@ -18,17 +18,7 @@ Label template details ```json { - "labeltemplate": { - "id": 1, - "company_id": 1, - "name": "…", - "width": 448, - "height": 200, - "dpi": 203, - "type": "zpl", - "content": {}, - "thumb": "…" - } + "labeltemplate": {} } ``` diff --git a/docs/api/routes/labeltemplate/list.md b/docs/api/routes/labeltemplate/list.md index e1549ce..22585d7 100644 --- a/docs/api/routes/labeltemplate/list.md +++ b/docs/api/routes/labeltemplate/list.md @@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). List label templates -Lists all ZPL label templates for the selected company. The `thumb` field is always included inline as a base64-encoded 50×50 1-bit PNG string (`null` if not yet rendered). +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. ## Response `200` @@ -13,17 +13,7 @@ List of label templates ```json { "labeltemplates": [ - { - "id": 1, - "company_id": 1, - "name": "…", - "width": 448, - "height": 200, - "dpi": 203, - "type": "zpl", - "content": {}, - "thumb": "…" - } + {} ] } ``` diff --git a/docs/api/routes/labeltemplate/update.md b/docs/api/routes/labeltemplate/update.md index 69015da..feeb222 100644 --- a/docs/api/routes/labeltemplate/update.md +++ b/docs/api/routes/labeltemplate/update.md @@ -1,13 +1,12 @@ -# `PATCH /labeltemplate/{id}` +# `PUT /labeltemplate/{id}` Protected. Requires a valid session cookie (`credentials: 'include'`). -Update label template (partial) +Update label template -Updates specified fields of an existing label template for the selected company. -When `content`, `width`, or `height` is provided, the thumbnail is automatically re-rendered. +Replaces an existing label template. +When `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered. -Also available as `PUT /labeltemplate/{id}`. ## Path parameters @@ -17,47 +16,17 @@ Also available as `PUT /labeltemplate/{id}`. ## Body (JSON) -All fields are optional. - ```json -{ - "name": "…", - "width": 448, - "height": 200, - "dpi": 203, - "type": "zpl", - "content": { - "objects": [] - } -} +{} ``` -| Field | Type | Description | -|-------|------|-------------| -| `name` | string | Template name | -| `width` | integer | Canvas width in pixels | -| `height` | integer | Canvas height in pixels | -| `dpi` | integer | Printer DPI — `203` or `300` | -| `type` | string | Template type — `"zpl"` | -| `content` | object | Label editor JSON content | - ## Response `200` Updated label template ```json { - "labeltemplate": { - "id": 1, - "company_id": 1, - "name": "…", - "width": 448, - "height": 200, - "dpi": 203, - "type": "zpl", - "content": {}, - "thumb": "…" - } + "labeltemplate": {} } ``` diff --git a/src/api/routes/article.js b/src/api/routes/article.js index c16e08d..1d7ccf7 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -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 }); + } + }); } diff --git a/src/api/routes/labeltemplate.js b/src/api/routes/labeltemplate.js index 8b058aa..5dfc3b1 100644 --- a/src/api/routes/labeltemplate.js +++ b/src/api/routes/labeltemplate.js @@ -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 diff --git a/src/services/labelThumb.js b/src/services/labelThumb.js index 071bba2..413857e 100644 --- a/src/services/labelThumb.js +++ b/src/services/labelThumb.js @@ -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} 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;