commit 537b594e0d6d732aca86c5be1e81c2e93ea81276 Author: Yandrik Date: Tue May 19 15:32:05 2026 +0200 feat: INITIAL COMMIT diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c4dd1ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +.git +.gitignore +.DS_Store +.env +coverage +*.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4f31f98 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..54df6cc --- /dev/null +++ b/README.md @@ -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. + +![Code Canvas Generator screenshot](docs/assets/code-canvas-generator.png) + +## 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 +``` + +![Example QR code](http://127.0.0.1:8080/api/code.png?type=qrcode&input=text&data=https%3A%2F%2Fexample.com%2F%3Fhello%3Dworld&size=256&margin=9&scale=4&rotate=N&fg=000000&bg=ffffff&includetext=false&textalign=center) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..52b9f09 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/assets/code-canvas-generator.png b/docs/assets/code-canvas-generator.png new file mode 100644 index 0000000..42c8c92 Binary files /dev/null and b/docs/assets/code-canvas-generator.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..5c63234 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..bf549ab --- /dev/null +++ b/public/app.js @@ -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'; +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..72799e0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,171 @@ + + + + + + Code Canvas Generator + + + + +
+
+
+

Code Canvas Generator

+

Self-hosted barcode and QR generator. All state is encoded as query parameters.

+
+ +
+ + +
+ + + +
+ + + +
+ + + +
+ +
+ + +
+ +
+ Advanced options +
+ + + +
+ +
+ + + +
+
+ +
+ + + + +
+
+
+ +
+
+
+

Live preview

+

Output: 256 × 256 px

+
+ Ready +
+ +
+ +
+ + + +

+ Margin is explicit white padding around the generated symbol. With margin 200 and size 256, the final PNG is 656 × 656 px. +

+
+
+ + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..1719a15 --- /dev/null +++ b/public/style.css @@ -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; + } +} diff --git a/src/input.js b/src/input.js new file mode 100644 index 0000000..1b1eea3 --- /dev/null +++ b/src/input.js @@ -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 +}; diff --git a/src/render.js b/src/render.js new file mode 100644 index 0000000..18f64ca --- /dev/null +++ b/src/render.js @@ -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 +}; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..3dfd55c --- /dev/null +++ b/src/server.js @@ -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); +} diff --git a/src/symbologies.js b/src/symbologies.js new file mode 100644 index 0000000..b861217 --- /dev/null +++ b/src/symbologies.js @@ -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 +};