feat: INITIAL COMMIT
This commit is contained in:
234
src/render.js
Normal file
234
src/render.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user