This commit is contained in:
jpmvaz
2026-09-13 20:09:20 +01:00
commit 9b5cc30fb4
482 changed files with 57569 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Version control
.git
.gitignore
.gitattributes
# Documentation that doesn't belong in the image
README.md
LICENSE
CHANGELOG.md
docs/
# Editor / OS junk
.vscode
.idea
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# Local env files (use .env at the compose level, not inside the image)
.env
.env.local
.env.*.local
# Build artefacts that shouldn't be shipped
node_modules
dist
build
*.log
npm-debug.log*
# CI
.github/
# Test outputs
coverage/
.nyc_output/
+5
View File
@@ -0,0 +1,5 @@
# Copy to `.env` and tweak as needed.
# These values feed docker-compose.yml.
# Public port on the host machine. Defaults to 8080.
HOST_PORT=8080
+42
View File
@@ -0,0 +1,42 @@
# =============================================================================
# Cipher Barcode Studio — production image
# Base: nginx 1.30.1 on Alpine 3.23 (unprivileged variant — runs as UID 101)
# =============================================================================
FROM nginxinc/nginx-unprivileged:1.30.1-alpine3.23-slim
# OCI labels for traceability
LABEL org.opencontainers.image.title="Cipher Barcode Studio" \
org.opencontainers.image.description="Static web app for generating barcodes in 22 symbologies and exporting JPG/PNG" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.source="https://example.com/cipher-barcode-studio"
# The unprivileged image runs as user 101 (nginx). All file ops use --chown.
USER root
# Apply the latest Alpine security patches at build time, then strip the cache
RUN apk update && apk upgrade --no-cache && rm -rf /var/cache/apk/*
# Replace the default nginx config with our hardened one
COPY --chown=nginx:nginx nginx/nginx.conf /etc/nginx/nginx.conf
# Copy the static site
COPY --chown=nginx:nginx web/ /usr/share/nginx/html/
# Make sure runtime dirs the unprivileged user needs are writable
RUN mkdir -p /tmp/client_body /tmp/proxy /tmp/fastcgi /tmp/uwsgi /tmp/scgi \
&& chown -R nginx:nginx /tmp/client_body /tmp/proxy /tmp/fastcgi /tmp/uwsgi /tmp/scgi /var/log/nginx \
&& chmod -R 755 /usr/share/nginx/html
# Drop back to the non-root user for runtime
USER nginx
# Internal port — unprivileged nginx listens on 8080
EXPOSE 8080
# Healthcheck driven by /healthz from nginx.conf
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1:8080/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]
+29
View File
@@ -0,0 +1,29 @@
MIT License
Copyright (c) 2026 Cipher Barcode Studio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Third-party software shipped with this project retains its own license:
* JsBarcode (MIT) — https://github.com/lindell/JsBarcode
* bwip-js (MIT) — https://github.com/metafloor/bwip-js
* nginx (BSD-2-Clause) — https://nginx.org/LICENSE
Binary file not shown.
Binary file not shown.
+385
View File
@@ -0,0 +1,385 @@
# Cipher · Barcode Studio
A self-contained, browser-based barcode generator that converts text or numeric sequences into 22 different barcode symbologies and exports them as downloadable JPG (or PNG) images. Designed to run as a single hardened Docker container with no backend, no database, and no telemetry — everything happens in the user's browser.
![status](https://img.shields.io/badge/status-stable-success) ![docker](https://img.shields.io/badge/docker-compose-blue) ![license](https://img.shields.io/badge/license-MIT-lightgrey)
---
## Table of Contents
1. [Features](#features)
2. [Supported Barcode Formats](#supported-barcode-formats)
3. [Architecture](#architecture)
4. [Requirements](#requirements)
5. [Quick Start](#quick-start)
6. [Configuration](#configuration)
7. [Project Structure](#project-structure)
8. [Security](#security)
9. [Dependency Versions & Vulnerability Status](#dependency-versions--vulnerability-status)
10. [Operations](#operations)
11. [Updating & Maintenance](#updating--maintenance)
12. [Troubleshooting](#troubleshooting)
13. [Development](#development)
14. [License](#license)
---
## Features
* **22 barcode symbologies** spanning retail/GS1, linear/industrial, pharma, postal and 2D matrix codes.
* **Live example preview** — every symbology shows a sample render the moment it's selected.
* **Batch generation** — paste lines into the input area and each line becomes its own barcode card.
* **Per-card text toggle** — show or hide the human-readable caption beneath each barcode, then download it in that state.
* **JPG and PNG export** — individual downloads per barcode plus a one-click "Download All as JPG" for the whole batch.
* **Adjustable scale and JPG quality** for print-ready output.
* **Fully offline-capable** once loaded — every library is vendored locally, no third-party CDN at runtime (web fonts are the only external request and are optional).
* **No backend** — runs entirely as static assets served by nginx. No database, no API, no user data leaves the browser.
---
## Supported Barcode Formats
| Group | Symbology | Notes |
|---|---|---|
| Retail · GS1 | EAN-13, EAN-8, UPC-A, UPC-E, ISBN-13, GS1 DataBar, GS1-128 | ISBN-10 auto-converts to ISBN-13 |
| Linear · Industrial | Code-128, Code-39, Code-39 Full ASCII, Code-93, Code-11, Code 2of5 Interleaved, MSI Plessey, Flattermarken, Telepen Alpha | |
| Pharma · Specialty | Pharmacode One-Track, Pharmacode Two-Track | Laetus binary codes |
| Postal | KIX | Dutch PostNL routing |
| 2D · Matrix | QR Code, Data Matrix, PDF417 | |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌───────────────────────────────────────────────────┐ │
│ │ index.html (UI + state) │ │
│ │ ├── JsBarcode (linear codes) │ │
│ │ └── bwip-js (specialty + 2D codes) │ │
│ │ ─→ HTMLCanvasElement → JPG / PNG Blob │ │
│ └───────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────┘
│ HTTP (static assets only)
┌─────────────────────────────────────────────────────────────┐
│ Docker container · nginx 1.30.1 · alpine 3.23 │
│ • runs as non-root (UID 101) │
│ • read-only root filesystem │
│ • all caps dropped, no-new-privileges │
│ • CSP / HSTS / nosniff / X-Frame-Options / COOP / CORP │
└─────────────────────────────────────────────────────────────┘
```
Encoding happens **entirely in the browser** — the container only ships static HTML/JS. The image cannot leak data because there is no application server to leak through.
---
## Requirements
| Component | Minimum version | Why |
|---|---|---|
| Docker Engine | **24.0+** | Buildkit, compose-v2 features |
| Docker Compose | **v2.20+** | Modern compose schema (`deploy.resources`, `tmpfs` options) |
| Host OS | Linux / macOS / Windows (WSL2) | Anything Docker supports |
| RAM | 64 MB free | Container reserves 32 MB, caps at 128 MB |
| Disk | ~50 MB | Image is ~40 MB |
| Open port | one host port (default 8080) | Reconfigurable via `.env` |
Verify your toolchain:
```bash
docker --version # → Docker version 24.x.x or newer
docker compose version # → Docker Compose version v2.20+ or newer
```
If you're on an older Docker that only has the standalone `docker-compose` binary, the file still works — just replace `docker compose` with `docker-compose` in the commands below.
---
## Quick Start
```bash
# 1. Unzip and enter the project
unzip cipher-barcode-studio.zip
cd cipher-barcode-studio
# 2. (Optional) override the host port
cp .env.example .env # then edit HOST_PORT if needed
# 3. Build and start
docker compose up -d --build
# 4. Open in browser
# http://localhost:8080
```
That's it. To stop:
```bash
docker compose down
```
To stop and remove the built image as well:
```bash
docker compose down --rmi local
```
---
## Configuration
All runtime configuration lives in `.env` (copied from `.env.example`):
| Variable | Default | Description |
|---|---|---|
| `HOST_PORT` | `8080` | Port published on the host machine |
The container itself always listens on `8080` internally — only the host-side port is configurable.
### Putting it behind a reverse proxy
If you're running this behind Traefik, Caddy, nginx, or similar:
```yaml
# example with Traefik labels
services:
cipher:
# ...existing config...
labels:
- "traefik.enable=true"
- "traefik.http.routers.cipher.rule=Host(`barcode.example.com`)"
- "traefik.http.routers.cipher.tls=true"
- "traefik.http.routers.cipher.tls.certresolver=letsencrypt"
- "traefik.http.services.cipher.loadbalancer.server.port=8080"
```
When fronted by HTTPS, the `Strict-Transport-Security` header in `nginx.conf` will kick in automatically.
---
## Project Structure
```
cipher-barcode-studio/
├── docker-compose.yml # Orchestration + runtime hardening
├── Dockerfile # Image build steps
├── .dockerignore # Keep the build context lean
├── .env.example # Template for runtime config
├── LICENSE # MIT + third-party attributions
├── README.md # You are here
├── nginx/
│ └── nginx.conf # Hardened nginx config w/ CSP & security headers
└── web/
├── index.html # The full application (single HTML file)
└── vendor/
├── JsBarcode.all.min.js
└── bwip-js-min.js
```
---
## Security
This image is built with a defense-in-depth posture even though it serves static content with no user data:
### Container hardening
| Control | Mechanism |
|---|---|
| Non-root execution | Image's `USER` is `nginx` (UID 101). Compose pins `user: "101:101"`. |
| Read-only rootfs | `read_only: true` in compose; writable paths exposed via `tmpfs`. |
| Capability drop | `cap_drop: [ALL]` — no Linux capabilities granted. |
| No privilege escalation | `security_opt: no-new-privileges:true`. |
| Resource limits | CPU capped at 0.5 cores, memory at 128 MB. |
| Minimal base | `nginxinc/nginx-unprivileged:alpine3.23-slim` — Alpine, no extra packages, security upgrades applied at build. |
| Restricted log size | json-file driver capped at 5 MB × 3 rotations. |
### HTTP hardening (in `nginx/nginx.conf`)
| Header | Purpose |
|---|---|
| `Content-Security-Policy` | Restricts scripts/styles/fonts/images to `self` + Google Fonts only |
| `Strict-Transport-Security` | One-year HSTS with subdomain coverage (effective behind TLS) |
| `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` | Clickjacking protection |
| `X-Content-Type-Options: nosniff` | Disables MIME sniffing |
| `Referrer-Policy: strict-origin-when-cross-origin` | Limits referrer leakage |
| `Permissions-Policy` | Disables camera, mic, geolocation, USB, payment APIs |
| `Cross-Origin-Opener-Policy: same-origin` | Window isolation |
| `Cross-Origin-Resource-Policy: same-origin` | Resource isolation |
| `server_tokens off` | Hides nginx version |
Also: request size capped at 1 KB (this is a static GET-only service), hidden files (`/\.`) blocked, `/healthz` lightweight endpoint for orchestrators.
### Supply chain
* JavaScript libraries are **vendored** under `web/vendor/`, not loaded from CDN at runtime.
* Each `<script>` tag in `index.html` carries a **Subresource Integrity (SRI)** hash (`sha384`), so any tampering with the vendor files will cause the browser to refuse execution.
* The only runtime third-party connection is to Google Fonts for typography. If you need full air-gap operation, see [Removing Google Fonts](#removing-google-fonts).
### Scanning the image yourself
```bash
docker compose build
docker scout cves cipher-barcode-studio:1.0.0 # native Docker scanner
# or
trivy image cipher-barcode-studio:1.0.0 # Trivy
# or
grype cipher-barcode-studio:1.0.0 # Grype
```
Expect findings to be limited to base-image OS packages; the application itself has no Node.js, npm, or other runtime dependencies to scan.
---
## Dependency Versions & Vulnerability Status
All versions verified against published CVE feeds at build time.
| Component | Pinned version | Latest as of build | Known CVEs | Source of truth |
|---|---|---|---|---|
| nginx | `1.30.1` | 1.30.1 (stable) | none active | [nginx security advisories](https://nginx.org/en/security_advisories.html) |
| Alpine Linux | `3.23` | 3.23 | base image kept patched via `apk upgrade` in Dockerfile | [Alpine secdb](https://secdb.alpinelinux.org/) |
| JsBarcode | `3.12.3` | 3.12.3 | 0 | [Snyk advisor](https://snyk.io/advisor/npm-package/jsbarcode), [Socket](https://socket.dev/npm/package/jsbarcode) |
| bwip-js | `4.10.1` | 4.10.1 | 0 | [Snyk advisor](https://security.snyk.io/package/npm/bwip-js) |
The build pulls `nginxinc/nginx-unprivileged:1.30.1-alpine3.23-slim` and then runs `apk upgrade --no-cache` so any Alpine package CVEs disclosed after the image was published are still patched.
If you fork this repo, re-run the vulnerability check periodically:
```bash
docker compose build --no-cache
trivy image --severity HIGH,CRITICAL cipher-barcode-studio:1.0.0
```
---
## Operations
### View logs
```bash
docker compose logs -f cipher
```
### Healthcheck
```bash
docker inspect --format='{{json .State.Health}}' cipher-barcode-studio | jq
# or hit it directly:
curl -i http://localhost:8080/healthz
```
### Restart
```bash
docker compose restart cipher
```
### Inspect security posture of the running container
```bash
docker inspect cipher-barcode-studio | jq '.[0].HostConfig | {ReadonlyRootfs, CapDrop, SecurityOpt, Memory, NanoCpus}'
```
Expected output should include `"ReadonlyRootfs": true`, `"CapDrop": ["ALL"]`, and `"no-new-privileges:true"`.
---
## Updating & Maintenance
### Rebuild with the latest patches
```bash
docker compose build --no-cache --pull
docker compose up -d
```
`--pull` forces Docker to refetch the base image, ensuring you pick up new Alpine and nginx releases.
### Update a vendored JS library
```bash
cd web/vendor
# Example: bump bwip-js
curl -sSL -o bwip-js-min.js "https://cdn.jsdelivr.net/npm/bwip-js@<new-version>/dist/bwip-js-min.js"
# Recompute the SRI hash
openssl dgst -sha384 -binary bwip-js-min.js | openssl base64 -A | xargs -I{} echo "sha384-{}"
# Paste the new hash into the integrity="" attribute in web/index.html
# Rebuild
docker compose up -d --build
```
### Removing Google Fonts
If you need a fully air-gapped install, edit `web/index.html`:
1. Delete the three `<link rel="preconnect">` and `<link rel="stylesheet">` tags pointing at `fonts.googleapis.com` / `fonts.gstatic.com`.
2. The page still works — it just falls back to the next font in each `font-family` stack (`serif` / `monospace`).
3. Tighten `nginx/nginx.conf` by removing the `https://fonts.googleapis.com` and `https://fonts.gstatic.com` entries from the CSP.
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| `bind: address already in use` | Port 8080 is taken | Set `HOST_PORT=8081` (or any free port) in `.env` and `docker compose up -d` |
| Container restart-loops with "Permission denied" on `/var/cache/nginx` | Older Docker without tmpfs option support | Upgrade Docker Engine to 24+ |
| Browser shows blank page, console says "Refused to execute script ... integrity" | Vendor file modified locally without updating SRI | Recompute SRI hash (see [Updating](#update-a-vendored-js-library)) |
| Fonts look generic | Network blocks Google Fonts | Either allow `fonts.googleapis.com` / `fonts.gstatic.com` or follow [Removing Google Fonts](#removing-google-fonts) |
| QR / Data Matrix barcode renders but caption is cut off | Output is a 2D code with long text | Hide the text via the per-card "Hide text" button, or shorten the input |
| `docker compose` not found | Only the legacy standalone `docker-compose` is installed | Use `docker-compose up -d --build` instead; or install the v2 plugin |
### Resetting from scratch
```bash
docker compose down --rmi local --volumes
docker compose up -d --build --force-recreate
```
---
## Development
To iterate on the UI without rebuilding the image every time, mount `web/` over the container's webroot:
```yaml
# docker-compose.override.yml (gitignored convention)
services:
cipher:
volumes:
- ./web:/usr/share/nginx/html:ro
```
Then:
```bash
docker compose up -d
# edit web/index.html, refresh the browser — no rebuild needed
```
When you're done, delete the override file and rebuild for production.
---
## License
This project is released under the **MIT License** — see [LICENSE](./LICENSE).
Third-party libraries retain their own licenses:
* **JsBarcode** — MIT — https://github.com/lindell/JsBarcode
* **bwip-js** — MIT — https://github.com/metafloor/bwip-js
* **nginx** — BSD-2-Clause — https://nginx.org/LICENSE
* **Alpine Linux** — MIT-equivalent — https://alpinelinux.org/
---
*Built with care. Encode anything. Print everything.*
+66
View File
@@ -0,0 +1,66 @@
# =============================================================================
# Cipher Barcode Studio — docker-compose
# Usage: docker compose up -d --build
# Access: http://localhost:8080
# =============================================================================
services:
cipher:
build:
context: .
dockerfile: Dockerfile
image: cipher-barcode-studio:1.0.0
container_name: cipher-barcode-studio
restart: unless-stopped
ports:
# Host:Container — change the left side to publish on a different port
- "${HOST_PORT:-8080}:8080"
# ---------- Runtime hardening ----------
# Run as the non-root user baked into the image (UID 101)
user: "101:101"
# Read-only root filesystem with explicit tmpfs for nginx's scratch dirs
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
- /var/cache/nginx:rw,noexec,nosuid,size=16m
- /var/run:rw,noexec,nosuid,size=4m
# Drop every Linux capability; nginx-unprivileged on port 8080 needs none
cap_drop:
- ALL
# Stop the container from gaining new privileges
security_opt:
- no-new-privileges:true
# ---------- Resource limits ----------
deploy:
resources:
limits:
cpus: "0.50"
memory: 128M
reservations:
cpus: "0.05"
memory: 32M
# ---------- Logging ----------
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "3"
# ---------- Healthcheck (overrides Dockerfile so we can tune it) ----------
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
networks:
- cipher-net
networks:
cipher-net:
driver: bridge
+128
View File
@@ -0,0 +1,128 @@
# Cipher Barcode Studio — hardened nginx configuration
# Static-only site, no upstream, no PHP, no dynamic code. Read-only filesystem.
worker_processes auto;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Temp paths in /tmp so we can run as a non-root user with a read-only rootfs
client_body_temp_path /tmp/client_body;
proxy_temp_path /tmp/proxy;
fastcgi_temp_path /tmp/fastcgi;
uwsgi_temp_path /tmp/uwsgi;
scgi_temp_path /tmp/scgi;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 30;
server_tokens off; # hide nginx version
# Limits — this is a static-only site, so cap request size strictly
client_max_body_size 1k;
client_body_buffer_size 1k;
client_header_buffer_size 1k;
large_client_header_buffers 2 4k;
# Compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
image/svg+xml
font/woff2;
server {
listen 8080 default_server;
listen [::]:8080 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ---------- Security headers ----------
# Static-only site: tight CSP. Inline styles/scripts are unavoidable
# because the app is one self-contained HTML file, but external scripts
# are restricted to 'self' and validated with SRI in the markup.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
# Block framing entirely (defense-in-depth alongside CSP frame-ancestors)
add_header X-Frame-Options "DENY" always;
# Stop MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;
# Disable unused browser features
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), payment=(), usb=()" always;
# Don't leak referrer to third-party fonts
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# HSTS — only meaningful behind HTTPS; harmless on HTTP
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Cross-origin isolation
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# ---------- Routes ----------
# Hash-able vendor assets get long-lived caches
location /vendor/ {
add_header Cache-Control "public, max-age=2592000, immutable" always;
# Re-apply security headers (add_header doesn't inherit across location blocks)
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
try_files $uri =404;
}
# The main HTML — short cache so updates roll out
location = / {
add_header Cache-Control "public, max-age=300, must-revalidate" always;
try_files /index.html =404;
}
# Healthcheck endpoint for docker / orchestrators
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
# Block hidden files and common sensitive paths
location ~ /\. {
deny all;
return 404;
}
location / {
try_files $uri $uri/ =404;
}
# Custom error pages
error_page 404 /index.html;
error_page 500 502 503 504 /index.html;
}
}
+794
View File
@@ -0,0 +1,794 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Barcode Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500;9..144,700;9..144,900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
<!-- Barcode libraries (vendored locally — pinned versions, no CDN dependency) -->
<script
src="vendor/JsBarcode.all.min.js"
integrity="sha384-vmcSy8TM1KhZWBIKMKTR8AxbrJQCuConAolGY+42odu9ZGIzw8L8xAT/u7ul4X2U"
crossorigin="anonymous"></script>
<script
src="vendor/bwip-js-min.js"
integrity="sha384-33pyXSGFs/ylT0uDwaLiFGZbH9AnIjaVhTWbD3HHFxyS0iGlnUpC5L7Vs/1gA1vw"
crossorigin="anonymous"></script>
<style>
:root{
--bg: #f1ece4;
--bg-2: #e8e2d6;
--ink: #14110d;
--ink-soft: #3a342b;
--accent: #ff4a1c;
--accent-2: #1b3aff;
--paper: #fbf8f2;
--line: #14110d;
--rule: rgba(20,17,13,.18);
--grain-opacity: .06;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--bg);color:var(--ink);font-family:'JetBrains Mono',monospace;font-size:14px;min-height:100vh;overflow-x:hidden}
/* Paper grain */
body::before{
content:'';position:fixed;inset:0;pointer-events:none;z-index:1;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
opacity:var(--grain-opacity);mix-blend-mode:multiply;
}
/* ===== Header ===== */
header{
position:relative;z-index:2;
border-bottom:1px solid var(--line);
padding:14px 28px;
display:flex;align-items:center;justify-content:space-between;
background:var(--bg);
}
.brand{display:flex;align-items:baseline;gap:14px}
.brand-mark{
font-family:'Fraunces',serif;font-weight:900;font-size:28px;letter-spacing:-.02em;
font-style:italic;
}
.brand-mark::after{
content:'';display:inline-block;width:10px;height:10px;background:var(--accent);margin-left:6px;transform:translateY(-2px);
}
.brand-sub{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-soft)}
.meta{display:flex;gap:24px;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft)}
.meta span b{color:var(--ink);font-weight:500}
.blink{display:inline-block;width:8px;height:8px;background:var(--accent);border-radius:50%;margin-right:6px;animation:pulse 1.6s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
/* ===== Hero strip ===== */
.hero{
position:relative;z-index:2;
border-bottom:1px solid var(--line);
padding:42px 28px 36px;
display:grid;grid-template-columns:1.4fr 1fr;gap:40px;align-items:end;
background:linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
}
.hero h1{
font-family:'Fraunces',serif;font-weight:300;
font-size:clamp(48px, 7vw, 96px);
line-height:.92;letter-spacing:-.035em;
}
.hero h1 em{font-style:italic;font-weight:500;color:var(--accent)}
.hero h1 .strike{text-decoration:line-through;text-decoration-thickness:2px;font-weight:900}
.hero-desc{
font-size:13px;line-height:1.6;color:var(--ink-soft);max-width:420px;
border-left:2px solid var(--accent);padding-left:14px;
}
.hero-desc b{color:var(--ink);font-weight:500;text-transform:uppercase;letter-spacing:.1em;font-size:11px;display:block;margin-bottom:8px}
/* ===== Main layout ===== */
main{
position:relative;z-index:2;
display:grid;grid-template-columns:360px 1fr;min-height:calc(100vh - 220px);
}
/* ===== Sidebar: format picker ===== */
.sidebar{
border-right:1px solid var(--line);
background:var(--bg);
overflow-y:auto;
max-height:calc(100vh - 110px);
position:sticky;top:0;
}
.sidebar-head{
padding:18px 22px 14px;
border-bottom:1px solid var(--rule);
display:flex;justify-content:space-between;align-items:baseline;
position:sticky;top:0;background:var(--bg);z-index:2;
}
.sidebar-head h2{
font-family:'Fraunces',serif;font-weight:500;font-style:italic;font-size:18px;
}
.sidebar-head .count{font-size:10px;letter-spacing:.18em;color:var(--ink-soft);text-transform:uppercase}
.group{padding:10px 0;border-bottom:1px solid var(--rule)}
.group-title{
padding:14px 22px 6px;font-size:10px;letter-spacing:.22em;text-transform:uppercase;
color:var(--ink-soft);display:flex;align-items:center;gap:8px;
}
.group-title::before{content:'';flex:0 0 14px;height:1px;background:var(--ink-soft)}
.fmt{
padding:11px 22px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:10px;
transition:background .15s, padding .15s;border-left:3px solid transparent;
}
.fmt:hover{background:var(--bg-2);padding-left:26px}
.fmt.active{background:var(--ink);color:var(--paper);border-left-color:var(--accent)}
.fmt.active .fmt-code{color:var(--paper);opacity:.6}
.fmt-name{font-size:13px;font-weight:500;letter-spacing:-.005em}
.fmt-code{font-size:10px;letter-spacing:.1em;color:var(--ink-soft);text-transform:uppercase}
/* ===== Workspace ===== */
.workspace{padding:28px 32px 80px;background:var(--bg-2);}
.selected-card{
background:var(--paper);
border:1px solid var(--line);
padding:22px 24px;
margin-bottom:24px;
display:grid;grid-template-columns:1fr auto;gap:24px;align-items:center;
position:relative;
}
.selected-card::before{
content:'01';position:absolute;top:-10px;left:18px;background:var(--accent);color:var(--paper);
font-size:10px;letter-spacing:.2em;padding:2px 8px;font-weight:700;
}
.selected-meta h3{
font-family:'Fraunces',serif;font-weight:500;font-size:32px;letter-spacing:-.02em;margin-bottom:4px;
}
.selected-meta .selected-type{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:14px}
.selected-meta p{font-size:12px;line-height:1.65;color:var(--ink-soft);max-width:520px}
.selected-spec{
font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft);
display:flex;gap:18px;margin-top:14px;flex-wrap:wrap;
}
.selected-spec b{color:var(--ink);font-weight:500}
.example-box{
width:260px;background:#fff;border:1px solid var(--rule);padding:14px;
display:flex;flex-direction:column;align-items:center;gap:8px;position:relative;
}
.example-box::before{
content:'EXAMPLE';position:absolute;top:-8px;left:10px;background:var(--paper);
font-size:9px;letter-spacing:.22em;color:var(--ink-soft);padding:0 6px;
}
.example-box canvas, .example-box svg{max-width:100%;height:auto;display:block}
.example-box .empty{font-size:10px;color:var(--ink-soft);letter-spacing:.1em;text-transform:uppercase;padding:20px 0}
/* ===== Input area ===== */
.input-section{
background:var(--paper);
border:1px solid var(--line);
padding:22px 24px 24px;
margin-bottom:24px;
position:relative;
}
.input-section::before{
content:'02';position:absolute;top:-10px;left:18px;background:var(--accent);color:var(--paper);
font-size:10px;letter-spacing:.2em;padding:2px 8px;font-weight:700;
}
.input-head{
display:flex;justify-content:space-between;align-items:baseline;margin-bottom:14px;flex-wrap:wrap;gap:12px;
}
.input-head h3{font-family:'Fraunces',serif;font-weight:500;font-style:italic;font-size:22px;letter-spacing:-.01em}
.input-help{font-size:11px;color:var(--ink-soft);letter-spacing:.06em}
.input-help kbd{background:var(--bg-2);border:1px solid var(--rule);padding:1px 5px;font-family:inherit;font-size:10px;border-radius:2px}
textarea{
width:100%;min-height:140px;resize:vertical;
background:#fff;border:1px solid var(--rule);
padding:14px 16px;
font-family:'JetBrains Mono',monospace;font-size:13px;line-height:1.7;
color:var(--ink);outline:none;transition:border-color .15s, box-shadow .15s;
}
textarea:focus{border-color:var(--ink);box-shadow:0 0 0 3px rgba(255,74,28,.15)}
.options-row{
display:flex;gap:24px;margin-top:16px;flex-wrap:wrap;align-items:center;
}
.opt{display:flex;align-items:center;gap:8px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);cursor:pointer}
.opt input[type="checkbox"]{
appearance:none;width:14px;height:14px;border:1.5px solid var(--ink);background:transparent;cursor:pointer;position:relative;
}
.opt input[type="checkbox"]:checked{background:var(--ink)}
.opt input[type="checkbox"]:checked::after{
content:'';position:absolute;inset:2px;background:var(--accent);
}
.opt-num{display:flex;align-items:center;gap:6px}
.opt-num input{
width:60px;background:#fff;border:1px solid var(--rule);padding:4px 8px;
font-family:inherit;font-size:11px;color:var(--ink);outline:none;
}
.opt-num input:focus{border-color:var(--ink)}
.actions{display:flex;gap:12px;margin-top:18px;flex-wrap:wrap}
button.btn{
font-family:'JetBrains Mono',monospace;
font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:700;
padding:14px 22px;cursor:pointer;border:1px solid var(--ink);
transition:all .15s;display:inline-flex;align-items:center;gap:10px;
}
.btn-primary{background:var(--ink);color:var(--paper)}
.btn-primary:hover{background:var(--accent);border-color:var(--accent);transform:translate(-2px,-2px);box-shadow:4px 4px 0 var(--ink)}
.btn-secondary{background:transparent;color:var(--ink)}
.btn-secondary:hover{background:var(--ink);color:var(--paper)}
.btn-danger:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}
/* ===== Results ===== */
.results-section{
background:var(--paper);
border:1px solid var(--line);
padding:22px 24px 28px;
position:relative;min-height:200px;
}
.results-section::before{
content:'03';position:absolute;top:-10px;left:18px;background:var(--accent);color:var(--paper);
font-size:10px;letter-spacing:.2em;padding:2px 8px;font-weight:700;
}
.results-head{
display:flex;justify-content:space-between;align-items:baseline;margin-bottom:18px;flex-wrap:wrap;gap:12px;
padding-bottom:12px;border-bottom:1px solid var(--rule);
}
.results-head h3{font-family:'Fraunces',serif;font-weight:500;font-style:italic;font-size:22px;letter-spacing:-.01em}
.results-stats{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-soft)}
.results-stats b{color:var(--accent);font-weight:700}
.results-empty{
padding:60px 20px;text-align:center;color:var(--ink-soft);
font-size:11px;letter-spacing:.18em;text-transform:uppercase;
}
.results-empty::before{
content:'⌗';display:block;font-size:54px;margin-bottom:14px;color:var(--rule);font-family:'Fraunces',serif;
}
.results-grid{
display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:18px;
}
.result-card{
background:#fff;border:1px solid var(--rule);padding:18px;
display:flex;flex-direction:column;gap:12px;
transition:transform .15s, box-shadow .15s;
position:relative;
}
.result-card:hover{transform:translate(-2px,-2px);box-shadow:4px 4px 0 var(--ink)}
.result-card .idx{
position:absolute;top:8px;right:10px;font-size:9px;letter-spacing:.14em;
color:var(--ink-soft);
}
.result-canvas-wrap{
background:#fff;display:flex;align-items:center;justify-content:center;
min-height:120px;padding:6px;border:1px dashed var(--rule);
}
.result-canvas-wrap canvas, .result-canvas-wrap svg{max-width:100%;height:auto;display:block}
.result-data{
font-size:11px;color:var(--ink-soft);word-break:break-all;
border-top:1px solid var(--rule);padding-top:10px;
max-height:50px;overflow:hidden;text-overflow:ellipsis;
}
.result-data b{color:var(--ink);font-weight:500}
.result-actions{display:flex;gap:6px}
.result-actions button{
flex:1;font-family:inherit;font-size:9px;letter-spacing:.14em;text-transform:uppercase;
padding:7px 8px;background:transparent;border:1px solid var(--ink);color:var(--ink);cursor:pointer;
transition:all .12s;font-weight:700;
}
.result-actions button:hover{background:var(--ink);color:var(--paper)}
.result-error{
background:#ffe9e2;border:1px solid var(--accent);color:var(--accent);
padding:14px;font-size:11px;line-height:1.55;
}
/* Toast */
.toast{
position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(80px);
background:var(--ink);color:var(--paper);padding:12px 22px;font-size:11px;letter-spacing:.18em;text-transform:uppercase;
z-index:50;transition:transform .3s cubic-bezier(.2,.8,.2,1);
border:1px solid var(--accent);
}
.toast.show{transform:translateX(-50%) translateY(0)}
/* Footer */
footer{
border-top:1px solid var(--line);padding:18px 28px;display:flex;justify-content:space-between;align-items:center;
font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-soft);background:var(--bg);position:relative;z-index:2;
}
footer .dots{display:flex;gap:4px}
footer .dots span{width:6px;height:6px;background:var(--ink);border-radius:50%}
footer .dots span:nth-child(2){background:var(--accent)}
footer .dots span:nth-child(3){background:var(--accent-2)}
/* Responsive */
@media (max-width: 1000px){
main{grid-template-columns:1fr}
.sidebar{position:static;max-height:none;border-right:none;border-bottom:1px solid var(--line)}
.hero{grid-template-columns:1fr;gap:20px}
.selected-card{grid-template-columns:1fr}
.example-box{width:100%}
}
@media (max-width: 600px){
header{flex-direction:column;gap:10px;align-items:flex-start}
.meta{flex-wrap:wrap;gap:10px}
.hero{padding:28px 18px}
.workspace{padding:18px}
}
/* Scrollbar */
.sidebar::-webkit-scrollbar{width:8px}
.sidebar::-webkit-scrollbar-track{background:transparent}
.sidebar::-webkit-scrollbar-thumb{background:var(--ink);border-radius:0}
</style>
</head>
<body>
<header>
<div class="brand">
<div class="brand-mark">Barcode Studio · v1.1</div>
<div class="brand-sub">J.Vaz 2026</div>
</div>
<div class="meta">
<span><span class="blink"></span>System Live</span>
<span>Formats <b>22</b></span>
<span>Output <b>JPG / PNG</b></span>
</div>
</header>
<section class="hero">
<h1>Encode <em>anything</em>.<br><span class="strike">Print</span> everything.</h1>
<div class="hero-desc">
<b>Studio Manual</b>
A precision instrument for converting text and numbers into industrial-grade barcodes.
Select a symbology, paste your data line-by-line, and export downloadable JPG images —
one per line, or all at once.
</div>
</section>
<main>
<!-- Sidebar: format picker -->
<aside class="sidebar">
<div class="sidebar-head">
<h2>Symbologies</h2>
<span class="count" id="formatCount">— FORMATS</span>
</div>
<div id="formatList"></div>
</aside>
<!-- Workspace -->
<div class="workspace">
<!-- Selected format card -->
<div class="selected-card">
<div class="selected-meta">
<h3 id="selName"></h3>
<div class="selected-type" id="selType">Select a format</div>
<p id="selDesc">Pick a symbology from the panel on the left to begin.</p>
<div class="selected-spec" id="selSpec"></div>
</div>
<div class="example-box">
<div id="exampleWrap"><div class="empty">Loading…</div></div>
</div>
</div>
<!-- Input area -->
<div class="input-section">
<div class="input-head">
<h3>Data Input</h3>
<div class="input-help">One barcode per line · <kbd></kbd> to add another</div>
</div>
<textarea id="dataInput" placeholder="Enter your data here…
Each line becomes a separate barcode.
123456789012
HELLO-WORLD
https://example.com"></textarea>
<div class="options-row">
<label class="opt">
<input type="checkbox" id="optShowText" checked>
<span>Show text below</span>
</label>
<label class="opt opt-num">
<span>Scale</span>
<input type="number" id="optScale" value="3" min="1" max="10" step="1">
</label>
<label class="opt opt-num">
<span>Quality</span>
<input type="number" id="optQuality" value="0.95" min="0.5" max="1" step="0.05">
</label>
</div>
<div class="actions">
<button class="btn btn-primary" id="btnGenerate">
<span></span> Generate Barcodes
</button>
<button class="btn btn-secondary" id="btnDownloadAll">
<span></span> Download All as JPG
</button>
<button class="btn btn-secondary btn-danger" id="btnClear">
<span>×</span> Clear
</button>
</div>
</div>
<!-- Results -->
<div class="results-section">
<div class="results-head">
<h3>Output</h3>
<div class="results-stats" id="resultsStats">Generated · <b>0</b></div>
</div>
<div id="results">
<div class="results-empty">No barcodes generated yet</div>
</div>
</div>
</div>
</main>
<footer>
<div>Barcode Studio</div>
<div class="dots"><span></span><span></span><span></span></div>
<div>JPG · PNG · Print-Ready</div>
</footer>
<div class="toast" id="toast">Saved</div>
<script>
/* =========================================================
Barcode format definitions
========================================================= */
const FORMATS = [
// ---------- Retail / EAN-UPC family ----------
{ id:'ean13', name:'EAN-13', group:'Retail · GS1', engine:'jsbarcode', code:'ean13', example:'5901234123457', desc:'European Article Number — the standard 13-digit retail barcode used on consumer products worldwide.', spec:['Numeric','13 digits','Check digit auto'] },
{ id:'ean8', name:'EAN-8', group:'Retail · GS1', engine:'jsbarcode', code:'ean8', example:'96385074', desc:'Compact 8-digit version of EAN-13, used on small packaging where space is limited.', spec:['Numeric','8 digits','Compact'] },
{ id:'upca', name:'UPC-A', group:'Retail · GS1', engine:'jsbarcode', code:'upc', example:'036000291452', desc:'Universal Product Code, the dominant retail barcode in North America. 12 digits.', spec:['Numeric','12 digits','USA standard'] },
{ id:'upce', name:'UPC-E', group:'Retail · GS1', engine:'jsbarcode', code:'upce', example:'01234565', desc:'Compressed 8-digit UPC variant for small retail items.', spec:['Numeric','8 digits','Compressed'] },
{ id:'isbn', name:'ISBN-13', group:'Retail · GS1', engine:'jsbarcode', code:'ean13', example:'9783161484100',desc:'International Standard Book Number — 13-digit barcode for books and periodicals.', spec:['Numeric','13 digits','Books'] },
{ id:'gs1databar', name:'GS1 DataBar', group:'Retail · GS1', engine:'bwip', code:'databaromni', example:'(01)24012345678905', desc:'Compact GS1 symbology designed for fresh foods, coupons, and small healthcare items.', spec:['Numeric · 14 digits','GS1 Application Identifier','Omnidirectional'] },
{ id:'gs1-128',name:'GS1-128', group:'Retail · GS1', engine:'bwip', code:'gs1-128', example:'(01)12345678901231(17)260101', desc:'GS1 application of Code 128 — used in supply chain, logistics, and shipping. Encodes Application Identifiers.', spec:['Alphanumeric','Variable length','Supply chain'] },
// ---------- Linear / Industrial ----------
{ id:'code128',name:'Code-128', group:'Linear · Industrial', engine:'jsbarcode', code:'CODE128', example:'CIPHER-2026-A1', desc:'High-density linear barcode. Encodes all 128 ASCII characters and is widely used in shipping and packaging.', spec:['ASCII','Variable','High density'] },
{ id:'code39', name:'Code-39', group:'Linear · Industrial', engine:'jsbarcode', code:'CODE39', example:'CODE 39 EXAMPLE', desc:'Discrete, self-checking symbology. Encodes 43 characters: AZ, 09, and a few symbols. Common in defense and industry.', spec:['AZ 09','Self-checking','Industrial'] },
{ id:'code39e',name:'Code-39 Full ASCII', group:'Linear · Industrial', engine:'bwip', code:'code39ext', example:'Code 39 ext.', desc:'Extended Code-39 that uses paired characters to encode the full 128-character ASCII set.', spec:['Full ASCII','128 chars','Paired encoding'] },
{ id:'code93', name:'Code-93', group:'Linear · Industrial', engine:'jsbarcode', code:'CODE93', example:'CODE93EXAMPLE', desc:'Denser successor to Code-39 with two built-in check digits for improved reliability.', spec:['ASCII','2 check digits','Compact'] },
{ id:'code11', name:'Code-11', group:'Linear · Industrial', engine:'bwip', code:'code11', example:'0123456789-', desc:'Numeric symbology developed for telecom equipment labeling. High density for digits and dashes.', spec:['09 + dash','Telecom','Numeric'] },
{ id:'i25', name:'Code 2of5 Interleaved', group:'Linear · Industrial', engine:'jsbarcode', code:'ITF', example:'1234567890', desc:'Interleaved 2 of 5 — efficient numeric-only symbology used in warehousing and ITF-14 cartons.', spec:['Numeric','Even length','Warehouse'] },
{ id:'msi', name:'MSI Plessey', group:'Linear · Industrial', engine:'jsbarcode', code:'MSI', example:'1234567', desc:'Modified Plessey code, used historically for inventory and warehouse shelf marking.', spec:['Numeric','Variable','Inventory'] },
{ id:'flatter',name:'Flattermarken', group:'Linear · Industrial', engine:'bwip', code:'flattermarken', example:'12345678', desc:'Specialty code used in the print industry to track signatures and folded sections of books.', spec:['Numeric','Print industry','Signatures'] },
{ id:'telepen',name:'Telepen Alpha', group:'Linear · Industrial', engine:'bwip', code:'telepen', example:'Telepen', desc:'Full-ASCII linear barcode developed in the UK, common in libraries and academic settings.', spec:['Full ASCII','UK origin','Libraries'] },
// ---------- Pharma ----------
{ id:'pharma1',name:'Pharmacode One-Track', group:'Pharma · Specialty', engine:'bwip', code:'pharmacode', example:'1234', desc:'Pharmaceutical binary code (Laetus). Verifies pharmaceutical packaging during production.', spec:['Numeric 3131070','Binary bars','Pharma QA'] },
{ id:'pharma2',name:'Pharmacode Two-Track', group:'Pharma · Specialty', engine:'bwip', code:'pharmacode2', example:'12345', desc:'Two-track variant of Pharmacode — encodes data using bar height in addition to position.', spec:['Numeric 464570','Two heights','Pharma QA'] },
// ---------- Postal ----------
{ id:'kix', name:'Postal · KIX', group:'Postal', engine:'bwip', code:'kix', example:'1234AB56P7', desc:'Dutch PostNL routing code. Encodes postal codes and house numbers for Dutch mail.', spec:['Alphanumeric','Netherlands','Routing'] },
// ---------- 2D ----------
{ id:'qr', name:'QR Code', group:'2D · Matrix', engine:'bwip', code:'qrcode', example:'https://anthropic.com', desc:'Quick Response code. Two-dimensional symbology that encodes large amounts of text, URLs, contacts, or binary data.', spec:['Up to 4 KB','Error correction','Ubiquitous'] },
{ id:'dm', name:'Data Matrix', group:'2D · Matrix', engine:'bwip', code:'datamatrix', example:'DataMatrix-Example-2026', desc:'High-density 2D symbology used in electronics, automotive, and aerospace manufacturing.', spec:['Up to 2335 chars','Square / rect','Industrial'] },
{ id:'pdf417', name:'PDF417', group:'2D · Matrix', engine:'bwip', code:'pdf417', example:'PDF417 multi-row stacked symbology', desc:'Stacked linear 2D barcode used on ID cards, boarding passes, and shipping labels.', spec:['Up to 1850 chars','Stacked rows','ID & travel'] },
];
/* =========================================================
State
========================================================= */
let currentFormat = FORMATS[0];
const generated = []; // {format, data, canvas, error, showText}
/* =========================================================
Render sidebar
========================================================= */
function renderSidebar(){
const list = document.getElementById('formatList');
document.getElementById('formatCount').textContent = `${FORMATS.length} FORMATS`;
const groups = {};
FORMATS.forEach(f => { (groups[f.group] = groups[f.group] || []).push(f); });
let html = '';
for (const g of Object.keys(groups)){
html += `<div class="group"><div class="group-title">${g}</div>`;
for (const f of groups[g]){
html += `<div class="fmt" data-id="${f.id}">
<span class="fmt-name">${f.name}</span>
<span class="fmt-code">${f.id}</span>
</div>`;
}
html += '</div>';
}
list.innerHTML = html;
list.querySelectorAll('.fmt').forEach(el => {
el.addEventListener('click', () => {
const fmt = FORMATS.find(f => f.id === el.dataset.id);
selectFormat(fmt);
});
});
}
/* =========================================================
Select format & show example
========================================================= */
function selectFormat(fmt){
currentFormat = fmt;
document.querySelectorAll('.fmt').forEach(el => el.classList.toggle('active', el.dataset.id === fmt.id));
document.getElementById('selName').textContent = fmt.name;
document.getElementById('selType').textContent = fmt.group;
document.getElementById('selDesc').textContent = fmt.desc;
const specHtml = (fmt.spec || []).map(s => `<span><b>·</b> ${s}</span>`).join('');
document.getElementById('selSpec').innerHTML = specHtml;
// Render example
const wrap = document.getElementById('exampleWrap');
wrap.innerHTML = '';
renderBarcode(fmt, fmt.example, wrap, { exampleMode:true })
.then(() => {
const cap = document.createElement('div');
cap.style.cssText = 'font-size:10px;color:var(--ink-soft);letter-spacing:.08em;text-align:center;margin-top:4px;word-break:break-all';
cap.innerHTML = `<b style="color:var(--ink)">Sample data:</b> ${escapeHtml(fmt.example)}`;
wrap.appendChild(cap);
})
.catch(err => {
wrap.innerHTML = `<div class="empty">Example unavailable</div>`;
});
}
/* =========================================================
Core barcode renderer — returns Promise<HTMLCanvasElement>
========================================================= */
function renderBarcode(fmt, data, container, opts = {}){
return new Promise((resolve, reject) => {
const scale = opts.exampleMode ? 2 : parseInt(document.getElementById('optScale').value || 3, 10);
const showText = (typeof opts.showText === 'boolean')
? opts.showText
: document.getElementById('optShowText').checked;
const canvas = document.createElement('canvas');
try {
if (fmt.engine === 'jsbarcode') {
let codeData = data;
// ISBN: strip any prefix and use EAN13 underneath
if (fmt.id === 'isbn'){
codeData = data.replace(/[-\s]/g,'');
if (codeData.length === 10) {
// convert ISBN-10 to ISBN-13
const base = '978' + codeData.slice(0,9);
let sum = 0;
for (let i=0;i<12;i++) sum += (i%2===0 ? 1 : 3) * parseInt(base[i],10);
const check = (10 - (sum%10)) % 10;
codeData = base + check;
}
}
JsBarcode(canvas, codeData, {
format: fmt.code,
width: scale * 0.8,
height: opts.exampleMode ? 60 : 80,
displayValue: showText,
font: 'JetBrains Mono',
fontSize: opts.exampleMode ? 12 : 14,
margin: 8,
background: '#ffffff',
lineColor: '#14110d'
});
container.appendChild(canvas);
resolve(canvas);
return;
}
if (fmt.engine === 'bwip') {
// bwip-js renders to canvas
const opts2 = {
bcid: fmt.code,
text: data,
scale: opts.exampleMode ? 2 : scale,
height: 10,
includetext: showText,
textxalign: 'center',
backgroundcolor: 'FFFFFF',
textfont: 'JetBrains Mono',
paddingwidth: 4,
paddingheight: 4,
};
// 2D codes don't take height the same way
if (['datamatrix','qrcode','pdf417'].includes(fmt.code)) {
delete opts2.height;
}
bwipjs.toCanvas(canvas, opts2);
container.appendChild(canvas);
resolve(canvas);
return;
}
reject(new Error('Unknown engine'));
} catch (err) {
reject(err);
}
});
}
/* =========================================================
Generate all from textarea
========================================================= */
function generateAll(){
const raw = document.getElementById('dataInput').value;
const lines = raw.split('\n').map(l => l.trim()).filter(l => l.length > 0);
if (lines.length === 0){
toast('Enter at least one line of data');
return;
}
const resultsEl = document.getElementById('results');
resultsEl.innerHTML = '';
generated.length = 0;
const grid = document.createElement('div');
grid.className = 'results-grid';
resultsEl.appendChild(grid);
let successCount = 0;
const globalShowText = document.getElementById('optShowText').checked;
const promises = lines.map((line, idx) => {
const card = document.createElement('div');
card.className = 'result-card';
card.innerHTML = `
<span class="idx">#${String(idx+1).padStart(2,'0')}</span>
<div class="result-canvas-wrap" data-slot="${idx}"></div>
<div class="result-data"><b>${escapeHtml(currentFormat.name)}</b> · ${escapeHtml(line)}</div>
<div class="result-actions">
<button data-act="toggle" data-idx="${idx}" title="Show/hide text below barcode">${globalShowText ? 'Hide text' : 'Show text'}</button>
<button data-act="jpg" data-idx="${idx}">JPG</button>
<button data-act="png" data-idx="${idx}">PNG</button>
</div>
`;
grid.appendChild(card);
const slot = card.querySelector('.result-canvas-wrap');
return renderBarcode(currentFormat, line, slot, { showText: globalShowText })
.then(canvas => {
generated[idx] = { format: currentFormat, data: line, canvas, error:null, showText: globalShowText };
successCount++;
})
.catch(err => {
slot.innerHTML = `<div class="result-error">⚠ ${escapeHtml(err.message || String(err))}<br><small>Check that your data matches the format requirements.</small></div>`;
card.querySelector('.result-actions').style.display = 'none';
generated[idx] = { format: currentFormat, data: line, canvas:null, error: err, showText: globalShowText };
});
});
Promise.all(promises).then(() => {
document.getElementById('resultsStats').innerHTML = `Generated · <b>${successCount}</b> / ${lines.length} · Format <b>${currentFormat.name}</b>`;
// Wire action buttons
grid.querySelectorAll('button[data-act]').forEach(b => {
b.addEventListener('click', () => {
const idx = parseInt(b.dataset.idx, 10);
const act = b.dataset.act;
if (act === 'toggle') {
toggleCardText(idx);
} else {
downloadOne(idx, act);
}
});
});
if (successCount > 0) toast(`Generated ${successCount} barcode${successCount>1?'s':''}`);
});
}
/* =========================================================
Download helpers
========================================================= */
function canvasToBlob(canvas, type, quality){
return new Promise(resolve => canvas.toBlob(resolve, type, quality));
}
function safeName(s){ return s.replace(/[^a-zA-Z0-9_-]/g,'_').slice(0,40) || 'barcode'; }
function toggleCardText(idx){
const item = generated[idx];
if (!item || item.error) return;
item.showText = !item.showText;
// Find the card via the slot's data attribute
const slot = document.querySelector(`.result-canvas-wrap[data-slot="${idx}"]`);
if (!slot) return;
slot.innerHTML = '';
renderBarcode(item.format, item.data, slot, { showText: item.showText })
.then(canvas => {
item.canvas = canvas;
const btn = document.querySelector(`button[data-act="toggle"][data-idx="${idx}"]`);
if (btn) btn.textContent = item.showText ? 'Hide text' : 'Show text';
});
}
async function downloadOne(idx, ext){
const item = generated[idx];
if (!item || !item.canvas) return;
const quality = parseFloat(document.getElementById('optQuality').value || 0.95);
if (ext === 'jpg'){
// Convert to JPG with white background (canvases may have alpha)
const c = document.createElement('canvas');
c.width = item.canvas.width;
c.height = item.canvas.height;
const ctx = c.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0,0,c.width,c.height);
ctx.drawImage(item.canvas, 0, 0);
const blob = await canvasToBlob(c, 'image/jpeg', quality);
saveBlob(blob, `${safeName(item.format.id+'_'+item.data)}.jpg`);
} else {
const blob = await canvasToBlob(item.canvas, 'image/png');
saveBlob(blob, `${safeName(item.format.id+'_'+item.data)}.png`);
}
}
function saveBlob(blob, filename){
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function downloadAll(){
const items = generated.filter(g => g && g.canvas);
if (items.length === 0){ toast('Nothing to download — generate first'); return; }
for (let i=0;i<items.length;i++){
await downloadOne(generated.indexOf(items[i]), 'jpg');
await new Promise(r => setTimeout(r, 120));
}
toast(`Downloaded ${items.length} JPG${items.length>1?'s':''}`);
}
/* =========================================================
UI helpers
========================================================= */
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
let toastTimer = null;
function toast(msg){
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2200);
}
/* =========================================================
Wire up
========================================================= */
document.getElementById('btnGenerate').addEventListener('click', generateAll);
document.getElementById('btnDownloadAll').addEventListener('click', downloadAll);
document.getElementById('btnClear').addEventListener('click', () => {
document.getElementById('dataInput').value = '';
document.getElementById('results').innerHTML = '<div class="results-empty">No barcodes generated yet</div>';
document.getElementById('resultsStats').innerHTML = 'Generated · <b>0</b>';
generated.length = 0;
});
// Re-render example when options change
['optShowText','optScale'].forEach(id => {
document.getElementById(id).addEventListener('change', () => selectFormat(currentFormat));
});
// Quick keyboard shortcut: Ctrl/Cmd + Enter to generate
document.getElementById('dataInput').addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter'){
e.preventDefault();
generateAll();
}
});
// Init
renderSidebar();
selectFormat(FORMATS[0]);
// Preload example data in textarea
document.getElementById('dataInput').value = '5901234123457\n4006381333931\n9780201379624';
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long