feat: INITIAL COMMIT
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
.env
|
||||
coverage
|
||||
*.zip
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY --chown=node:node src ./src
|
||||
COPY --chown=node:node public ./public
|
||||
|
||||
USER node
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
44
README.md
Normal file
44
README.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Code Canvas Generator
|
||||
|
||||
Self-hosted barcode / QR generator with a browser canvas preview, query-parameter state, PNG download, and copyable image/page URLs.
|
||||
|
||||

|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Open `http://localhost:8080`.
|
||||
|
||||
## Run with Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open `http://localhost:8080`.
|
||||
|
||||
## Main query parameters
|
||||
|
||||
- `type`: bwip-js encoder id, for example `qrcode`, `azteccode`, `datamatrix`, `pdf417`, `code128`, `ean13`, `gs1-128`.
|
||||
- `input`: `text`, `ascii`, `latin1`, `base64`, `base64url`, `hex`, `binary`, `urlencoded`, or `json`.
|
||||
- `data`: payload in the selected input format.
|
||||
- `size`: inner code area in pixels. Default: `256`.
|
||||
- `margin`: explicit white padding around the code in pixels. Default: `0`.
|
||||
- `scale`: bwip-js module render scale before final fitting. Default: `4`.
|
||||
- `rotate`: `N`, `R`, `L`, or `I`.
|
||||
- `fg`, `bg`: 6-digit hex colors.
|
||||
- `includetext`: `true` or `false`.
|
||||
|
||||
The final PNG dimensions are `size + 2 * margin` square pixels.
|
||||
|
||||
## Example direct PNG URL
|
||||
|
||||
```text
|
||||
http://localhost:8080/api/code.png?type=qrcode&input=text&data=https%3A%2F%2Fexample.com&size=256&margin=32&scale=4&rotate=N&fg=000000&bg=ffffff&includetext=false&textalign=center
|
||||
```
|
||||
|
||||

|
||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@ -0,0 +1,12 @@
|
||||
services:
|
||||
code-canvas-generator:
|
||||
build: .
|
||||
container_name: code-canvas-generator
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
PORT: "8080"
|
||||
MAX_DATA_BYTES: "8192"
|
||||
MAX_CODE_SIZE: "4096"
|
||||
MAX_MARGIN: "2000"
|
||||
restart: unless-stopped
|
||||
BIN
docs/assets/code-canvas-generator.png
Normal file
BIN
docs/assets/code-canvas-generator.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
20
package.json
Normal file
20
package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "code-canvas-generator",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Self-hosted canvas barcode and QR code generator with query-parameter share URLs.",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"check": "node --check src/server.js && node --check src/input.js && node --check src/render.js && node --check src/symbologies.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bwip-js/node": "^4.10.1",
|
||||
"express": "^5.2.1",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
313
public/app.js
Normal file
313
public/app.js
Normal file
@ -0,0 +1,313 @@
|
||||
'use strict';
|
||||
|
||||
const els = {
|
||||
form: document.getElementById('controls'),
|
||||
type: document.getElementById('type'),
|
||||
typeList: document.getElementById('type-list'),
|
||||
input: document.getElementById('input'),
|
||||
data: document.getElementById('data'),
|
||||
size: document.getElementById('size'),
|
||||
margin: document.getElementById('margin'),
|
||||
scale: document.getElementById('scale'),
|
||||
rotate: document.getElementById('rotate'),
|
||||
fg: document.getElementById('fg'),
|
||||
bg: document.getElementById('bg'),
|
||||
includetext: document.getElementById('includetext'),
|
||||
textalign: document.getElementById('textalign'),
|
||||
heightmm: document.getElementById('heightmm'),
|
||||
eclevel: document.getElementById('eclevel'),
|
||||
parsefnc: document.getElementById('parsefnc'),
|
||||
parse: document.getElementById('parse'),
|
||||
sample: document.getElementById('sample'),
|
||||
copyPage: document.getElementById('copy-page'),
|
||||
copyImage: document.getElementById('copy-image'),
|
||||
download: document.getElementById('download'),
|
||||
canvas: document.getElementById('canvas'),
|
||||
status: document.getElementById('status'),
|
||||
meta: document.getElementById('meta'),
|
||||
imageUrl: document.getElementById('image-url')
|
||||
};
|
||||
|
||||
const DEFAULTS = {
|
||||
type: 'qrcode',
|
||||
input: 'text',
|
||||
data: 'https://example.com/?hello=world',
|
||||
size: '256',
|
||||
margin: '0',
|
||||
scale: '4',
|
||||
rotate: 'N',
|
||||
fg: '#000000',
|
||||
bg: '#ffffff',
|
||||
includetext: 'false',
|
||||
textalign: 'center'
|
||||
};
|
||||
|
||||
let renderGeneration = 0;
|
||||
let popularTypes = [];
|
||||
let debounceTimer = null;
|
||||
|
||||
main().catch((error) => {
|
||||
setStatus(`Startup error: ${error.message || error}`, true);
|
||||
});
|
||||
|
||||
async function main() {
|
||||
await loadTypes();
|
||||
hydrateFromQuery();
|
||||
bindEvents();
|
||||
await render();
|
||||
}
|
||||
|
||||
async function loadTypes() {
|
||||
const response = await fetch('/api/types');
|
||||
if (!response.ok) {
|
||||
throw new Error('Could not load supported code types.');
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
popularTypes = payload.popular || [];
|
||||
|
||||
els.typeList.innerHTML = '';
|
||||
for (const item of popularTypes) {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.label = `${item.label} · ${item.kind}`;
|
||||
els.typeList.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
els.form.addEventListener('input', scheduleRender);
|
||||
els.form.addEventListener('change', scheduleRender);
|
||||
|
||||
els.sample.addEventListener('click', async () => {
|
||||
const type = els.type.value.trim() || DEFAULTS.type;
|
||||
try {
|
||||
const response = await fetch(`/api/sample/${encodeURIComponent(type)}`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'No sample available.');
|
||||
}
|
||||
els.data.value = payload.data;
|
||||
scheduleRender();
|
||||
} catch (error) {
|
||||
setStatus(error.message || String(error), true);
|
||||
}
|
||||
});
|
||||
|
||||
els.copyPage.addEventListener('click', async () => {
|
||||
const url = buildPageUrl();
|
||||
await copyText(url, 'Page URL copied');
|
||||
});
|
||||
|
||||
els.copyImage.addEventListener('click', async () => {
|
||||
const url = buildAbsoluteImageUrl();
|
||||
await copyText(url, 'Image URL copied');
|
||||
});
|
||||
|
||||
els.download.addEventListener('click', () => {
|
||||
const filename = filenameForCurrentCode();
|
||||
els.canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
setStatus('Could not create PNG blob.', true);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
setStatus('PNG downloaded');
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
function hydrateFromQuery() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
setValue(els.type, params.get('type') || params.get('bcid') || DEFAULTS.type);
|
||||
setValue(els.input, params.get('input') || params.get('format') || DEFAULTS.input);
|
||||
setValue(els.data, params.get('data') ?? params.get('text') ?? DEFAULTS.data);
|
||||
setValue(els.size, params.get('size') || params.get('codeSize') || DEFAULTS.size);
|
||||
setValue(els.margin, params.get('margin') || DEFAULTS.margin);
|
||||
setValue(els.scale, params.get('scale') || DEFAULTS.scale);
|
||||
setValue(els.rotate, params.get('rotate') || DEFAULTS.rotate);
|
||||
setValue(els.fg, normalizeColorForInput(params.get('fg') || params.get('foreground') || DEFAULTS.fg));
|
||||
setValue(els.bg, normalizeColorForInput(params.get('bg') || params.get('background') || DEFAULTS.bg));
|
||||
setValue(els.includetext, params.get('includetext') || DEFAULTS.includetext);
|
||||
setValue(els.textalign, params.get('textalign') || DEFAULTS.textalign);
|
||||
setValue(els.heightmm, params.get('heightmm') || params.get('height') || '');
|
||||
setValue(els.eclevel, params.get('eclevel') || '');
|
||||
setValue(els.parsefnc, params.get('parsefnc') || '');
|
||||
setValue(els.parse, params.get('parse') || '');
|
||||
}
|
||||
|
||||
function setValue(element, value) {
|
||||
element.value = value;
|
||||
}
|
||||
|
||||
function normalizeColorForInput(value) {
|
||||
const stripped = String(value || '').trim().replace(/^#/, '');
|
||||
return /^[0-9a-fA-F]{6}$/.test(stripped) ? `#${stripped}` : value;
|
||||
}
|
||||
|
||||
function scheduleRender() {
|
||||
window.clearTimeout(debounceTimer);
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
render().catch((error) => setStatus(error.message || String(error), true));
|
||||
}, 120);
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const generation = ++renderGeneration;
|
||||
const params = buildParams();
|
||||
const imagePath = `/api/code.png?${params.toString()}`;
|
||||
const imageUrl = `${window.location.origin}${imagePath}`;
|
||||
|
||||
els.imageUrl.value = imageUrl;
|
||||
updateMeta();
|
||||
window.history.replaceState(null, '', buildPageUrl());
|
||||
setStatus('Rendering…');
|
||||
|
||||
const response = await fetch(imagePath, { headers: { Accept: 'image/png, application/json' } });
|
||||
if (generation !== renderGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await readError(response);
|
||||
setStatus(message, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const image = await blobToImage(blob);
|
||||
if (generation !== renderGeneration) {
|
||||
URL.revokeObjectURL(image.src);
|
||||
return;
|
||||
}
|
||||
|
||||
drawImageToCanvas(image);
|
||||
URL.revokeObjectURL(image.src);
|
||||
setStatus('Ready');
|
||||
}
|
||||
|
||||
function buildParams() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set('type', els.type.value.trim() || DEFAULTS.type);
|
||||
params.set('input', els.input.value || DEFAULTS.input);
|
||||
params.set('data', els.data.value);
|
||||
params.set('size', els.size.value || DEFAULTS.size);
|
||||
params.set('margin', els.margin.value || DEFAULTS.margin);
|
||||
params.set('scale', els.scale.value || DEFAULTS.scale);
|
||||
params.set('rotate', els.rotate.value || DEFAULTS.rotate);
|
||||
params.set('fg', stripHash(els.fg.value || DEFAULTS.fg));
|
||||
params.set('bg', stripHash(els.bg.value || DEFAULTS.bg));
|
||||
params.set('includetext', els.includetext.value || DEFAULTS.includetext);
|
||||
params.set('textalign', els.textalign.value || DEFAULTS.textalign);
|
||||
|
||||
setOptional(params, 'heightmm', els.heightmm.value);
|
||||
setOptional(params, 'eclevel', els.eclevel.value);
|
||||
setOptional(params, 'parsefnc', els.parsefnc.value);
|
||||
setOptional(params, 'parse', els.parse.value);
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function setOptional(params, key, value) {
|
||||
if (value !== undefined && String(value).trim() !== '') {
|
||||
params.set(key, String(value).trim());
|
||||
}
|
||||
}
|
||||
|
||||
function buildPageUrl() {
|
||||
return `${window.location.origin}${window.location.pathname}?${buildParams().toString()}`;
|
||||
}
|
||||
|
||||
function buildAbsoluteImageUrl() {
|
||||
return `${window.location.origin}/api/code.png?${buildParams().toString()}`;
|
||||
}
|
||||
|
||||
function stripHash(color) {
|
||||
return String(color || '').replace(/^#/, '');
|
||||
}
|
||||
|
||||
function updateMeta() {
|
||||
const size = Math.max(0, Number.parseInt(els.size.value || DEFAULTS.size, 10));
|
||||
const margin = Math.max(0, Number.parseInt(els.margin.value || DEFAULTS.margin, 10));
|
||||
const output = size + margin * 2;
|
||||
els.meta.textContent = `Output: ${output} × ${output} px`;
|
||||
}
|
||||
|
||||
function drawImageToCanvas(image) {
|
||||
const canvas = els.canvas;
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(image, 0, 0);
|
||||
}
|
||||
|
||||
function blobToImage(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Could not load generated image.'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function readError(response) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const payload = await response.json();
|
||||
return payload.error || `Render failed with HTTP ${response.status}`;
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function copyText(text, successMessage) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
setStatus(successMessage);
|
||||
} catch (error) {
|
||||
setStatus(`Copy failed: ${error.message || error}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-1000px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
textarea.remove();
|
||||
}
|
||||
|
||||
function filenameForCurrentCode() {
|
||||
const safeType = (els.type.value || DEFAULTS.type).replace(/[^a-z0-9_-]+/gi, '-').slice(0, 64);
|
||||
const size = els.size.value || DEFAULTS.size;
|
||||
const margin = els.margin.value || DEFAULTS.margin;
|
||||
return `${safeType}-${size}px-m${margin}.png`;
|
||||
}
|
||||
|
||||
function setStatus(message, error = false) {
|
||||
els.status.textContent = message;
|
||||
els.status.dataset.error = error ? 'true' : 'false';
|
||||
}
|
||||
171
public/index.html
Normal file
171
public/index.html
Normal file
@ -0,0 +1,171 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Code Canvas Generator</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="layout">
|
||||
<section class="panel controls" aria-labelledby="title">
|
||||
<div class="header-block">
|
||||
<h1 id="title">Code Canvas Generator</h1>
|
||||
<p>Self-hosted barcode and QR generator. All state is encoded as query parameters.</p>
|
||||
</div>
|
||||
|
||||
<form id="controls" autocomplete="off">
|
||||
<label>
|
||||
Code type / bwip-js id
|
||||
<input id="type" name="type" list="type-list" value="qrcode" spellcheck="false">
|
||||
<datalist id="type-list"></datalist>
|
||||
</label>
|
||||
|
||||
<div class="row two">
|
||||
<label>
|
||||
Input format
|
||||
<select id="input" name="input">
|
||||
<option value="text">Text / UTF-8</option>
|
||||
<option value="ascii">ASCII</option>
|
||||
<option value="latin1">Latin-1 binary string</option>
|
||||
<option value="base64">Base64 bytes</option>
|
||||
<option value="base64url">Base64URL bytes</option>
|
||||
<option value="hex">Hex dump</option>
|
||||
<option value="binary">Binary bits</option>
|
||||
<option value="urlencoded">URL-encoded text</option>
|
||||
<option value="json">JSON string/value</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Rotation
|
||||
<select id="rotate" name="rotate">
|
||||
<option value="N">Normal</option>
|
||||
<option value="R">Right 90°</option>
|
||||
<option value="L">Left 90°</option>
|
||||
<option value="I">Invert 180°</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Data
|
||||
<textarea id="data" name="data" rows="7" spellcheck="false">https://example.com/?hello=world</textarea>
|
||||
</label>
|
||||
|
||||
<div class="row three">
|
||||
<label>
|
||||
Code size px
|
||||
<input id="size" name="size" type="number" min="16" max="4096" step="1" value="256">
|
||||
</label>
|
||||
<label>
|
||||
Margin px
|
||||
<input id="margin" name="margin" type="number" min="0" max="2000" step="1" value="0">
|
||||
</label>
|
||||
<label>
|
||||
Engine scale
|
||||
<input id="scale" name="scale" type="number" min="1" max="20" step="1" value="4">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row two">
|
||||
<label>
|
||||
Foreground
|
||||
<input id="fg" name="fg" type="color" value="#000000">
|
||||
</label>
|
||||
<label>
|
||||
Background
|
||||
<input id="bg" name="bg" type="color" value="#ffffff">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Advanced options</summary>
|
||||
<div class="row three advanced-grid">
|
||||
<label>
|
||||
Human text
|
||||
<select id="includetext" name="includetext">
|
||||
<option value="false">Off</option>
|
||||
<option value="true">On</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Text align
|
||||
<select id="textalign" name="textalign">
|
||||
<option value="center">Center</option>
|
||||
<option value="left">Left</option>
|
||||
<option value="right">Right</option>
|
||||
<option value="justify">Justify</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Linear height mm
|
||||
<input id="heightmm" name="heightmm" type="number" min="1" max="200" step="1" placeholder="optional">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row three advanced-grid">
|
||||
<label>
|
||||
QR EC level
|
||||
<select id="eclevel" name="eclevel">
|
||||
<option value="">Default</option>
|
||||
<option value="L">L</option>
|
||||
<option value="M">M</option>
|
||||
<option value="Q">Q</option>
|
||||
<option value="H">H</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
parsefnc
|
||||
<select id="parsefnc" name="parsefnc">
|
||||
<option value="">Default</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
parse
|
||||
<select id="parse" name="parse">
|
||||
<option value="">Default</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" id="sample">Use sample for type</button>
|
||||
<button type="button" id="copy-page">Copy page URL</button>
|
||||
<button type="button" id="copy-image">Copy image URL</button>
|
||||
<button type="button" id="download">Download PNG</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel preview" aria-labelledby="preview-title">
|
||||
<div class="preview-header">
|
||||
<div>
|
||||
<h2 id="preview-title">Live preview</h2>
|
||||
<p id="meta">Output: 256 × 256 px</p>
|
||||
</div>
|
||||
<span id="status" role="status">Ready</span>
|
||||
</div>
|
||||
|
||||
<div class="canvas-shell">
|
||||
<canvas id="canvas" width="256" height="256" aria-label="Generated code preview"></canvas>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Generated image URL
|
||||
<input id="image-url" readonly spellcheck="false">
|
||||
</label>
|
||||
|
||||
<p class="note">
|
||||
Margin is explicit white padding around the generated symbol. With margin 200 and size 256, the final PNG is 656 × 656 px.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
264
public/style.css
Normal file
264
public/style.css
Normal file
@ -0,0 +1,264 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--panel-2: #0f141b;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--border: #30363d;
|
||||
--accent: #58a6ff;
|
||||
--danger: #ff7b72;
|
||||
--button: #238636;
|
||||
--button-hover: #2ea043;
|
||||
--input: #0d1117;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
background: radial-gradient(circle at top left, #1f2a44, var(--bg) 45%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(340px, 560px) minmax(320px, 1fr);
|
||||
gap: 24px;
|
||||
width: min(1440px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.controls,
|
||||
.preview {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.header-block h1,
|
||||
.preview h2 {
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.header-block p,
|
||||
.preview p,
|
||||
.note {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 11px;
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
min-height: 42px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.row.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.row.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
details {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.advanced-grid {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid color-mix(in srgb, var(--button) 65%, white);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
background: var(--button);
|
||||
color: white;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--button-hover);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 50%;
|
||||
min-height: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
color: var(--muted);
|
||||
background: var(--panel-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#status[data-error="true"] {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, var(--border));
|
||||
}
|
||||
|
||||
.canvas-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 420px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
background:
|
||||
linear-gradient(45deg, rgba(255,255,255,0.035) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(255,255,255,0.035) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.035) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.035) 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
image-rendering: pixelated;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
#image-url {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.layout {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.controls,
|
||||
.preview {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.row.two,
|
||||
.row.three,
|
||||
.actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
#status {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.canvas-shell {
|
||||
min-height: 300px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
173
src/input.js
Normal file
173
src/input.js
Normal file
@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
const DEFAULT_MAX_BYTES = Number.parseInt(process.env.MAX_DATA_BYTES || '8192', 10);
|
||||
|
||||
function byteLengthUtf8(text) {
|
||||
return Buffer.byteLength(text, 'utf8');
|
||||
}
|
||||
|
||||
function assertMaxBytes(bytes, maxBytes = DEFAULT_MAX_BYTES) {
|
||||
if (bytes > maxBytes) {
|
||||
throw new Error(`Input is too large. Max decoded payload is ${maxBytes} bytes.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAscii(text) {
|
||||
for (const char of text) {
|
||||
if (char.codePointAt(0) > 0x7f) {
|
||||
throw new Error('ASCII input may only contain characters from 0x00 to 0x7F.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertLatin1(text) {
|
||||
for (const char of text) {
|
||||
if (char.codePointAt(0) > 0xff) {
|
||||
throw new Error('Latin-1 input may only contain characters from 0x00 to 0xFF.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function strictBase64ToBuffer(raw, urlSafe = false) {
|
||||
const compact = String(raw || '').replace(/\s+/g, '');
|
||||
|
||||
if (!compact) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
const pattern = urlSafe
|
||||
? /^[A-Za-z0-9_-]*={0,2}$/
|
||||
: /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
|
||||
if (!pattern.test(compact) || compact.length % 4 === 1) {
|
||||
throw new Error(urlSafe ? 'Invalid Base64URL input.' : 'Invalid Base64 input.');
|
||||
}
|
||||
|
||||
const normalized = urlSafe
|
||||
? compact.replace(/-/g, '+').replace(/_/g, '/')
|
||||
: compact;
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||
|
||||
return Buffer.from(padded, 'base64');
|
||||
}
|
||||
|
||||
function hexToBuffer(raw) {
|
||||
const cleaned = String(raw || '')
|
||||
.replace(/0x/gi, '')
|
||||
.replace(/[\s:_-]/g, '');
|
||||
|
||||
if (!cleaned) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
if (!/^[0-9a-fA-F]+$/.test(cleaned) || cleaned.length % 2 !== 0) {
|
||||
throw new Error('Hex input must contain an even number of hex digits. Separators are allowed.');
|
||||
}
|
||||
|
||||
return Buffer.from(cleaned, 'hex');
|
||||
}
|
||||
|
||||
function binaryToBuffer(raw) {
|
||||
const cleaned = String(raw || '').replace(/[\s:_-]/g, '');
|
||||
|
||||
if (!cleaned) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
if (!/^[01]+$/.test(cleaned) || cleaned.length % 8 !== 0) {
|
||||
throw new Error('Binary input must contain only 0/1 bits and the bit count must be divisible by 8.');
|
||||
}
|
||||
|
||||
const bytes = [];
|
||||
for (let i = 0; i < cleaned.length; i += 8) {
|
||||
bytes.push(Number.parseInt(cleaned.slice(i, i + 8), 2));
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function decodeInput(rawData, inputFormat = 'text', maxBytes = DEFAULT_MAX_BYTES) {
|
||||
const format = String(inputFormat || 'text').trim().toLowerCase();
|
||||
const data = rawData == null ? '' : String(rawData);
|
||||
|
||||
switch (format) {
|
||||
case 'text':
|
||||
case 'utf8': {
|
||||
assertMaxBytes(byteLengthUtf8(data), maxBytes);
|
||||
return { text: data, binarytext: false, format };
|
||||
}
|
||||
|
||||
case 'ascii': {
|
||||
assertAscii(data);
|
||||
assertMaxBytes(Buffer.byteLength(data, 'ascii'), maxBytes);
|
||||
return { text: data, binarytext: false, format };
|
||||
}
|
||||
|
||||
case 'latin1':
|
||||
case 'binary-string': {
|
||||
assertLatin1(data);
|
||||
assertMaxBytes(Buffer.byteLength(data, 'latin1'), maxBytes);
|
||||
return { text: Buffer.from(data, 'latin1').toString('latin1'), binarytext: true, format: 'latin1' };
|
||||
}
|
||||
|
||||
case 'base64': {
|
||||
const bytes = strictBase64ToBuffer(data, false);
|
||||
assertMaxBytes(bytes.length, maxBytes);
|
||||
return { text: bytes.toString('latin1'), binarytext: true, format };
|
||||
}
|
||||
|
||||
case 'base64url': {
|
||||
const bytes = strictBase64ToBuffer(data, true);
|
||||
assertMaxBytes(bytes.length, maxBytes);
|
||||
return { text: bytes.toString('latin1'), binarytext: true, format };
|
||||
}
|
||||
|
||||
case 'hex':
|
||||
case 'hexdump': {
|
||||
const bytes = hexToBuffer(data);
|
||||
assertMaxBytes(bytes.length, maxBytes);
|
||||
return { text: bytes.toString('latin1'), binarytext: true, format: 'hex' };
|
||||
}
|
||||
|
||||
case 'binary':
|
||||
case 'bits': {
|
||||
const bytes = binaryToBuffer(data);
|
||||
assertMaxBytes(bytes.length, maxBytes);
|
||||
return { text: bytes.toString('latin1'), binarytext: true, format: 'binary' };
|
||||
}
|
||||
|
||||
case 'urlencoded':
|
||||
case 'url': {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeURIComponent(data.replace(/\+/g, ' '));
|
||||
} catch {
|
||||
throw new Error('Invalid URL-encoded input.');
|
||||
}
|
||||
assertMaxBytes(byteLengthUtf8(decoded), maxBytes);
|
||||
return { text: decoded, binarytext: false, format: 'urlencoded' };
|
||||
}
|
||||
|
||||
case 'json':
|
||||
case 'json-string': {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON input. Expected a JSON string or JSON value.');
|
||||
}
|
||||
const text = typeof parsed === 'string' ? parsed : JSON.stringify(parsed);
|
||||
assertMaxBytes(byteLengthUtf8(text), maxBytes);
|
||||
return { text, binarytext: false, format: 'json' };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error('Unsupported input format. Use text, ascii, latin1, base64, base64url, hex, binary, urlencoded, or json.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
decodeInput,
|
||||
strictBase64ToBuffer,
|
||||
hexToBuffer,
|
||||
binaryToBuffer
|
||||
};
|
||||
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
|
||||
};
|
||||
95
src/server.js
Normal file
95
src/server.js
Normal file
@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const express = require('express');
|
||||
const { renderCodePng } = require('./render');
|
||||
const { POPULAR_TYPES, sampleForType } = require('./symbologies');
|
||||
|
||||
const app = express();
|
||||
const port = Number.parseInt(process.env.PORT || '8080', 10);
|
||||
const publicDir = path.join(__dirname, '..', 'public');
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.set('query parser', 'simple');
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
"img-src 'self' blob:",
|
||||
"connect-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'none'",
|
||||
"frame-ancestors 'self'"
|
||||
].join('; '));
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.static(publicDir, {
|
||||
extensions: ['html'],
|
||||
maxAge: process.env.NODE_ENV === 'production' ? '1h' : 0
|
||||
}));
|
||||
|
||||
app.get('/api/types', (req, res) => {
|
||||
res.json({
|
||||
popular: POPULAR_TYPES,
|
||||
note: 'The UI lists popular barcode ids. The backend also accepts any safe bwip-js encoder id in the type query parameter.'
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/sample/:type', (req, res) => {
|
||||
try {
|
||||
res.json({ data: sampleForType(req.params.type) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: messageFromError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/code.png', async (req, res) => {
|
||||
try {
|
||||
const result = await renderCodePng(req.query);
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Code-Type', result.normalized.type);
|
||||
res.setHeader('X-Code-Size', String(result.normalized.size));
|
||||
res.setHeader('X-Code-Margin', String(result.normalized.margin));
|
||||
res.setHeader('X-Output-Size', String(result.normalized.outputSize));
|
||||
res.send(result.png);
|
||||
} catch (error) {
|
||||
const message = messageFromError(error);
|
||||
res.status(400);
|
||||
if (acceptsJson(req)) {
|
||||
res.json({ error: message });
|
||||
} else {
|
||||
res.type('text/plain').send(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/healthz', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).type('text/plain').send('Not found');
|
||||
});
|
||||
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`Code Canvas Generator listening on http://0.0.0.0:${port}`);
|
||||
});
|
||||
|
||||
function acceptsJson(req) {
|
||||
const accept = String(req.headers.accept || '');
|
||||
return accept.includes('application/json') || accept.includes('*/*');
|
||||
}
|
||||
|
||||
function messageFromError(error) {
|
||||
if (!error) {
|
||||
return 'Unknown error';
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
return error.message || String(error);
|
||||
}
|
||||
97
src/symbologies.js
Normal file
97
src/symbologies.js
Normal file
@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
const POPULAR_TYPES = [
|
||||
{ id: 'qrcode', label: 'QR Code', kind: '2D', sample: 'https://example.com/?hello=world' },
|
||||
{ id: 'gs1qrcode', label: 'GS1 QR Code', kind: '2D / GS1', sample: '(01)09501101530003(10)ABC123' },
|
||||
{ id: 'microqrcode', label: 'Micro QR Code', kind: '2D', sample: 'A12345' },
|
||||
{ id: 'rectangularmicroqrcode', label: 'Rectangular Micro QR Code', kind: '2D', sample: 'A12345' },
|
||||
{ id: 'azteccode', label: 'Aztec Code', kind: '2D', sample: 'Aztec payload 123' },
|
||||
{ id: 'azteccodecompact', label: 'Compact Aztec Code', kind: '2D', sample: 'Compact Aztec 123' },
|
||||
{ id: 'datamatrix', label: 'Data Matrix', kind: '2D', sample: 'Data Matrix payload 123' },
|
||||
{ id: 'datamatrixrectangular', label: 'Data Matrix Rectangular', kind: '2D', sample: 'RECT-1234567890' },
|
||||
{ id: 'gs1datamatrix', label: 'GS1 Data Matrix', kind: '2D / GS1', sample: '(01)09501101530003(17)261231(10)ABC123' },
|
||||
{ id: 'pdf417', label: 'PDF417', kind: 'Stacked', sample: 'PDF417 payload with more text 1234567890' },
|
||||
{ id: 'pdf417compact', label: 'Compact PDF417', kind: 'Stacked', sample: 'Compact PDF417 1234567890' },
|
||||
{ id: 'micropdf417', label: 'MicroPDF417', kind: 'Stacked', sample: 'MicroPDF417 123456' },
|
||||
{ id: 'maxicode', label: 'MaxiCode', kind: '2D', sample: 'MaxiCode payload 1234567890' },
|
||||
{ id: 'dotcode', label: 'DotCode', kind: '2D', sample: 'DotCode payload 1234567890' },
|
||||
{ id: 'hanxin', label: 'Han Xin Code', kind: '2D', sample: 'Han Xin payload 123' },
|
||||
{ id: 'ultracode', label: 'Ultracode', kind: '2D', sample: 'Ultracode payload 123' },
|
||||
{ id: 'code128', label: 'Code 128', kind: 'Linear', sample: 'CODE128-1234567890' },
|
||||
{ id: 'gs1-128', label: 'GS1-128', kind: 'Linear / GS1', sample: '(01)09501101530003(10)ABC123' },
|
||||
{ id: 'code39', label: 'Code 39', kind: 'Linear', sample: 'CODE39-123' },
|
||||
{ id: 'code39ext', label: 'Code 39 Extended', kind: 'Linear', sample: 'Code39 extended: abc-123' },
|
||||
{ id: 'code93', label: 'Code 93', kind: 'Linear', sample: 'CODE93-123' },
|
||||
{ id: 'code93ext', label: 'Code 93 Extended', kind: 'Linear', sample: 'Code93 extended: abc-123' },
|
||||
{ id: 'ean13', label: 'EAN-13', kind: 'Retail', sample: '5901234123457' },
|
||||
{ id: 'ean8', label: 'EAN-8', kind: 'Retail', sample: '96385074' },
|
||||
{ id: 'upca', label: 'UPC-A', kind: 'Retail', sample: '012345678905' },
|
||||
{ id: 'upce', label: 'UPC-E', kind: 'Retail', sample: '01234565' },
|
||||
{ id: 'isbn', label: 'ISBN', kind: 'Retail', sample: '9781565812314' },
|
||||
{ id: 'itf14', label: 'ITF-14', kind: 'Logistics', sample: '04601234567893' },
|
||||
{ id: 'interleaved2of5', label: 'Interleaved 2 of 5', kind: 'Linear', sample: '0123456789' },
|
||||
{ id: 'rationalizedCodabar', label: 'Codabar', kind: 'Linear', sample: 'A0123456789B' },
|
||||
{ id: 'msi', label: 'MSI Modified Plessey', kind: 'Linear', sample: '0123456789' },
|
||||
{ id: 'telepen', label: 'Telepen', kind: 'Linear', sample: 'ABC123xyz' },
|
||||
{ id: 'postnet', label: 'USPS POSTNET', kind: 'Postal', sample: '01234567890' },
|
||||
{ id: 'planet', label: 'USPS PLANET', kind: 'Postal', sample: '01234567890' },
|
||||
{ id: 'onecode', label: 'USPS Intelligent Mail', kind: 'Postal', sample: '01234567094987654321-01234567891' },
|
||||
{ id: 'royalmail', label: 'Royal Mail 4 State', kind: 'Postal', sample: 'LE28HS9Z' }
|
||||
];
|
||||
|
||||
const TYPE_ALIASES = Object.freeze({
|
||||
qr: 'qrcode',
|
||||
'qr-code': 'qrcode',
|
||||
qrcode: 'qrcode',
|
||||
aztec: 'azteccode',
|
||||
'aztec-code': 'azteccode',
|
||||
'compact-aztec': 'azteccodecompact',
|
||||
datamatrix: 'datamatrix',
|
||||
'data-matrix': 'datamatrix',
|
||||
dm: 'datamatrix',
|
||||
pdf: 'pdf417',
|
||||
pdf417: 'pdf417',
|
||||
'compact-pdf417': 'pdf417compact',
|
||||
code128: 'code128',
|
||||
'code-128': 'code128',
|
||||
code39: 'code39',
|
||||
'code-39': 'code39',
|
||||
ean13: 'ean13',
|
||||
'ean-13': 'ean13',
|
||||
ean8: 'ean8',
|
||||
'ean-8': 'ean8',
|
||||
upca: 'upca',
|
||||
'upc-a': 'upca',
|
||||
upce: 'upce',
|
||||
'upc-e': 'upce',
|
||||
itf14: 'itf14',
|
||||
'itf-14': 'itf14',
|
||||
codabar: 'rationalizedCodabar',
|
||||
intelligentmail: 'onecode',
|
||||
'intelligent-mail': 'onecode'
|
||||
});
|
||||
|
||||
const SAFE_BCID = /^[A-Za-z0-9_-]{2,64}$/;
|
||||
|
||||
function normalizeBcid(raw) {
|
||||
const requested = String(raw || 'qrcode').trim();
|
||||
const alias = TYPE_ALIASES[requested.toLowerCase()] || requested;
|
||||
|
||||
if (!SAFE_BCID.test(alias)) {
|
||||
throw new Error('Invalid type. Use a bwip-js encoder id such as qrcode, azteccode, datamatrix, pdf417, code128, or gs1-128.');
|
||||
}
|
||||
|
||||
return alias;
|
||||
}
|
||||
|
||||
function sampleForType(type) {
|
||||
const normalized = normalizeBcid(type);
|
||||
return POPULAR_TYPES.find((item) => item.id === normalized)?.sample || 'Hello from Code Canvas';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
POPULAR_TYPES,
|
||||
TYPE_ALIASES,
|
||||
normalizeBcid,
|
||||
sampleForType
|
||||
};
|
||||
Reference in New Issue
Block a user