148 lines
4.6 KiB
JavaScript
148 lines
4.6 KiB
JavaScript
import zlib from 'node:zlib';
|
||
import { optimizeImage } from 'wasm-image-optimization';
|
||
import { renderLabel } from 'react-canvas-label-editor';
|
||
|
||
const THUMB_W = 150;
|
||
const THUMB_H = 80;
|
||
|
||
const CRC_TABLE = (() => {
|
||
const table = new Uint32Array(256);
|
||
for (let n = 0; n < 256; n++) {
|
||
let c = n;
|
||
for (let k = 0; k < 8; k++) {
|
||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||
}
|
||
table[n] = c;
|
||
}
|
||
return table;
|
||
})();
|
||
|
||
function crc32(buf) {
|
||
let c = 0xffffffff;
|
||
for (let i = 0; i < buf.length; i++) {
|
||
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||
}
|
||
return (c ^ 0xffffffff) >>> 0;
|
||
}
|
||
|
||
function pngChunk(type, data) {
|
||
const b = Buffer.alloc(12 + data.length);
|
||
b.writeUInt32BE(data.length, 0);
|
||
b.write(type, 4, 4, 'ascii');
|
||
data.copy(b, 8);
|
||
b.writeUInt32BE(crc32(b.subarray(4, 8 + data.length)), 8 + data.length);
|
||
return b;
|
||
}
|
||
|
||
function encodeRgbaToPng(w, h, rgba) {
|
||
const raw = Buffer.alloc((w * 4 + 1) * h);
|
||
for (let y = 0; y < h; y++) {
|
||
raw[y * (w * 4 + 1)] = 0;
|
||
rgba.copy(raw, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
|
||
}
|
||
const idat = zlib.deflateSync(raw, { level: 9 });
|
||
const ihdr = Buffer.alloc(13);
|
||
ihdr.writeUInt32BE(w, 0);
|
||
ihdr.writeUInt32BE(h, 4);
|
||
ihdr[8] = 8;
|
||
ihdr[9] = 6; // RGBA
|
||
return Buffer.concat([
|
||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||
pngChunk('IHDR', ihdr),
|
||
pngChunk('IDAT', idat),
|
||
pngChunk('IEND', Buffer.alloc(0)),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Renders a 150×80 thumbnail for a label template.
|
||
*
|
||
* Workflow:
|
||
* 1. Render the label at its native pixel resolution via renderLabel().
|
||
* 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 {Array} opts.objects - Label editor objects array.
|
||
* @returns {Promise<Buffer>} PNG buffer.
|
||
*/
|
||
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 fit within 150×80, preserving aspect ratio.
|
||
// Transparent background for letterbox padding.
|
||
const scaled = await optimizeImage({
|
||
image: nativePng,
|
||
width: THUMB_W,
|
||
height: THUMB_H,
|
||
fit: 'contain',
|
||
format: 'raw',
|
||
});
|
||
|
||
const w = THUMB_W;
|
||
const h = THUMB_H;
|
||
const sw = scaled.width;
|
||
const sh = scaled.height;
|
||
const offsetX = Math.floor((w - sw) / 2);
|
||
const offsetY = Math.floor((h - sh) / 2);
|
||
const data = Buffer.alloc(w * h * 4);
|
||
for (let y = 0; y < sh; y++) {
|
||
const dy = y + offsetY;
|
||
if (dy < 0 || dy >= h) continue;
|
||
for (let x = 0; x < sw; x++) {
|
||
const dx = x + offsetX;
|
||
if (dx < 0 || dx >= w) continue;
|
||
const srcIdx = (y * sw + x) * 4;
|
||
const dstIdx = (dy * w + dx) * 4;
|
||
data[dstIdx] = scaled.data[srcIdx];
|
||
data[dstIdx + 1] = scaled.data[srcIdx + 1];
|
||
data[dstIdx + 2] = scaled.data[srcIdx + 2];
|
||
data[dstIdx + 3] = scaled.data[srcIdx + 3];
|
||
}
|
||
}
|
||
|
||
// 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 PNG via wasm-image-optimization.
|
||
const rawPng = encodeRgbaToPng(w, h, rgba);
|
||
const thumb = await optimizeImage({
|
||
image: rawPng,
|
||
format: 'png',
|
||
});
|
||
|
||
return Buffer.from(thumb.data);
|
||
}
|