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