feat: INITIAL COMMIT

This commit is contained in:
2026-05-19 15:32:05 +02:00
commit 537b594e0d
13 changed files with 1446 additions and 0 deletions

234
src/render.js Normal file
View File

@ -0,0 +1,234 @@
'use strict';
const bwipjs = require('@bwip-js/node');
const sharp = require('sharp');
const { decodeInput } = require('./input');
const { normalizeBcid } = require('./symbologies');
const DEFAULT_SIZE = 256;
const DEFAULT_MARGIN = 0;
const DEFAULT_SCALE = 4;
const MAX_SIZE = Number.parseInt(process.env.MAX_CODE_SIZE || '4096', 10);
const MAX_MARGIN = Number.parseInt(process.env.MAX_MARGIN || '2000', 10);
const MAX_OUTPUT_PIXELS = Number.parseInt(process.env.MAX_OUTPUT_PIXELS || '67108864', 10); // 8192 * 8192
function asSingle(value) {
return Array.isArray(value) ? value[0] : value;
}
function parseInteger(query, names, fallback, min, max) {
const keys = Array.isArray(names) ? names : [names];
const raw = keys.map((key) => asSingle(query[key])).find((value) => value !== undefined && value !== '');
if (raw === undefined) {
return fallback;
}
const value = Number.parseInt(String(raw), 10);
if (!Number.isFinite(value) || String(raw).trim() === '') {
throw new Error(`${keys[0]} must be an integer.`);
}
if (value < min || value > max) {
throw new Error(`${keys[0]} must be between ${min} and ${max}.`);
}
return value;
}
function parseBoolean(query, name, fallback = false) {
const raw = asSingle(query[name]);
if (raw === undefined || raw === '') {
return fallback;
}
const value = String(raw).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(value)) {
return true;
}
if (['0', 'false', 'no', 'off'].includes(value)) {
return false;
}
throw new Error(`${name} must be true/false or 1/0.`);
}
function normalizeColor(raw, fallback) {
const value = String(raw || fallback).trim().replace(/^#/, '');
if (!/^[0-9a-fA-F]{6}$/.test(value)) {
throw new Error('Colors must be 6-digit hex values, for example 000000 or ffffff.');
}
return value.toLowerCase();
}
function cssColor(hex) {
return `#${hex}`;
}
function normalizeRotate(raw) {
const value = String(raw || 'N').trim().toUpperCase();
if (!['N', 'R', 'L', 'I'].includes(value)) {
throw new Error('rotate must be one of N, R, L, or I.');
}
return value;
}
function normalizeTextAlign(raw) {
const value = String(raw || 'center').trim().toLowerCase();
if (!['left', 'center', 'right', 'justify'].includes(value)) {
throw new Error('textalign must be left, center, right, or justify.');
}
return value;
}
function applyOptionalBwippOptions(query, options) {
const stringOptions = ['eclevel', 'mode', 'version', 'symbolversion', 'primary'];
const numericOptions = ['columns', 'rows', 'layers', 'securitylevel'];
const booleanOptions = ['parse', 'parsefnc', 'guardwhitespace', 'dotty'];
for (const key of stringOptions) {
const raw = asSingle(query[key]);
if (raw !== undefined && raw !== '') {
options[key] = String(raw).trim();
}
}
for (const key of numericOptions) {
const raw = asSingle(query[key]);
if (raw !== undefined && raw !== '') {
const parsed = Number.parseInt(String(raw), 10);
if (!Number.isFinite(parsed)) {
throw new Error(`${key} must be an integer.`);
}
options[key] = parsed;
}
}
for (const key of booleanOptions) {
const raw = asSingle(query[key]);
if (raw !== undefined && raw !== '') {
options[key] = parseBoolean(query, key, false);
}
}
}
function normalizeRequest(query) {
const type = normalizeBcid(asSingle(query.type) || asSingle(query.bcid) || 'qrcode');
const input = String(asSingle(query.input) || asSingle(query.format) || 'text').trim().toLowerCase();
const data = asSingle(query.data) ?? asSingle(query.text) ?? '';
const size = parseInteger(query, ['size', 'codeSize'], DEFAULT_SIZE, 16, MAX_SIZE);
const margin = parseInteger(query, 'margin', DEFAULT_MARGIN, 0, MAX_MARGIN);
const scale = parseInteger(query, 'scale', DEFAULT_SCALE, 1, 20);
const rotate = normalizeRotate(asSingle(query.rotate));
const includetext = parseBoolean(query, 'includetext', false);
const textalign = normalizeTextAlign(asSingle(query.textalign) || asSingle(query.textxalign));
const fg = normalizeColor(asSingle(query.fg) || asSingle(query.foreground) || asSingle(query.barcolor), '000000');
const bg = normalizeColor(asSingle(query.bg) || asSingle(query.background) || asSingle(query.backgroundcolor), 'ffffff');
const heightmmRaw = asSingle(query.heightmm) || asSingle(query.height);
const heightmm = heightmmRaw === undefined || heightmmRaw === ''
? undefined
: parseInteger(query, ['heightmm', 'height'], 25, 1, 200);
const outputSize = size + margin * 2;
if (outputSize * outputSize > MAX_OUTPUT_PIXELS) {
throw new Error(`Output image is too large. Current limit is ${MAX_OUTPUT_PIXELS} pixels.`);
}
return {
type,
input,
data: String(data),
size,
margin,
scale,
rotate,
includetext,
textalign,
fg,
bg,
heightmm,
outputSize
};
}
async function trimBarcodeWhitespace(png, background) {
try {
return await sharp(png)
.flatten({ background: cssColor(background) })
.trim({ background: cssColor(background), threshold: 1 })
.png({ compressionLevel: 9 })
.toBuffer();
} catch {
return png;
}
}
async function renderCodePng(query) {
const normalized = normalizeRequest(query);
const decoded = decodeInput(normalized.data, normalized.input);
const bwipOptions = {
bcid: normalized.type,
text: decoded.text,
scale: normalized.scale,
rotate: normalized.rotate,
padding: 0,
backgroundcolor: normalized.bg,
barcolor: normalized.fg,
includetext: normalized.includetext,
textxalign: normalized.textalign
};
if (decoded.binarytext) {
bwipOptions.binarytext = true;
}
if (normalized.heightmm !== undefined) {
bwipOptions.height = normalized.heightmm;
}
applyOptionalBwippOptions(query, bwipOptions);
const rawPng = await bwipjs.toBuffer(bwipOptions);
const trimmedPng = await trimBarcodeWhitespace(rawPng, normalized.bg);
const fittedCode = await sharp(trimmedPng)
.flatten({ background: cssColor(normalized.bg) })
.resize({
width: normalized.size,
height: normalized.size,
fit: 'contain',
background: cssColor(normalized.bg),
kernel: sharp.kernel.nearest
})
.png({ compressionLevel: 9 })
.toBuffer();
const png = normalized.margin === 0
? fittedCode
: await sharp({
create: {
width: normalized.outputSize,
height: normalized.outputSize,
channels: 3,
background: cssColor(normalized.bg)
}
})
.composite([{ input: fittedCode, left: normalized.margin, top: normalized.margin }])
.png({ compressionLevel: 9 })
.toBuffer();
return {
png,
normalized,
bwipOptions
};
}
module.exports = {
renderCodePng,
normalizeRequest,
parseBoolean,
parseInteger,
normalizeColor
};