v3
This commit is contained in:
@@ -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/
|
||||||
@@ -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
@@ -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;"]
|
||||||
@@ -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.
@@ -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.
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.*
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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: A–Z, 0–9, and a few symbols. Common in defense and industry.', spec:['A–Z 0–9','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:['0–9 + 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 3–131070','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 4–64570','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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||||
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+70
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user