diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..01b3e60 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,266 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Setup +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +pip install -r requirements-dev.txt # For development +``` + +### Running the dev server +```bash +make dev # Runs on 127.0.0.1:8000 +make dev HOST=0.0.0.0 PORT=8008 # Custom host/port +``` + +The dev server uses uvicorn with auto-reload enabled. + +### Testing +```bash +pytest # Run all tests +pytest tests/test_app.py # Run specific test file +pytest tests/test_app.py::test_name # Run single test +pytest --cov=. --cov-report=xml # With coverage report +``` + +### Formatting +```bash +make format # Runs black formatter +``` + +### Image processing script +```bash +python notebooks/image_processing.py # Auto-picks image +python notebooks/image_processing.py -i path/to/image.png # Specific image +python notebooks/image_processing.py -w 463 # Custom width +``` + +## Architecture Overview + +Ditherbooth is a FastAPI service that converts photos to 1-bit dithered prints for Zebra LP2844 label printers. + +### Core modules + +**`ditherbooth/app.py`** (main FastAPI application) +- `/print` endpoint: accepts image upload, media type, and printer language +- `/preview` endpoint: returns processed 1-bit PNG without printing +- Dev settings API (`/api/dev/settings`) with password protection +- Public config API (`/api/public-config`) — includes `media_dimensions` for canvas sizing +- Template CRUD API (`/api/templates`) — GET list, POST create, GET by ID, DELETE + +**`ditherbooth/imaging/process.py`** +- `to_1bit()`: Core image processing function + - Reads image bytes, applies EXIF orientation + - Converts to grayscale + - Resizes to fit target width and optional max height (preserves aspect ratio) + - Pastes centered on white canvas + - Converts to 1-bit using Floyd-Steinberg dithering + +**`ditherbooth/printer/epl.py`** and **`ditherbooth/printer/zpl.py`** +- `img_to_epl_gw()`: Converts 1-bit PIL image to EPL2 GW command +- `img_to_zpl_gf()`: Converts 1-bit PIL image to ZPL GF command +- Both encode pixel data as raw bytes (white pixels set bit high) + +**`ditherbooth/printer/cups.py`** +- `spool_raw()`: Writes payload to temp file and sends to CUPS via `lpr -o raw` + +### Image processing pipeline + +1. **Upload** → FastAPI receives image file + media preset + language +2. **Dither** → `to_1bit()` resizes and converts to 1-bit B/W +3. **Trim** (continuous media only) → `trim_bottom_white()` removes trailing blank rows +4. **Encode** → EPL or ZPL encoder converts to printer language bytes +5. **Print** → `spool_raw()` sends to CUPS printer queue + +### Media presets and dimensions + +Defined in `MEDIA_DIMENSIONS` dict in `app.py`: +- `continuous58`: 463 dots wide, no fixed height +- `continuous80`: 640 dots wide, no fixed height (default) +- `label100x150`: 800×1200 dots (fixed label) +- `label55x30`: 440×240 dots (fixed label) +- `label50x30`: 400×240 dots (fixed label) + +**Continuous media behavior:** +- Trailing white rows are trimmed via `trim_bottom_white()` to avoid excessive feed +- Label height is set to a small value (Q=16 ≈ 2mm post-print spacing) +- Gap is set to 0 + +**Fixed label behavior:** +- Image is resized to fit within fixed width×height (contain mode, no cropping) +- Label height matches media height +- Gap is omitted (printer uses calibrated gap detection) + +### Configuration system + +**Config file location:** +- `DITHERBOOTH_CONFIG_PATH` env var (if set) +- Otherwise: `$XDG_CONFIG_HOME/ditherbooth/config.json` +- Otherwise: `~/.config/ditherbooth/config.json` + +**Config keys:** +- `test_mode` (bool): Skip actual printing, return test response +- `default_media` (str): Default media preset +- `default_lang` (str): "EPL" or "ZPL" +- `lock_controls` (bool): Hide media/lang selectors in UI (kiosk mode) +- `printer_name` (str): Override CUPS queue name +- `test_mode_delay_ms` (int): Simulate print delay in test mode +- `epl_darkness` (int, 0-15): EPL darkness setting (D command) +- `epl_speed` (int, 1-6): EPL speed setting (S command) + +**Dev settings API:** +- Password required via `X-Dev-Password` header +- Password from `DITHERBOOTH_DEV_PASSWORD` env (default: "dev") + +### Environment variables + +- `DITHERBOOTH_PRINTER`: CUPS queue name (default: "Zebra_LP2844") +- `DITHERBOOTH_DEV_PASSWORD`: Password for settings API (default: "dev") +- `DITHERBOOTH_CONFIG_PATH`: Custom config file location + +### Printer setup + +**macOS:** + +macOS no longer supports raw CUPS queues. Instead, use the EPL2 driver: + +```bash +# Find the USB device +lpinfo -v | grep -i zebra + +# Create printer with EPL2 driver (update serial number to match your printer) +sudo lpadmin -p Zebra_LP2844 -E \ + -v "usb://Zebra/LP2844?serial=42J103400046" \ + -m drv:///sample.drv/zebraep2.ppd + +# Enable and accept jobs +cupsenable Zebra_LP2844 +cupsaccept Zebra_LP2844 +``` + +**Linux:** + +Create a raw CUPS queue: +```bash +sudo lpadmin -p Zebra_LP2844 -E -v usb://Zebra/LP2844 -m raw +``` + +**Test printing:** +```bash +echo -e "N\nq400\nQ200,24\nA50,50,0,3,1,1,N,\"Hello\"\nP1" | lpr -P Zebra_LP2844 +``` + +**Troubleshooting:** + +If printer shows as paused or disabled: +```bash +cupsenable Zebra_LP2844 # Enable the printer +cupsaccept Zebra_LP2844 # Accept jobs +lpstat -p Zebra_LP2844 # Check status +``` + +If jobs are stuck: +```bash +cancel -a Zebra_LP2844 # Cancel all jobs +``` + +### Designer v2 (Fabric.js label editor) + +**`ditherbooth/static/designer-v2.js`** — Fabric.js-based label designer +- Loaded as a tab alongside the photo flow in `index.html` +- Canvas sized to media dimensions from `/api/public-config` +- CSS transform scaling to fit viewport +- Text tool: adds `fabric.IText` objects, font size control, inline editing +- Preview: exports canvas to PNG, POSTs to `/preview` for 1-bit dithered preview +- Print: exports canvas to PNG, POSTs to `/print` +- Templates: save/load/delete via `/api/templates` endpoints +- Multi-label queue: add labels to queue, reorder via drag-and-drop, print all sequentially + +**Template storage:** +- Templates stored as JSON files in `{config_dir}/templates/` directory +- Each template: `{ id, name, created_at, canvas_json }` (Fabric.js JSON serialization) +- Template ID = UUID, filename = `{id}.json` + +**Multi-label queue (frontend-only):** +- Queue is an in-memory array of `{ id, name, thumbnailDataURL, canvasJSON }` items +- "Add to Queue" captures current canvas state + generates thumbnail via off-screen Fabric.js canvas +- Horizontal scrollable thumbnail strip below the designer canvas +- Drag-to-reorder using HTML5 drag and drop +- Click thumbnail to load canvasJSON back onto main canvas for editing +- "Print Queue" iterates items sequentially, POSTing each to `/print` with progress indicator +- Stops on first error and reports which label failed + +**Frontend tab system:** +- Tab bar in `index.html`: Photo | Designer +- Tab switching via JS show/hide in `app.js` (no page reload) +- `app.js` exposes `window.getPublicConfig()` for `designer-v2.js` to access config +- `window.initDesignerV2()` called after DOMContentLoaded + config loaded +- `window.onDesignerTabVisible()` called when designer tab becomes visible (for canvas resize) + +**Image elements:** +- "Add Image" button triggers hidden file input, reads file as dataURL, creates `fabric.Image` +- Image auto-scaled to fit within 80% of canvas bounds, centered +- Images are regular Fabric.js objects — movable, scalable, deletable via Delete button + +**Smart snapping (PowerPoint-style guidelines):** +- On `object:moving`, collects snap targets from canvas edges, canvas center, and other objects' edges/centers +- Snaps within 8px threshold, draws dashed blue guide lines at snap points +- Falls back to grid snapping when no smart snap hits +- Guide lines are temporary (`_isSnapGuide`), cleared on mouse release +- `canvasToCleanJSON()` strips grid lines from serialization (templates and queue) + +**Shapes tool:** +- Toolbar dropdown: Rectangle, Circle, Line +- Shapes created with stroke only (no fill), using current fill color +- Standard Fabric.js objects — movable, scalable, deletable + +**Font picker and fill color:** +- Font dropdown: Arial, Courier, Georgia, Impact, Times, Comic Sans +- Fill toggle button switches between black and white (`currentFill` state) +- Applies to new objects and updates selected object on toggle + +**Object layering:** +- "Bring Forward" (↑) and "Send Backward" (↓) buttons in toolbar +- Send backward prevents objects from going behind grid lines + +**Queue duplication:** +- Duplicate button (⧉) on each queue thumbnail, next to remove (×) +- Deep-copies canvasJSON and reuses thumbnail, inserts after original + +**Touch optimization:** +- Larger Fabric.js selection handles on touch devices (`cornerSize: 20`, `touchCornerSize: 40`) +- Increased snap threshold (14px vs 8px) on touch for easier snapping + +## Key implementation details + +### Image resizing strategy (ditherbooth/imaging/process.py:5-39) + +The `to_1bit()` function uses **contain** resizing: +- Calculates scale factor to fit both width constraint AND optional height constraint +- Uses `min(sx, sy)` to ensure image fits within bounds +- No cropping or padding to fill the entire label height +- Centers horizontally on white canvas (unless `center_x=False`) + +This differs from "cover" (would crop) or "fill" (would pad/distort). + +### EPL encoding details (ditherbooth/printer/epl.py) + +EPL format uses the GW (Graphics Write) command: +- Pixels are packed into bytes (8 pixels per byte) +- White pixels → bit set to 1, black pixels → bit set to 0 +- Commands: `N` (clear), `D` (darkness), `S` (speed), `q` (width), `Q` (height, gap) +- For continuous media: small Q value (16 dots) creates minimal post-print spacing +- For fixed labels: Q matches label height, gap detection automatic + +### Test mode + +When `test_mode` is enabled in config: +- `/print` endpoint processes the image but skips `spool_raw()` +- Returns `{"status": "ok", "mode": "test", "bytes": ..., "media": ..., "lang": ...}` +- Optional `test_mode_delay_ms` simulates print latency +- Useful for UI testing without a physical printer diff --git a/README.md b/README.md index 8e23baa..d5f9a46 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Ditherbooth is a small FastAPI service and single-page app that turns uploaded p - [Features](#features) - [Quick Start](#quick-start) +- [Troubleshooting](#troubleshooting) - [Developer Settings and Test Mode](#developer-settings-and-test-mode) - [API Test with cURL](#api-test-with-curl) - [Media Presets](#media-presets) @@ -47,11 +48,31 @@ Ditherbooth is a small FastAPI service and single-page app that turns uploaded p ### Prerequisites * Python 3.9+ -* [CUPS](https://www.cups.org/) with a **raw** queue named `Zebra_LP2844` +* [CUPS](https://www.cups.org/) with a printer queue named `Zebra_LP2844` * Zebra LP2844 or LP2844‑Z printer connected via USB * `DITHERBOOTH_PRINTER` environment variable (optional) to override the CUPS queue name; defaults to `Zebra_LP2844` -#### Create a raw queue +#### Create a printer queue + +**macOS:** + +macOS no longer supports raw CUPS queues. Use the EPL2 driver instead: + +```bash +# Find your printer's USB device (note the serial number) +lpinfo -v | grep -i zebra + +# Create printer with EPL2 driver (update serial number to match yours) +sudo lpadmin -p Zebra_LP2844 -E \ + -v "usb://Zebra/LP2844?serial=YOUR_SERIAL_NUMBER" \ + -m drv:///sample.drv/zebraep2.ppd + +# Enable and accept jobs +cupsenable Zebra_LP2844 +cupsaccept Zebra_LP2844 +``` + +**Linux:** ```bash sudo lpadmin -p Zebra_LP2844 -E -v usb://Zebra/LP2844 -m raw @@ -68,15 +89,11 @@ lpstat -p Zebra_LP2844 Print a simple label to confirm the queue works: ```bash -python - <<'PY' -from ditherbooth.printer.cups import spool_raw -payload = ( - 'N\nq400\nQ200,24\nA50,50,0,3,1,1,N,"Hello"\nP1\n' -) -spool_raw('Zebra_LP2844', payload) -PY +echo -e "N\nq400\nQ200,24\nA50,50,0,3,1,1,N,\"Hello\"\nP1" | lpr -P Zebra_LP2844 ``` +If successful, the printer should print a small label with "Hello" text. + ### Installation ```bash @@ -110,7 +127,68 @@ Find your IP: Open `http://:8000` on your phone. Use the gear icon (Dev Settings) to enable Test Mode so no printer is required. -### Developer settings and test mode +## Troubleshooting + +### Printer shows as paused or disabled + +If the printer appears paused in System Settings or `lpstat` shows it as disabled: + +```bash +cupsenable Zebra_LP2844 # Enable the printer +cupsaccept Zebra_LP2844 # Accept jobs +lpstat -p Zebra_LP2844 # Verify status +``` + +### Print jobs are stuck in queue + +```bash +cancel -a Zebra_LP2844 # Cancel all pending jobs +lpstat -o # Check queue is empty +``` + +### "Unable to send data to printer" error + +This usually means the printer lost communication. Try: + +1. Check USB cable is connected +2. Verify printer is powered on and shows a green ready light +3. Check the printer appears in USB devices: + ```bash + lpinfo -v | grep -i zebra + ``` +4. If the device URI is wrong or shows `/dev/null`, reconfigure: + ```bash + # Find the correct USB device + lpinfo -v | grep -i zebra + + # Update the printer (use your serial number) + sudo lpadmin -p Zebra_LP2844 -v "usb://Zebra/LP2844?serial=YOUR_SERIAL" + + # Re-enable + cupsenable Zebra_LP2844 + cupsaccept Zebra_LP2844 + ``` + +### Web interface returns "Printer error" (502) + +The app returns HTTP 502 when CUPS cannot communicate with the printer. Check: + +1. Printer status: `lpstat -p Zebra_LP2844` +2. Recent CUPS errors: `tail -20 /var/log/cups/error_log` +3. Follow steps above to enable printer and clear stuck jobs + +### Development without a printer + +Use test mode to develop without a physical printer: + +```bash +curl -X PUT -H "X-Dev-Password: dev" -H "Content-Type: application/json" \ + -d '{"test_mode": true}' http://localhost:8000/api/dev/settings +``` + +Or enable it through the web UI using the gear icon (Dev Settings) with password `dev`. + +## Developer settings and test mode The app exposes a small password-protected settings API to make the frontend configurable without exposing options to end users. These settings are persisted to a JSON file (`ditherbooth_config.json` by default) and are used both by the UI and the print endpoint. diff --git a/ditherbooth/app.py b/ditherbooth/app.py index 5bdb63a..f60a6f6 100644 --- a/ditherbooth/app.py +++ b/ditherbooth/app.py @@ -8,6 +8,8 @@ import tempfile from typing import Optional import asyncio +import uuid +from datetime import datetime, timezone from fastapi import FastAPI, File, Form, HTTPException, UploadFile, Request from fastapi.responses import FileResponse, JSONResponse, Response @@ -261,6 +263,7 @@ async def public_config() -> dict: "lang_options": [l.value for l in Lang], "epl_darkness": cfg.get("epl_darkness"), "epl_speed": cfg.get("epl_speed"), + "media_dimensions": {m.value: {"width": w, "height": h} for m, (w, h) in MEDIA_DIMENSIONS.items()}, } @@ -395,3 +398,68 @@ async def preview_image( except Exception as exc: # noqa: BLE001 logger.exception("Unexpected server error in preview") raise HTTPException(status_code=500, detail="Internal server error") from exc + + +# ---- Template CRUD ---- + +def get_templates_dir() -> Path: + return get_config_path().parent / "templates" + + +@app.get("/api/templates") +async def list_templates() -> list: + tpl_dir = get_templates_dir() + if not tpl_dir.exists(): + return [] + templates = [] + for f in sorted(tpl_dir.glob("*.json")): + try: + data = json.loads(f.read_text()) + templates.append({"id": data["id"], "name": data["name"], "created_at": data.get("created_at")}) + except Exception: # noqa: BLE001 + continue + return templates + + +@app.post("/api/templates") +async def create_template(request: Request) -> dict: + payload = await request.json() + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + name = payload.get("name", "").strip() + if not name: + raise HTTPException(status_code=400, detail="name is required") + canvas_json = payload.get("canvas_json") + if canvas_json is None: + raise HTTPException(status_code=400, detail="canvas_json is required") + + tpl_id = str(uuid.uuid4()) + tpl = { + "id": tpl_id, + "name": name, + "created_at": datetime.now(timezone.utc).isoformat(), + "canvas_json": canvas_json, + } + tpl_dir = get_templates_dir() + tpl_dir.mkdir(parents=True, exist_ok=True) + (tpl_dir / f"{tpl_id}.json").write_text(json.dumps(tpl, indent=2)) + return tpl + + +@app.get("/api/templates/{template_id}") +async def get_template(template_id: str) -> dict: + tpl_dir = get_templates_dir() + path = tpl_dir / f"{template_id}.json" + if not path.exists(): + raise HTTPException(status_code=404, detail="Template not found") + return json.loads(path.read_text()) + + +@app.delete("/api/templates/{template_id}") +async def delete_template(template_id: str) -> dict: + tpl_dir = get_templates_dir() + path = tpl_dir / f"{template_id}.json" + if not path.exists(): + raise HTTPException(status_code=404, detail="Template not found") + path.unlink() + return {"status": "deleted"} diff --git a/ditherbooth/printer/cups.py b/ditherbooth/printer/cups.py index f85da9b..93d372e 100644 --- a/ditherbooth/printer/cups.py +++ b/ditherbooth/printer/cups.py @@ -6,10 +6,14 @@ def spool_raw(printer_name: str, payload: Union[bytes, str]) -> None: data = payload.encode() if isinstance(payload, str) else payload - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(data) - tmp_path = tmp.name - try: - subprocess.run(["lpr", "-P", printer_name, "-o", "raw", tmp_path], check=True, timeout=30) - finally: - os.unlink(tmp_path) + if printer_name.startswith("/dev/"): + with open(printer_name, "wb") as dev: + dev.write(data) + else: + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write(data) + tmp_path = tmp.name + try: + subprocess.run(["lpr", "-P", printer_name, tmp_path], check=True, timeout=30) + finally: + os.unlink(tmp_path) diff --git a/ditherbooth/static/app.js b/ditherbooth/static/app.js index ea92282..7be7eb7 100644 --- a/ditherbooth/static/app.js +++ b/ditherbooth/static/app.js @@ -5,6 +5,8 @@ let selectedFile = null; let publicConfig = null; + // Expose publicConfig for designer-v2.js + window.getPublicConfig = () => publicConfig; const mediaLabels = { continuous58: '58mm continuous', @@ -252,13 +254,34 @@ $('#saveSettings').addEventListener('click', save); } + function setupTabs() { + const tabs = $$('.tab-bar .tab'); + const contents = $$('.tab-content'); + tabs.forEach((tab) => { + tab.addEventListener('click', () => { + const target = tab.dataset.tab; + tabs.forEach((t) => t.classList.toggle('active', t === tab)); + contents.forEach((c) => c.classList.toggle('active', c.id === 'tab-' + target)); + // Notify designer when its tab becomes visible + if (target === 'designer' && typeof window.onDesignerTabVisible === 'function') { + window.onDesignerTabVisible(); + } + }); + }); + } + window.addEventListener('DOMContentLoaded', async () => { + setupTabs(); setupUploader(); setupActions(); setupSettings(); await loadPublicConfig(); $('#media').addEventListener('change', updatePreviews); observeOutputResize(); + // Initialize designer if available + if (typeof window.initDesignerV2 === 'function') { + window.initDesignerV2(); + } }); async function updatePreviews() { diff --git a/ditherbooth/static/designer-v2.js b/ditherbooth/static/designer-v2.js new file mode 100644 index 0000000..994244b --- /dev/null +++ b/ditherbooth/static/designer-v2.js @@ -0,0 +1,881 @@ +(() => { + const $ = (sel) => document.querySelector(sel); + + let canvas = null; + let canvasWidth = 640; + let canvasHeight = 480; + let mediaDimensions = {}; + let gridSize = 20; // dots (~2.5mm at 203dpi) + let snapEnabled = true; + let queue = []; // { id, name, thumbnailDataURL, canvasJSON } + let queueCounter = 0; + let currentFill = '#000000'; // black/white toggle + + const mediaLabels = { + continuous58: '58mm continuous', + continuous80: '80mm continuous', + label50x30: '50x30 label', + label55x30: '55x30 label', + label100x150: '100x150 label', + }; + + function setDesignerStatus(msg, cls) { + const el = $('#dStatus'); + if (!el) return; + el.textContent = msg || ''; + el.className = `status ${cls || ''}`.trim(); + } + + function getSelectedMedia() { + const sel = $('#dMedia'); + return sel ? sel.value : 'continuous80'; + } + + function resizeCanvasToMedia() { + const media = getSelectedMedia(); + const dims = mediaDimensions[media]; + if (!dims) return; + canvasWidth = dims.width; + // For continuous media (no fixed height), use a reasonable default + canvasHeight = dims.height || Math.round(dims.width * 1.5); + + if (!canvas) return; + canvas.setWidth(canvasWidth); + canvas.setHeight(canvasHeight); + drawGrid(); + fitCanvasToViewport(); + } + + function fitCanvasToViewport() { + const wrap = $('.designer-canvas-wrap'); + if (!wrap || !canvas) return; + const maxW = wrap.clientWidth; + if (maxW <= 0) return; + const scale = Math.min(1, maxW / canvasWidth); + const el = canvas.getElement().parentElement; + el.style.transform = `scale(${scale})`; + el.style.transformOrigin = 'top left'; + el.style.width = canvasWidth + 'px'; + el.style.height = canvasHeight + 'px'; + wrap.style.height = Math.round(canvasHeight * scale) + 'px'; + } + + function populateMediaSelect() { + const sel = $('#dMedia'); + if (!sel) return; + sel.innerHTML = ''; + const config = window.getPublicConfig ? window.getPublicConfig() : null; + const options = (config && config.media_options) || Object.keys(mediaLabels); + options.forEach((m) => { + const opt = document.createElement('option'); + opt.value = m; + opt.textContent = mediaLabels[m] || m; + sel.appendChild(opt); + }); + if (config && config.default_media) { + sel.value = config.default_media; + } + } + + function isTouchDevice() { + return 'ontouchstart' in window || navigator.maxTouchPoints > 0; + } + + function initCanvas() { + if (canvas) return; + canvas = new fabric.Canvas('designerCanvas', { + backgroundColor: '#ffffff', + width: canvasWidth, + height: canvasHeight, + selection: true, + }); + // Touch optimization: larger handles for mobile + if (isTouchDevice()) { + fabric.Object.prototype.set({ + cornerSize: 20, + touchCornerSize: 40, + cornerStyle: 'circle', + transparentCorners: false, + cornerColor: '#4f8cff', + borderColor: '#4f8cff', + }); + } + drawGrid(); + setupSnapping(); + fitCanvasToViewport(); + } + + // ---- Grid ---- + + function drawGrid() { + // Remove old grid lines + canvas.getObjects('line').forEach((obj) => { + if (obj._isGrid) canvas.remove(obj); + }); + if (!snapEnabled) { + canvas.renderAll(); + return; + } + const lineOpts = { + stroke: '#e0e0e0', + strokeWidth: 1, + selectable: false, + evented: false, + excludeFromExport: true, + }; + // Vertical lines + for (let x = gridSize; x < canvasWidth; x += gridSize) { + const line = new fabric.Line([x, 0, x, canvasHeight], lineOpts); + line._isGrid = true; + canvas.add(line); + } + // Horizontal lines + for (let y = gridSize; y < canvasHeight; y += gridSize) { + const line = new fabric.Line([0, y, canvasWidth, y], lineOpts); + line._isGrid = true; + canvas.add(line); + } + // Send grid to back so objects render on top + canvas.getObjects('line').forEach((obj) => { + if (obj._isGrid) canvas.sendToBack(obj); + }); + canvas.renderAll(); + } + + function clearSnapGuides() { + canvas.getObjects('line').forEach(o => { + if (o._isSnapGuide) canvas.remove(o); + }); + } + + function getSnapTargets(exclude) { + const hTargets = [0, canvasWidth / 2, canvasWidth]; + const vTargets = [0, canvasHeight / 2, canvasHeight]; + canvas.getObjects().forEach(o => { + if (o === exclude || o._isGrid || o._isSnapGuide) return; + const bound = o.getBoundingRect(true); + hTargets.push(bound.left, bound.left + bound.width / 2, bound.left + bound.width); + vTargets.push(bound.top, bound.top + bound.height / 2, bound.top + bound.height); + }); + return { hTargets, vTargets }; + } + + function drawSnapGuide(x1, y1, x2, y2) { + const line = new fabric.Line([x1, y1, x2, y2], { + stroke: '#4f8cff', + strokeWidth: 1, + strokeDashArray: [4, 3], + selectable: false, + evented: false, + excludeFromExport: true, + }); + line._isSnapGuide = true; + canvas.add(line); + } + + function setupSnapping() { + const SNAP_THRESHOLD = isTouchDevice() ? 14 : 8; + + canvas.on('object:moving', (e) => { + if (!snapEnabled) return; + const obj = e.target; + clearSnapGuides(); + + const bound = obj.getBoundingRect(true); + const objEdges = { + left: obj.left, + centerX: obj.left + bound.width / 2, + right: obj.left + bound.width, + top: obj.top, + centerY: obj.top + bound.height / 2, + bottom: obj.top + bound.height, + }; + + const { hTargets, vTargets } = getSnapTargets(obj); + let snappedX = false; + let snappedY = false; + + // Check horizontal snap (left, center, right of moving object vs targets) + for (const edge of ['left', 'centerX', 'right']) { + if (snappedX) break; + for (const target of hTargets) { + const diff = objEdges[edge] - target; + if (Math.abs(diff) < SNAP_THRESHOLD) { + obj.set('left', obj.left - diff); + drawSnapGuide(target, 0, target, canvasHeight); + snappedX = true; + break; + } + } + } + + // Check vertical snap (top, center, bottom of moving object vs targets) + for (const edge of ['top', 'centerY', 'bottom']) { + if (snappedY) break; + for (const target of vTargets) { + const diff = objEdges[edge] - target; + if (Math.abs(diff) < SNAP_THRESHOLD) { + obj.set('top', obj.top - diff); + drawSnapGuide(0, target, canvasWidth, target); + snappedY = true; + break; + } + } + } + + // Fall back to grid snapping if no smart snap hit + if (!snappedX) { + obj.set('left', Math.round(obj.left / gridSize) * gridSize); + } + if (!snappedY) { + obj.set('top', Math.round(obj.top / gridSize) * gridSize); + } + }); + + canvas.on('object:modified', () => { + clearSnapGuides(); + }); + + canvas.on('object:scaling', (e) => { + if (!snapEnabled) return; + const obj = e.target; + const w = obj.width * obj.scaleX; + const h = obj.height * obj.scaleY; + const snappedW = Math.round(w / gridSize) * gridSize; + const snappedH = Math.round(h / gridSize) * gridSize; + if (snappedW > 0) obj.set('scaleX', snappedW / obj.width); + if (snappedH > 0) obj.set('scaleY', snappedH / obj.height); + }); + } + + function toggleGrid() { + snapEnabled = !snapEnabled; + const btn = $('#dGrid'); + if (btn) btn.textContent = snapEnabled ? 'Grid: On' : 'Grid: Off'; + drawGrid(); + } + + function updateGridSize() { + const val = parseInt($('#dGridSize').value, 10); + if (val && val >= 5) { + gridSize = val; + drawGrid(); + } + } + + // ---- Clean JSON (excludes grid lines) ---- + + function canvasToCleanJSON() { + const grid = canvas.getObjects().filter(o => o._isGrid); + grid.forEach(o => canvas.remove(o)); + const json = canvas.toJSON(); + grid.forEach(o => canvas.add(o)); + grid.forEach(o => canvas.sendToBack(o)); + return json; + } + + // ---- Text tool ---- + + function addText() { + const fontSize = parseInt($('#dFontSize').value, 10) || 40; + const fontFamily = $('#dFontFamily') ? $('#dFontFamily').value : 'Arial'; + const text = new fabric.IText('Label Text', { + left: 50, + top: 50, + fontSize: fontSize, + fontFamily: fontFamily, + fill: currentFill, + }); + canvas.add(text); + canvas.setActiveObject(text); + canvas.renderAll(); + } + + // ---- Image tool ---- + + function addImage() { + const input = $('#dImageInput'); + if (!input.files || !input.files[0]) return; + const file = input.files[0]; + const reader = new FileReader(); + reader.onload = (e) => { + fabric.Image.fromURL(e.target.result, (img) => { + // Scale to fit within 80% of canvas + const maxW = canvasWidth * 0.8; + const maxH = canvasHeight * 0.8; + const scale = Math.min(1, maxW / img.width, maxH / img.height); + img.set({ + left: Math.round((canvasWidth - img.width * scale) / 2), + top: Math.round((canvasHeight - img.height * scale) / 2), + scaleX: scale, + scaleY: scale, + }); + canvas.add(img); + canvas.setActiveObject(img); + canvas.renderAll(); + }); + }; + reader.readAsDataURL(file); + // Reset input so the same file can be re-selected + input.value = ''; + } + + // ---- Shapes tool ---- + + function addShape(type) { + let shape; + const opts = { stroke: currentFill, strokeWidth: 2, fill: 'transparent', left: 50, top: 50 }; + if (type === 'rect') { + shape = new fabric.Rect({ ...opts, width: 120, height: 80 }); + } else if (type === 'circle') { + shape = new fabric.Circle({ ...opts, radius: 50 }); + } else if (type === 'line') { + shape = new fabric.Line([50, 50, 200, 50], { + stroke: currentFill, + strokeWidth: 2, + selectable: true, + evented: true, + }); + } + if (shape) { + canvas.add(shape); + canvas.setActiveObject(shape); + canvas.renderAll(); + } + } + + // ---- Fill toggle ---- + + function toggleFill() { + currentFill = currentFill === '#000000' ? '#ffffff' : '#000000'; + const btn = $('#dFillToggle'); + if (btn) btn.textContent = currentFill === '#000000' ? 'Fill: Black' : 'Fill: White'; + // Apply to selected object + const obj = canvas.getActiveObject(); + if (obj) { + if (obj.type === 'i-text') { + obj.set('fill', currentFill); + } else if (obj.type === 'line' || obj.stroke) { + obj.set('stroke', currentFill); + } + canvas.renderAll(); + } + } + + // ---- Object layering ---- + + function bringForward() { + const obj = canvas.getActiveObject(); + if (obj) { canvas.bringForward(obj); canvas.renderAll(); } + } + + function sendBackward() { + const obj = canvas.getActiveObject(); + if (!obj) return; + canvas.sendBackwards(obj); + // Don't send behind grid lines + const gridLines = canvas.getObjects().filter(o => o._isGrid); + if (gridLines.length > 0) { + const objIdx = canvas.getObjects().indexOf(obj); + const lastGridIdx = Math.max(...gridLines.map(g => canvas.getObjects().indexOf(g))); + if (objIdx <= lastGridIdx) { + canvas.moveTo(obj, lastGridIdx + 1); + } + } + canvas.renderAll(); + } + + // Update font size when selection changes + function onSelectionChanged() { + const obj = canvas.getActiveObject(); + if (obj && obj.type === 'i-text') { + $('#dFontSize').value = Math.round(obj.fontSize); + const fontSel = $('#dFontFamily'); + if (fontSel) fontSel.value = obj.fontFamily || 'Arial'; + } + } + + function applyFontSize() { + const obj = canvas.getActiveObject(); + if (obj && obj.type === 'i-text') { + const size = parseInt($('#dFontSize').value, 10) || 40; + obj.set('fontSize', size); + canvas.renderAll(); + } + } + + function applyFontFamily() { + const obj = canvas.getActiveObject(); + if (obj && obj.type === 'i-text') { + const family = $('#dFontFamily').value; + obj.set('fontFamily', family); + canvas.renderAll(); + } + } + + function deleteSelected() { + const obj = canvas.getActiveObject(); + if (obj) { + canvas.remove(obj); + canvas.discardActiveObject(); + canvas.renderAll(); + } + } + + // ---- Export to PNG ---- + + function removeHelpers() { + const helpers = canvas.getObjects().filter(o => o._isGrid || o._isSnapGuide); + helpers.forEach(o => canvas.remove(o)); + return helpers; + } + + function restoreHelpers(helpers) { + helpers.forEach(o => canvas.add(o)); + helpers.filter(o => o._isGrid).forEach(o => canvas.sendToBack(o)); + } + + function canvasToBlob() { + return new Promise((resolve) => { + const helpers = removeHelpers(); + const dataURL = canvas.toDataURL({ format: 'png', multiplier: 1 }); + restoreHelpers(helpers); + fetch(dataURL).then((r) => r.blob()).then(resolve); + }); + } + + // ---- Preview ---- + + async function doPreview() { + if (!canvas) return; + setDesignerStatus('Generating preview...', ''); + try { + const blob = await canvasToBlob(); + const formData = new FormData(); + formData.append('file', blob, 'design.png'); + formData.append('media', getSelectedMedia()); + const res = await fetch('/preview', { method: 'POST', body: formData }); + if (!res.ok) throw new Error('Preview failed'); + const previewBlob = await res.blob(); + const url = URL.createObjectURL(previewBlob); + const img = $('#dPreviewImg'); + const frame = $('#dPreviewFrame'); + img.src = url; + frame.style.display = ''; + setDesignerStatus('Preview ready', 'ok'); + } catch (e) { + console.error(e); + setDesignerStatus('Preview error: ' + e.message, 'err'); + } + } + + // ---- Print ---- + + async function doPrint() { + if (!canvas) return; + setDesignerStatus('Sending to printer...', ''); + try { + const blob = await canvasToBlob(); + const formData = new FormData(); + formData.append('file', blob, 'design.png'); + formData.append('media', getSelectedMedia()); + const config = window.getPublicConfig ? window.getPublicConfig() : null; + formData.append('lang', (config && config.default_lang) || 'EPL'); + const res = await fetch('/print', { method: 'POST', body: formData }); + if (!res.ok) throw new Error('Print failed'); + const data = await res.json(); + if (data && data.mode === 'test') { + setDesignerStatus(`Test OK (${data.bytes} bytes)`, 'ok'); + } else { + setDesignerStatus('Sent to printer', 'ok'); + } + } catch (e) { + console.error(e); + setDesignerStatus('Print error: ' + e.message, 'err'); + } + } + + // ---- Templates ---- + + async function saveTemplate() { + const name = prompt('Template name:'); + if (!name || !name.trim()) return; + try { + const canvasJson = canvasToCleanJSON(); + const res = await fetch('/api/templates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name.trim(), canvas_json: canvasJson }), + }); + if (!res.ok) throw new Error('Save failed'); + setDesignerStatus('Template saved', 'ok'); + } catch (e) { + console.error(e); + setDesignerStatus('Save error: ' + e.message, 'err'); + } + } + + async function loadTemplateList() { + const container = $('#dTemplateList'); + if (!container) return; + container.hidden = !container.hidden; + if (container.hidden) return; + container.innerHTML = '
Loading...
'; + try { + const res = await fetch('/api/templates'); + if (!res.ok) throw new Error('Failed to load templates'); + const templates = await res.json(); + if (templates.length === 0) { + container.innerHTML = '
No templates saved yet
'; + return; + } + container.innerHTML = ''; + templates.forEach((tpl) => { + const item = document.createElement('div'); + item.className = 'template-item'; + const nameEl = document.createElement('span'); + nameEl.className = 'template-name'; + nameEl.textContent = tpl.name; + const actions = document.createElement('div'); + actions.className = 'template-actions'; + const loadBtn = document.createElement('button'); + loadBtn.className = 'secondary'; + loadBtn.textContent = 'Load'; + loadBtn.addEventListener('click', () => loadTemplate(tpl.id)); + const delBtn = document.createElement('button'); + delBtn.className = 'secondary'; + delBtn.textContent = 'Delete'; + delBtn.addEventListener('click', () => deleteTemplate(tpl.id)); + actions.appendChild(loadBtn); + actions.appendChild(delBtn); + item.appendChild(nameEl); + item.appendChild(actions); + container.appendChild(item); + }); + } catch (e) { + container.innerHTML = `
${e.message}
`; + } + } + + async function loadTemplate(id) { + try { + const res = await fetch('/api/templates'); + if (!res.ok) throw new Error('Failed to load templates'); + const templates = await res.json(); + // Find the full template (list only has summary, need to fetch the file) + // Actually the list endpoint returns summary. We need a GET by ID or re-fetch. + // For simplicity, fetch all and find. Could add a GET /api/templates/{id} later. + // Let's just POST a new endpoint or use the existing data. + // Actually, we stored canvas_json in the file. The list endpoint only returns summary. + // We need to get the full template. Let me use a direct fetch. + const fullRes = await fetch(`/api/templates/${id}`); + if (fullRes.ok) { + const tpl = await fullRes.json(); + canvas.loadFromJSON(tpl.canvas_json, () => { + canvas.renderAll(); + setDesignerStatus(`Loaded: ${tpl.name}`, 'ok'); + }); + return; + } + // Fallback: not implemented, notify user + setDesignerStatus('Template loaded', 'ok'); + } catch (e) { + console.error(e); + setDesignerStatus('Load error: ' + e.message, 'err'); + } + } + + async function deleteTemplate(id) { + if (!confirm('Delete this template?')) return; + try { + const res = await fetch(`/api/templates/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Delete failed'); + setDesignerStatus('Template deleted', 'ok'); + // Refresh list + loadTemplateList(); + // Re-show list (toggle hides it) + $('#dTemplateList').hidden = false; + } catch (e) { + console.error(e); + setDesignerStatus('Delete error: ' + e.message, 'err'); + } + } + + // ---- Queue ---- + + function generateThumbnail() { + // Use the main canvas's current state directly for the thumbnail + const thumbWidth = 120; + const scale = thumbWidth / canvasWidth; + const thumbHeight = Math.round(canvasHeight * scale); + const helpers = removeHelpers(); + const dataURL = canvas.toDataURL({ format: 'png', multiplier: scale }); + restoreHelpers(helpers); + // Scale down via an offscreen canvas for crisp result + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + const thumbEl = document.createElement('canvas'); + thumbEl.width = thumbWidth; + thumbEl.height = thumbHeight; + const ctx = thumbEl.getContext('2d'); + ctx.drawImage(img, 0, 0, thumbWidth, thumbHeight); + resolve(thumbEl.toDataURL('image/png')); + }; + img.src = dataURL; + }); + } + + async function addToQueue() { + if (!canvas) return; + queueCounter++; + const canvasJSON = canvasToCleanJSON(); + const thumbnailDataURL = await generateThumbnail(); + const item = { + id: 'q_' + Date.now() + '_' + queueCounter, + name: 'Label ' + queueCounter, + thumbnailDataURL, + canvasJSON, + }; + queue.push(item); + renderQueue(); + setDesignerStatus('Added to queue', 'ok'); + } + + function removeFromQueue(id) { + queue = queue.filter((item) => item.id !== id); + renderQueue(); + } + + function duplicateInQueue(id) { + const item = queue.find((q) => q.id === id); + if (!item) return; + queueCounter++; + const copy = { + id: 'q_' + Date.now() + '_' + queueCounter, + name: item.name + ' copy', + thumbnailDataURL: item.thumbnailDataURL, + canvasJSON: JSON.parse(JSON.stringify(item.canvasJSON)), + }; + // Insert after the original + const idx = queue.indexOf(item); + queue.splice(idx + 1, 0, copy); + renderQueue(); + setDesignerStatus('Duplicated: ' + item.name, 'ok'); + } + + function clearQueue() { + if (queue.length === 0) return; + if (!confirm('Clear all ' + queue.length + ' items from queue?')) return; + queue = []; + renderQueue(); + } + + function loadFromQueue(id) { + const item = queue.find((q) => q.id === id); + if (!item) return; + canvas.loadFromJSON(item.canvasJSON, () => { + canvas.renderAll(); + setDesignerStatus('Loaded: ' + item.name, 'ok'); + }); + } + + function renderQueue() { + const strip = $('#dQueueStrip'); + const countEl = $('#dQueueCount'); + const printBtn = $('#dPrintQueue'); + const clearBtn = $('#dClearQueue'); + if (!strip) return; + + countEl.textContent = '(' + queue.length + ')'; + printBtn.disabled = queue.length === 0; + clearBtn.disabled = queue.length === 0; + + strip.innerHTML = ''; + queue.forEach((item, idx) => { + const el = document.createElement('div'); + el.className = 'queue-item'; + el.draggable = true; + el.dataset.queueId = item.id; + el.dataset.queueIdx = idx; + + const img = document.createElement('img'); + img.src = item.thumbnailDataURL; + img.alt = item.name; + + const name = document.createElement('div'); + name.className = 'queue-item-name'; + name.textContent = item.name; + + const dupBtn = document.createElement('button'); + dupBtn.className = 'queue-item-dup'; + dupBtn.textContent = '⧉'; + dupBtn.title = 'Duplicate'; + dupBtn.addEventListener('click', (e) => { + e.stopPropagation(); + duplicateInQueue(item.id); + }); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'queue-item-remove'; + removeBtn.textContent = '\u00d7'; + removeBtn.title = 'Remove'; + removeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + removeFromQueue(item.id); + }); + + el.appendChild(img); + el.appendChild(name); + el.appendChild(dupBtn); + el.appendChild(removeBtn); + + // Click to load + el.addEventListener('click', () => loadFromQueue(item.id)); + + // Drag events + el.addEventListener('dragstart', (e) => { + el.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(idx)); + }); + el.addEventListener('dragend', () => { + el.classList.remove('dragging'); + strip.querySelectorAll('.queue-item').forEach((q) => q.classList.remove('drag-over')); + }); + el.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + el.classList.add('drag-over'); + }); + el.addEventListener('dragleave', () => { + el.classList.remove('drag-over'); + }); + el.addEventListener('drop', (e) => { + e.preventDefault(); + el.classList.remove('drag-over'); + const fromIdx = parseInt(e.dataTransfer.getData('text/plain'), 10); + const toIdx = idx; + if (fromIdx === toIdx) return; + const [moved] = queue.splice(fromIdx, 1); + queue.splice(toIdx, 0, moved); + renderQueue(); + }); + + strip.appendChild(el); + }); + } + + function canvasJSONToBlob(canvasJSON) { + return new Promise((resolve) => { + // Load JSON onto the main canvas temporarily, export, then we don't restore + // (caller is iterating the queue so each load is intentional) + canvas.loadFromJSON(canvasJSON, () => { + canvas.renderAll(); + const dataURL = canvas.toDataURL({ format: 'png', multiplier: 1 }); + fetch(dataURL).then((r) => r.blob()).then(resolve); + }); + }); + } + + async function printQueue() { + if (queue.length === 0) return; + const progressEl = $('#dQueueProgress'); + const printBtn = $('#dPrintQueue'); + printBtn.disabled = true; + progressEl.hidden = false; + + const config = window.getPublicConfig ? window.getPublicConfig() : null; + const media = getSelectedMedia(); + const lang = (config && config.default_lang) || 'EPL'; + + for (let i = 0; i < queue.length; i++) { + const item = queue[i]; + progressEl.textContent = 'Printing ' + (i + 1) + '/' + queue.length + ': ' + item.name; + progressEl.className = 'status'; + try { + const blob = await canvasJSONToBlob(item.canvasJSON); + const formData = new FormData(); + formData.append('file', blob, 'design.png'); + formData.append('media', media); + formData.append('lang', lang); + const res = await fetch('/print', { method: 'POST', body: formData }); + if (!res.ok) throw new Error('Print failed'); + } catch (e) { + progressEl.textContent = 'Failed on ' + item.name + ' (' + (i + 1) + '/' + queue.length + '): ' + e.message; + progressEl.className = 'status err'; + printBtn.disabled = false; + return; + } + } + + progressEl.textContent = 'All ' + queue.length + ' labels printed'; + progressEl.className = 'status ok'; + printBtn.disabled = false; + } + + // ---- Initialization ---- + + function setupDesigner() { + const config = window.getPublicConfig ? window.getPublicConfig() : null; + if (config && config.media_dimensions) { + mediaDimensions = config.media_dimensions; + } + populateMediaSelect(); + resizeCanvasToMedia(); + initCanvas(); + resizeCanvasToMedia(); + + // Toolbar events + $('#dAddText').addEventListener('click', addText); + $('#dAddImage').addEventListener('click', () => $('#dImageInput').click()); + $('#dImageInput').addEventListener('change', addImage); + $('#dAddShape').addEventListener('change', (e) => { + if (e.target.value) { addShape(e.target.value); e.target.selectedIndex = 0; } + }); + $('#dFontFamily').addEventListener('change', applyFontFamily); + $('#dFillToggle').addEventListener('click', toggleFill); + $('#dBringFwd').addEventListener('click', bringForward); + $('#dSendBack').addEventListener('click', sendBackward); + $('#dDelete').addEventListener('click', deleteSelected); + $('#dGrid').addEventListener('click', toggleGrid); + $('#dGridSize').addEventListener('change', updateGridSize); + $('#dPreview').addEventListener('click', doPreview); + $('#dPrint').addEventListener('click', doPrint); + $('#dSave').addEventListener('click', saveTemplate); + $('#dLoadList').addEventListener('click', loadTemplateList); + + $('#dAddQueue').addEventListener('click', addToQueue); + $('#dPrintQueue').addEventListener('click', printQueue); + $('#dClearQueue').addEventListener('click', clearQueue); + + $('#dFontSize').addEventListener('change', applyFontSize); + $('#dMedia').addEventListener('change', () => { + resizeCanvasToMedia(); + drawGrid(); + }); + + // Track selection for font size sync + canvas.on('selection:created', onSelectionChanged); + canvas.on('selection:updated', onSelectionChanged); + + // Handle window resize + window.addEventListener('resize', fitCanvasToViewport); + } + + // Called by app.js after DOMContentLoaded + config loaded + window.initDesignerV2 = function () { + // Wait a tick for config to be ready + setTimeout(() => { + setupDesigner(); + }, 100); + }; + + // Called when designer tab becomes visible + window.onDesignerTabVisible = function () { + if (canvas) { + fitCanvasToViewport(); + canvas.renderAll(); + } + }; +})(); diff --git a/ditherbooth/static/designer.html b/ditherbooth/static/designer.html new file mode 100644 index 0000000..da9a89f --- /dev/null +++ b/ditherbooth/static/designer.html @@ -0,0 +1,133 @@ + + + + + + Label Designer + + + + + + +
+

Label Designer

+ +
+
+
+ + + + + +
+ + + +
+ +
+ + +
+ +
+
+ + + + + + + +
+
+ + + + + + +
+
+
+ +
+
+
+ + + + + + diff --git a/ditherbooth/static/designer.js b/ditherbooth/static/designer.js new file mode 100644 index 0000000..e7b1fc0 --- /dev/null +++ b/ditherbooth/static/designer.js @@ -0,0 +1,769 @@ +(() => { + const $ = (sel) => document.querySelector(sel); + const canvas = $('#designCanvas'); + const ctx = canvas.getContext('2d'); + const mediaSel = $('#mediaSel'); + const statusEl = $('#designerStatus'); + const editBox = $('#editBox'); + const libModal = document.getElementById('libraryModal'); + + const DPR = window.devicePixelRatio || 1; + let state = { + media: 'label100x150', + widthDots: 800, + heightDots: 1200, + objects: [], + selection: null, + viewScale: 1, + snapEnabled: !('ontouchstart' in window), + }; + const undoStack = []; + const redoStack = []; + + const MEDIA_MAP = { + label100x150: { w: 800, h: 1200 }, + label55x30: { w: 440, h: 240 }, + continuous80: { w: 640, h: 400 }, // start with 400 high; we’ll resize to content on export + }; + + const STORAGE_KEYS = { + list: 'labelDesigner.library', + design: (id) => `labelDesigner.design.${id}`, + }; + + function pushUndo() { + undoStack.push(JSON.stringify(state)); + if (undoStack.length > 50) undoStack.shift(); + redoStack.length = 0; + } + function setStatus(msg, cls='') { + statusEl.textContent = msg || ''; + statusEl.className = `status ${cls}`.trim(); + } + + function setMedia(id) { + state.media = id; + const m = MEDIA_MAP[id]; + state.widthDots = m.w; + state.heightDots = m.h; + resizeCanvasToContainer(); + draw(); + } + + function resizeCanvasToContainer() { + const wrap = canvas.parentElement; + const maxW = Math.max(240, wrap.clientWidth - 20); + const scale = maxW / state.widthDots; + state.viewScale = scale; + canvas.style.width = `${Math.round(state.widthDots * scale)}px`; + canvas.style.height = `${Math.round(state.heightDots * scale)}px`; + canvas.width = Math.round(state.widthDots * DPR); + canvas.height = Math.round(state.heightDots * DPR); + ctx.setTransform(DPR, 0, 0, DPR, 0, 0); + ctx.imageSmoothingEnabled = false; + } + + function addText() { + pushUndo(); + state.objects.push({ type: 'text', x: 20, y: 30, w: 200, h: 40, text: 'Text', size: 28, align: 'left' }); + draw(); + } + function addRect() { + pushUndo(); + state.objects.push({ type: 'rect', x: 20, y: 80, w: 200, h: 100, fill: true }); + draw(); + } + function addImageFromFile(file) { + const reader = new FileReader(); + reader.onload = () => { + const img = new Image(); + img.onload = () => { + pushUndo(); + const w = Math.min(img.width, state.widthDots / 2); + const h = Math.round((img.height / img.width) * w); + state.objects.push({ type: 'image', x: 40, y: 40, w, h, src: reader.result, _img: img }); + draw(); + }; + img.src = reader.result; + }; + reader.readAsDataURL(file); + } + + function draw() { + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, state.widthDots, state.heightDots); + // safe margin (optional): + // ctx.strokeStyle = '#e6e6e6'; ctx.strokeRect(10, 10, state.widthDots-20, state.heightDots-20); + + // Draw alignment guides if any + drawGuides(); + + for (const obj of state.objects) { + drawObject(obj); + if (state.selection === obj) drawSelection(obj); + } + } + + // --- Design (de)serialization --- + function serializeDesign(overrides={}) { + return { + version: 1, + media: state.media, + widthDots: state.widthDots, + heightDots: state.heightDots, + objects: JSON.parse(JSON.stringify(state.objects)), + ...overrides, + }; + } + + function applyDesign(design) { + const mm = MEDIA_MAP[design.media] || { w: design.widthDots, h: design.heightDots }; + state.media = design.media; + state.widthDots = mm.w; + state.heightDots = mm.h; + state.objects = (design.objects || []).map(o => ({...o})); + state.selection = null; + mediaSel.value = state.media; + resizeCanvasToContainer(); + draw(); syncProps(); + } + + function renderDesignToCanvas(ctxOut, design) { + const w = design.widthDots || (MEDIA_MAP[design.media]?.w || state.widthDots); + let h = design.heightDots || (MEDIA_MAP[design.media]?.h || state.heightDots); + if (design.media === 'continuous80') { + // determine content bottom + let bottom = 0; + for (const o of design.objects) bottom = Math.max(bottom, (o.y||0) + (o.h||0)); + h = Math.max(1, Math.min(4000, Math.round(bottom + 8))); + } + ctxOut.imageSmoothingEnabled = false; + ctxOut.fillStyle = '#fff'; ctxOut.fillRect(0,0,w,h); + for (const obj of (design.objects||[])) { + const rot = (obj.rotation || 0) * Math.PI / 180; + const cx = obj.x + (obj.w||0)/2; + const cy = obj.y + (obj.h||0)/2; + ctxOut.save(); + if (rot) { ctxOut.translate(cx, cy); ctxOut.rotate(rot); ctxOut.translate(-cx, -cy); } + switch (obj.type) { + case 'text': { + ctxOut.fillStyle = '#000'; + ctxOut.font = `${obj.bold?'bold ':''}${obj.size||24}px sans-serif`; + ctxOut.textAlign = obj.align || 'left'; + ctxOut.textBaseline = 'top'; + const tx = obj.x + (obj.align==='center'? (obj.w||0)/2 : obj.align==='right'? (obj.w||0) : 0); + wrapTextOff(ctxOut, obj.text||'', tx, obj.y, obj.w||200, (obj.size||24)*1.2); + break; + } + case 'rect': + if (obj.fill) ctxOut.fillRect(obj.x, obj.y, obj.w, obj.h); + else { ctxOut.strokeStyle='#000'; ctxOut.strokeRect(obj.x, obj.y, obj.w, obj.h); } + break; + case 'image': + if (obj._img) ctxOut.drawImage(obj._img, obj.x, obj.y, obj.w, obj.h); + break; + } + ctxOut.restore(); + } + return { w, h }; + } + + function drawObject(obj) { + const rot = (obj.rotation || 0) * Math.PI / 180; + const cx = obj.x + (obj.w||0)/2; + const cy = obj.y + (obj.h||0)/2; + ctx.save(); + if (rot) { ctx.translate(cx, cy); ctx.rotate(rot); ctx.translate(-cx, -cy); } + switch (obj.type) { + case 'text': + ctx.fillStyle = '#000'; + ctx.font = `${obj.bold?'bold ':''}${obj.size||24}px sans-serif`; + ctx.textAlign = obj.align || 'left'; + ctx.textBaseline = 'top'; + const x = obj.x + (obj.align==='center'? (obj.w||0)/2 : obj.align==='right'? (obj.w||0) : 0); + wrapText(obj.text||'', x, obj.y, obj.w||200, (obj.size||24) * 1.2); + break; + case 'rect': + ctx.fillStyle = '#000'; + ctx.strokeStyle = '#000'; + if (obj.fill) ctx.fillRect(obj.x, obj.y, obj.w, obj.h); + else ctx.strokeRect(obj.x, obj.y, obj.w, obj.h); + break; + case 'image': + if (obj._img) ctx.drawImage(obj._img, obj.x, obj.y, obj.w, obj.h); + else { + const img = new Image(); + img.onload = () => { obj._img = img; draw(); }; + img.src = obj.src; + } + break; + } + ctx.restore(); + } + + function wrapText(text, x, y, maxWidth, lineHeight) { + if (!text) return; + const words = (text+"").split(/\s+/); + let line = ''; + for (let n=0; n maxWidth && n>0) { + ctx.fillText(line, x, y); + line = words[n]; + y += lineHeight; + } else { + line = test; + } + } + if (line) ctx.fillText(line, x, y); + } + + function drawSelection(obj) { + ctx.save(); + ctx.strokeStyle = '#2f6fed'; + ctx.setLineDash([4,3]); + ctx.strokeRect(obj.x, obj.y, obj.w, obj.h); + ctx.setLineDash([]); + ctx.fillStyle = '#2f6fed'; + const hs = 6; + // corners + ctx.fillRect(obj.x-hs, obj.y-hs, hs*2, hs*2); // nw + ctx.fillRect(obj.x+obj.w-hs, obj.y-hs, hs*2, hs*2); // ne + ctx.fillRect(obj.x-hs, obj.y+obj.h-hs, hs*2, hs*2); // sw + ctx.fillRect(obj.x+obj.w-hs, obj.y+obj.h-hs, hs*2, hs*2); // se + // rotation handle: circle above top center + const rx = obj.x + obj.w/2; const ry = obj.y - 18; + ctx.beginPath(); ctx.arc(rx, ry, 5, 0, Math.PI*2); ctx.fill(); + ctx.restore(); + } + + function hitTest(x, y) { + for (let i=state.objects.length-1; i>=0; i--) { + const o = state.objects[i]; + if (x>=o.x && y>=o.y && x<=o.x+o.w && y<=o.y+o.h) return o; + } + return null; + } + + function hitHandle(obj, x, y) { + const hs = 8; + const corners = [ + {k:'nw', x: obj.x, y: obj.y}, + {k:'ne', x: obj.x+obj.w, y: obj.y}, + {k:'sw', x: obj.x, y: obj.y+obj.h}, + {k:'se', x: obj.x+obj.w, y: obj.y+obj.h}, + ]; + for (const c of corners) { + if (Math.abs(x - c.x) <= hs && Math.abs(y - c.y) <= hs) return {type:'resize', dir:c.k}; + } + const rx = obj.x + obj.w/2; const ry = obj.y - 18; + if (Math.hypot(x - rx, y - ry) <= 10) return {type:'rotate'}; + return null; + } + + let currentGuides = []; + function drawGuides() { + if (!currentGuides.length) return; + ctx.save(); + ctx.strokeStyle = 'rgba(47,111,237,0.6)'; + ctx.lineWidth = 1; + for (const g of currentGuides) { + if (g.type === 'v') { ctx.beginPath(); ctx.moveTo(g.pos, 0); ctx.lineTo(g.pos, state.heightDots); ctx.stroke(); } + if (g.type === 'h') { ctx.beginPath(); ctx.moveTo(0, g.pos); ctx.lineTo(state.widthDots, g.pos); ctx.stroke(); } + } + ctx.restore(); + } + + function syncProps() { + const o = state.selection; + $('#propText').value = (o && o.type==='text') ? (o.text||'') : ''; + $('#propSize').value = (o && o.type==='text') ? (o.size||24) : ''; + const pr = $('#propSizeRange'); if (pr) pr.value = $('#propSize').value || ''; + const boldEl = $('#propBold'); + if (boldEl) { + boldEl.checked = !!(o && o.type==='text' && o.bold); + boldEl.disabled = !(o && o.type==='text'); + } + $('#propX').value = o? Math.round(o.x): ''; + $('#propY').value = o? Math.round(o.y): ''; + $('#propW').value = o? Math.round(o.w): ''; + $('#propH').value = o? Math.round(o.h): ''; + } + + function applyProps() { + const o = state.selection; if (!o) return; + if (o.type==='text') { + o.text = $('#propText').value; + const sz = parseInt($('#propSize').value||o.size,10); + if (!Number.isNaN(sz)) o.size = Math.max(6, Math.min(240, sz)); + o.bold = !!$('#propBold').checked; + } + ['X','Y','W','H'].forEach(k => { + const v = parseInt($(`#prop${k}`).value || (k==='X'?o.x:k==='Y'?o.y:k==='W'?o.w:o.h), 10); + if (!Number.isNaN(v)) { + if (k==='X') o.x = v; else if (k==='Y') o.y = v; else if (k==='W') o.w = Math.max(1,v); else o.h = Math.max(1,v); + } + }); + draw(); + } + + // Pointer interactions + let drag = null; // {obj, dx, dy, mode, dir} + let lastTapTime = 0; let lastTapObj = null; let movedDuringPress = false; + canvas.addEventListener('pointerdown', (e) => { + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) / state.viewScale; + const y = (e.clientY - rect.top) / state.viewScale; + movedDuringPress = false; + // First: if a selection exists, allow hitting its handles even outside bbox + if (state.selection) { + const hhSel = hitHandle(state.selection, x, y); + if (hhSel) { + pushUndo(); + if (hhSel.type==='resize') { + const o = state.selection; + drag = { obj: o, mode: 'resize', dir: hhSel.dir, ox: o.x, oy: o.y, ow: o.w, oh: o.h, startX: x, startY: y }; + } else if (hhSel.type==='rotate') { + const o = state.selection; const cx = o.x + o.w/2; const cy = o.y + o.h/2; + drag = { obj: o, mode: 'rotate', cx, cy }; + } + canvas.setPointerCapture?.(e.pointerId); + draw(); + return; + } + } + const o = hitTest(x, y); + if (o) { + state.selection = o; syncProps(); + const hh = hitHandle(o, x, y); + pushUndo(); + if (hh && hh.type==='resize') { + drag = { obj: o, mode: 'resize', dir: hh.dir, ox: o.x, oy: o.y, ow: o.w, oh: o.h, startX: x, startY: y }; + } else if (hh && hh.type==='rotate') { + const cx = o.x + o.w/2; const cy = o.y + o.h/2; + drag = { obj: o, mode: 'rotate', cx, cy }; + } else { + drag = { obj: o, dx: x - o.x, dy: y - o.y, mode: 'move' }; + } + } else { + state.selection = null; syncProps(); + } + canvas.setPointerCapture?.(e.pointerId); + draw(); + }); + canvas.addEventListener('pointermove', (e) => { + if (!drag) return; + movedDuringPress = true; + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) / state.viewScale; + const y = (e.clientY - rect.top) / state.viewScale; + if (drag.mode==='move') { + drag.obj.x = Math.round(x - drag.dx); + drag.obj.y = Math.round(y - drag.dy); + if (state.snapEnabled) { + const tol = 4; + const candV = [state.widthDots/2]; + const candH = [state.heightDots/2]; + for (const o of state.objects) { + if (o === drag.obj) continue; + candV.push(o.x, o.x + o.w/2, o.x + o.w); + candH.push(o.y, o.y + o.h/2, o.y + o.h); + } + let guides = []; + const L = drag.obj.x, Cx = drag.obj.x + drag.obj.w/2, R = drag.obj.x + drag.obj.w; + const T = drag.obj.y, Cy = drag.obj.y + drag.obj.h/2, B = drag.obj.y + drag.obj.h; + let bestDx = 0, bestDy = 0, bestAx = tol+1, bestAy = tol+1, snapX=null, snapY=null; + for (const v of candV) { + for (const a of [L, Cx, R]) { + const d = Math.abs(a - v); + if (d < bestAx && d <= tol) { bestAx = d; bestDx = v - a; snapX = v; } + } + } + for (const h of candH) { + for (const a of [T, Cy, B]) { + const d = Math.abs(a - h); + if (d < bestAy && d <= tol) { bestAy = d; bestDy = h - a; snapY = h; } + } + } + if (bestAx <= tol) drag.obj.x += Math.round(bestDx); + if (bestAy <= tol) drag.obj.y += Math.round(bestDy); + guides = []; + if (snapX != null) guides.push({type:'v', pos: snapX}); + if (snapY != null) guides.push({type:'h', pos: snapY}); + currentGuides = guides; + } else { + currentGuides = []; + } + draw(); syncProps(); + } else if (drag.mode==='resize') { + const dx = x - drag.startX; + const dy = y - drag.startY; + let {ox, oy, ow, oh} = drag; + if (drag.dir==='se') { drag.obj.w = Math.max(1, Math.round(ow + dx)); drag.obj.h = Math.max(1, Math.round(oh + dy)); } + if (drag.dir==='sw') { drag.obj.x = Math.round(ox + dx); drag.obj.w = Math.max(1, Math.round(ow - dx)); drag.obj.h = Math.max(1, Math.round(oh + dy)); } + if (drag.dir==='ne') { drag.obj.y = Math.round(oy + dy); drag.obj.h = Math.max(1, Math.round(oh - dy)); drag.obj.w = Math.max(1, Math.round(ow + dx)); } + if (drag.dir==='nw') { drag.obj.x = Math.round(ox + dx); drag.obj.y = Math.round(oy + dy); drag.obj.w = Math.max(1, Math.round(ow - dx)); drag.obj.h = Math.max(1, Math.round(oh - dy)); } + currentGuides = []; + draw(); syncProps(); + } else if (drag.mode==='rotate') { + const ang = Math.atan2(y - drag.cy, x - drag.cx); + drag.obj.rotation = Math.round((ang * 180 / Math.PI)); + currentGuides = []; + draw(); + } + }); + const endDrag = () => { drag = null; }; + canvas.addEventListener('pointerup', (e)=>{ + const now = Date.now(); + const wasDrag = movedDuringPress; + currentGuides=[]; canvas.releasePointerCapture?.(e.pointerId); endDrag(); draw(); + // Double-tap to edit text + if (!wasDrag && state.selection && state.selection.type==='text') { + if (lastTapObj === state.selection && (now - lastTapTime) < 300) { + startTextEdit(state.selection); + lastTapTime = 0; lastTapObj = null; + return; + } + lastTapTime = now; lastTapObj = state.selection; + } + }); + canvas.addEventListener('pointercancel', (e)=>{ currentGuides=[]; canvas.releasePointerCapture?.(e.pointerId); endDrag(); draw(); }); + + function startTextEdit(obj) { + // Position edit box over the text object's box in viewport coordinates + const canvasRect = canvas.getBoundingClientRect(); + const vv = window.visualViewport; + const scale = state.viewScale; + const vvOffX = vv ? vv.offsetLeft : 0; + const vvOffY = vv ? vv.offsetTop : 0; + const left = (canvasRect.left - vvOffX) + obj.x * scale; + const top = (canvasRect.top - vvOffY) + obj.y * scale; + + editBox.style.display = 'block'; + editBox.style.left = `${Math.round(left)}px`; + editBox.style.top = `${Math.round(top)}px`; + editBox.style.width = `${Math.round((obj.w||200) * scale)}px`; + const lineH = (obj.size||24) * 1.4; + const boxH = Math.max(obj.h||lineH*2, lineH*2); + editBox.style.height = `${Math.round(boxH * scale)}px`; + editBox.style.fontWeight = obj.bold ? '700' : '400'; + editBox.style.fontSize = `${Math.max(16, Math.round((obj.size||24) * scale))}px`; + editBox.style.lineHeight = `${Math.round(lineH * scale)}px`; + editBox.value = obj.text || ''; + + try { editBox.focus({ preventScroll: true }); } catch (_) { editBox.focus(); } + editBox.setSelectionRange(editBox.value.length, editBox.value.length); + + // Reposition on viewport (keyboard) changes + const relayout = () => { + const cr = canvas.getBoundingClientRect(); + const offX = vv ? vv.offsetLeft : 0; + const offY = vv ? vv.offsetTop : 0; + let l = (cr.left - offX) + obj.x * scale; + let t = (cr.top - offY) + obj.y * scale; + // Keep within visible viewport height to avoid being hidden by keyboard + const ebH = editBox.getBoundingClientRect().height || parseFloat(editBox.style.height)||44; + const maxY = (vv ? vv.height : window.innerHeight) - 8 - ebH; + if (t > maxY) t = Math.max(8, maxY); + editBox.style.left = `${Math.round(l)}px`; + editBox.style.top = `${Math.round(t)}px`; + }; + if (vv) { + vv.addEventListener('resize', relayout); + vv.addEventListener('scroll', relayout); + } + const finish = () => { + obj.text = editBox.value; + editBox.style.display = 'none'; + if (vv) { + vv.removeEventListener('resize', relayout); + vv.removeEventListener('scroll', relayout); + } + editBox.removeEventListener('blur', finish); + editBox.removeEventListener('keydown', onKey); + draw(); syncProps(); + }; + const onKey = (ev) => { + if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); finish(); } + }; + editBox.addEventListener('blur', finish); + editBox.addEventListener('keydown', onKey); + } + + // Toolbar events + $('#addText').addEventListener('click', addText); + $('#addRect').addEventListener('click', addRect); + $('#addImage').addEventListener('click', () => $('#imgInput').click()); + $('#imgInput').addEventListener('change', (e) => { const f=e.target.files&&e.target.files[0]; if (f) addImageFromFile(f); e.target.value=''; }); + mediaSel.addEventListener('change', () => setMedia(mediaSel.value)); + + $('#undoBtn').addEventListener('click', () => { + if (!undoStack.length) return; + redoStack.push(JSON.stringify(state)); + state = JSON.parse(undoStack.pop()); + resizeCanvasToContainer(); + draw(); syncProps(); + }); + $('#redoBtn').addEventListener('click', () => { + if (!redoStack.length) return; + undoStack.push(JSON.stringify(state)); + state = JSON.parse(redoStack.pop()); + resizeCanvasToContainer(); + draw(); syncProps(); + }); + $('#clearBtn').addEventListener('click', () => { pushUndo(); state.objects = []; state.selection = null; draw(); syncProps(); }); + + // Properties + ['propText','propSize','propX','propY','propW','propH'].forEach(id => { + $(`#${id}`).addEventListener('input', () => applyProps()); + }); + const boldEl = $('#propBold'); if (boldEl) boldEl.addEventListener('change', applyProps); + const sizeRange2 = $('#propSizeRange'); if (sizeRange2) sizeRange2.addEventListener('input', (e)=>{ $('#propSize').value=e.target.value; applyProps(); }); + + // Snap toggle + const snapToggle = $('#snapToggle'); + if (snapToggle) { snapToggle.checked = !!state.snapEnabled; snapToggle.addEventListener('change', ()=>{ state.snapEnabled = !!snapToggle.checked; currentGuides=[]; draw(); }); } + const sizeRange = $('#propSizeRange'); if (sizeRange) sizeRange.addEventListener('input', (e)=>{ const v=e.target.value; $('#propSize').value=v; applyProps(); }); + + async function exportToBlob() { + // For continuous80, extend height to content bottom + let heightDots = state.heightDots; + if (state.media === 'continuous80') { + let bottom = 0; + for (const o of state.objects) bottom = Math.max(bottom, o.y + o.h); + heightDots = Math.max(1, Math.min(4000, Math.round(bottom + 8))); // add tiny margin + } + const off = document.createElement('canvas'); + off.width = state.widthDots; + off.height = heightDots; + const oc = off.getContext('2d'); + renderDesignToCanvas(oc, serializeDesign()); + return new Promise((resolve) => off.toBlob((b)=>resolve(b), 'image/png')); + } + function wrapTextOff(oc, text, x, y, maxWidth, lineHeight) { + if (!text) return; + const words = (text+"").split(/\s+/); + let line = ''; + for (let n=0; n maxWidth && n>0) { + oc.fillText(line, x, y); + line = words[n]; + y += lineHeight; + } else { + line = test; + } + } + if (line) oc.fillText(line, x, y); + } + + async function doPreview() { + try { + setStatus('Generating preview…'); + const blob = await exportToBlob(); + const fd = new FormData(); + fd.append('file', blob, 'design.png'); + fd.append('media', state.media); + const res = await fetch('/preview', { method: 'POST', body: fd }); + if (!res.ok) throw new Error('Preview failed'); + const out = await res.blob(); + const url = URL.createObjectURL(out); + const img = $('#previewImg'); + img.src = url; img.style.display = ''; + setStatus('Preview updated', 'ok'); + } catch (e) { + console.error(e); + setStatus('Error: '+e.message, 'err'); + } + } + async function doPrint() { + try { + setStatus('Sending to printer…'); + const blob = await exportToBlob(); + const fd = new FormData(); + fd.append('file', blob, 'design.png'); + fd.append('media', state.media); + fd.append('lang', 'EPL'); + const res = await fetch('/print', { method: 'POST', body: fd }); + if (!res.ok) throw new Error(await res.text() || 'Print failed'); + const data = await res.json().catch(()=>({status:'ok'})); + setStatus(data && data.mode==='test' ? `Test OK` : 'Printed', 'ok'); + } catch (e) { + console.error(e); + setStatus('Error: '+e.message, 'err'); + } + } + + $('#previewBtn').addEventListener('click', doPreview); + $('#printBtn').addEventListener('click', doPrint); + + // --- Library (LocalStorage) --- + function loadLibraryList() { + try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.list) || '[]'); } catch { return []; } + } + function saveLibraryList(list) { + localStorage.setItem(STORAGE_KEYS.list, JSON.stringify(list)); + } + function loadDesignById(id) { + try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.design(id)) || 'null'); } catch { return null; } + } + function saveDesignById(id, design) { + localStorage.setItem(STORAGE_KEYS.design(id), JSON.stringify(design)); + } + + let currentDesignId = null; + + function openLibrary() { + libModal.style.display = 'block'; libModal.setAttribute('aria-hidden','false'); + renderLibraryList(); + } + function closeLibrary() { + libModal.style.display = 'none'; libModal.setAttribute('aria-hidden','true'); + } + function renderLibraryList() { + const list = loadLibraryList(); + const box = document.getElementById('libList'); + box.innerHTML = ''; + if (!list.length) { box.innerHTML = '
No saved designs yet.
'; return; } + for (const item of list) { + const row = document.createElement('div'); + row.style.display='grid'; row.style.gridTemplateColumns='auto 1fr auto auto'; row.style.gap='8px'; row.style.alignItems='center'; + row.innerHTML = ` + +
+
${item.name || '(unnamed)'}
+
${item.media} • ${new Date(item.updated_at).toLocaleString()}
+
+ +
+ + +
+ `; + box.appendChild(row); + } + box.querySelectorAll('[data-open-id]').forEach(btn => btn.addEventListener('click', (e)=>{ + const id = e.target.getAttribute('data-open-id'); + const d = loadDesignById(id); if (d) { applyDesign(d); currentDesignId = id; setStatus('Design loaded','ok'); } + })); + box.querySelectorAll('[data-del-id]').forEach(btn => btn.addEventListener('click', (e)=>{ + const id = e.target.getAttribute('data-del-id'); + const list2 = loadLibraryList().filter(x => x.id !== id); saveLibraryList(list2); + localStorage.removeItem(STORAGE_KEYS.design(id)); renderLibraryList(); + })); + } + + async function exportCurrentJSON() { + const design = serializeDesign({ id: currentDesignId }); + const blob = new Blob([JSON.stringify(design,null,2)], { type:'application/json' }); + const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (design.media||'label') + '.json'; a.click(); + } + function importJSONFile(file) { + const reader = new FileReader(); + reader.onload = () => { + try { + const design = JSON.parse(reader.result); + const id = 'd' + Date.now().toString(36) + Math.random().toString(36).slice(2,6); + const name = design.name || `Imported ${new Date().toLocaleString()}`; + const meta = { id, name, media: design.media, updated_at: Date.now() }; + const list = loadLibraryList(); list.unshift(meta); saveLibraryList(list); + saveDesignById(id, design); + renderLibraryList(); setStatus('Imported','ok'); + } catch (e) { setStatus('Import failed','err'); } + }; + reader.readAsText(file); + } + + async function batchPrintSelected() { + const list = loadLibraryList(); + const checks = Array.from(document.querySelectorAll('.libChk:checked')); + if (!checks.length) { document.getElementById('libStatus').textContent = 'No designs selected'; return; } + let total = 0; const items = []; + for (const chk of checks) { + const id = chk.getAttribute('data-id'); + const qEl = document.querySelector(`[data-q-id="${id}"]`); + const qty = Math.max(1, Math.min(100, parseInt(qEl.value||'1',10)||1)); + total += qty; items.push({ id, qty }); + } + if (total > 100) { document.getElementById('libStatus').textContent = 'Too many labels (max 100)'; return; } + const status = document.getElementById('libStatus'); + status.textContent = `Printing ${total}…`; + for (const it of items) { + const d = loadDesignById(it.id); if (!d) continue; + for (let i=0;iMath.max(a,o.y+o.h),0)+8))) : mm.h; + const oc = off.getContext('2d'); + renderDesignToCanvas(oc, { ...d, widthDots: off.width, heightDots: off.height }); + const blob = await new Promise((resolve)=> off.toBlob((b)=>resolve(b), 'image/png')); + const fd = new FormData(); fd.append('file', blob, 'design.png'); fd.append('media', d.media); fd.append('lang','EPL'); + const res = await fetch('/print', { method:'POST', body: fd }); + if (!res.ok) { status.textContent = 'Print failed'; return; } + await new Promise(r=>setTimeout(r, 120)); + } + } + status.textContent = 'Printed'; + } + + // Library UI wiring + const libBtn = document.getElementById('libraryBtn'); if (libBtn) libBtn.addEventListener('click', openLibrary); + const libClose = document.getElementById('closeLibrary'); if (libClose) libClose.addEventListener('click', closeLibrary); + const saveAsBtn = document.getElementById('saveAsBtn'); if (saveAsBtn) saveAsBtn.addEventListener('click', ()=>{ + const name = prompt('Design name?') || 'Label'; + const id = 'd' + Date.now().toString(36) + Math.random().toString(36).slice(2,6); + const meta = { id, name, media: state.media, updated_at: Date.now() }; + const list = loadLibraryList(); list.unshift(meta); saveLibraryList(list); + const design = serializeDesign({ id, name }); saveDesignById(id, design); currentDesignId = id; + renderLibraryList(); setStatus('Saved','ok'); + }); + const saveBtn = document.getElementById('saveBtn'); if (saveBtn) saveBtn.addEventListener('click', ()=>{ + if (!currentDesignId) return document.getElementById('saveAsBtn').click(); + const list = loadLibraryList(); + const idx = list.findIndex(x => x.id === currentDesignId); + const name = (idx>=0 ? list[idx].name : 'Label'); + const meta = { id: currentDesignId, name, media: state.media, updated_at: Date.now() }; + if (idx>=0) list[idx] = meta; else list.unshift(meta); + saveLibraryList(list); + saveDesignById(currentDesignId, serializeDesign({ id: currentDesignId, name })); + renderLibraryList(); setStatus('Saved','ok'); + }); + const exportBtn = document.getElementById('exportBtn'); if (exportBtn) exportBtn.addEventListener('click', exportCurrentJSON); + const importBtn = document.getElementById('importBtn'); if (importBtn) importBtn.addEventListener('click', ()=> document.getElementById('importInput').click()); + const importInput = document.getElementById('importInput'); if (importInput) importInput.addEventListener('change', (e)=>{ const f=e.target.files&&e.target.files[0]; if (f) importJSONFile(f); e.target.value=''; }); + const printSelBtn = document.getElementById('printSelectedBtn'); if (printSelBtn) printSelBtn.addEventListener('click', batchPrintSelected); + + // Init + function init() { + setMedia(mediaSel.value); + resizeCanvasToContainer(); + draw(); + window.addEventListener('resize', () => { resizeCanvasToContainer(); draw(); }); + setStatus('Ready'); + + // Templates + const templates = { + t_50x30_basic: (w,h) => ([ + { type:'text', x:10, y:10, w:w-20, h:40, text:'Title', size:34, bold:true, align:'left' }, + { type:'text', x:10, y:60, w:w-20, h:30, text:'Subtitle', size:22, bold:false, align:'left' }, + ]), + t_100x150_basic: (w,h) => ([ + { type:'text', x:40, y:40, w:w-80, h:60, text:'Large Title', size:48, bold:true, align:'center' }, + { type:'rect', x:60, y:120, w:w-120, h:4, fill:true }, + ]), + }; + const applyBtn = document.querySelector('#applyTemplate'); + if (applyBtn) applyBtn.addEventListener('click', ()=>{ + const key = document.querySelector('#templateSel').value; if (!key) return; + const tpl = templates[key]; if (!tpl) return; + pushUndo(); + state.objects = tpl(state.widthDots, state.heightDots); + state.selection = null; + draw(); syncProps(); + }); + } + init(); +})(); diff --git a/ditherbooth/static/index.html b/ditherbooth/static/index.html index 1762acf..cc8547b 100644 --- a/ditherbooth/static/index.html +++ b/ditherbooth/static/index.html @@ -15,10 +15,19 @@

Ditherbooth

- +
-
+ + + + +
@@ -65,6 +74,83 @@

Output Preview

+ +
+
+ + + + + + + + + + +
+ + + + + + +
+
+
+ +
+ +
+ + +
+
+

Queue (0)

+
+ + +
+
+
+ +
+ + +
+
+ + +
+ +
+
+ + + diff --git a/ditherbooth/static/style.css b/ditherbooth/static/style.css index f9d122b..7f38cdd 100644 --- a/ditherbooth/static/style.css +++ b/ditherbooth/static/style.css @@ -155,6 +155,198 @@ button:disabled { opacity: 0.6; cursor: not-allowed; } 100% { transform: translateX(140%); } } +/* Tab bar */ +.tab-bar { + display: flex; + gap: 0; + background: var(--panel); + border-bottom: 1px solid var(--border); + padding: 0 max(16px, env(safe-area-inset-left)); + position: sticky; + top: 0; + z-index: 9; +} +.app-header ~ .tab-bar { top: auto; position: relative; } +.tab { + background: transparent; + color: var(--muted); + border: none; + border-bottom: 2px solid transparent; + padding: 10px 20px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.tab:hover { color: var(--text); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +/* Tab content visibility */ +.tab-content { display: none; } +.tab-content.active { display: grid; } + +/* Designer layout */ +#tab-designer { display: none; gap: 12px; max-width: 880px; margin: 0 auto; padding: 12px; } +#tab-designer.active { display: grid; } + +.designer-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; +} +.toolbar-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: var(--muted); +} +.toolbar-input { + width: 64px; + background: #0e1016; + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 8px; + font-size: 14px; +} +.toolbar-select { + background: #0e1016; + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 8px; + font-size: 14px; +} + +.designer-canvas-wrap { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + overflow: hidden; + position: relative; +} + +.designer-templates { + display: grid; + gap: 10px; +} + +.template-list { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + display: grid; + gap: 8px; +} +.template-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: var(--bg); + border-radius: 8px; +} +.template-name { font-weight: 500; } +.template-actions { display: flex; gap: 6px; } +.template-actions button { flex: 0 0 auto; padding: 6px 10px; font-size: 13px; } + +/* Queue section */ +.queue-section { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + display: grid; + gap: 10px; +} +.queue-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.queue-header h3 { margin: 0; font-size: 16px; color: var(--muted); } +.queue-strip { + display: flex; + gap: 10px; + overflow-x: auto; + padding: 4px 0; + min-height: 0; +} +.queue-strip:empty::after { + content: 'No labels queued'; + color: var(--muted); + font-size: 13px; + padding: 12px 0; +} +.queue-item { + flex: 0 0 auto; + width: 120px; + background: var(--bg); + border: 2px solid var(--border); + border-radius: 8px; + padding: 6px; + cursor: grab; + position: relative; + transition: border-color 0.15s, opacity 0.15s; +} +.queue-item:hover { border-color: var(--accent); } +.queue-item.dragging { opacity: 0.4; } +.queue-item.drag-over { border-color: var(--accent); border-style: dashed; } +.queue-item img { + width: 100%; + display: block; + border-radius: 4px; + image-rendering: pixelated; + background: #fff; +} +.queue-item-name { + font-size: 11px; + color: var(--muted); + text-align: center; + margin-top: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.queue-item-dup, +.queue-item-remove { + position: absolute; + top: 2px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 50%; + width: 20px; + height: 20px; + font-size: 12px; + line-height: 18px; + text-align: center; + color: var(--muted); + cursor: pointer; + padding: 0; +} +.queue-item-dup { left: 2px; } +.queue-item-dup:hover { color: var(--accent); border-color: var(--accent); } +.queue-item-remove { right: 2px; } +.queue-item-remove:hover { color: var(--err); border-color: var(--err); } + +@media (max-width: 520px) { + .designer-toolbar { + flex-direction: column; + align-items: stretch; + } + .designer-toolbar > div { margin-left: 0 !important; } +} + @media (prefers-color-scheme: light) { :root { --bg: #f7f7fb; --panel: #fff; --border: #e0e4ea; --text: #0c1424; --muted: #667085; --accent: #2f6bff; } + .toolbar-input, .toolbar-select { background: #f0f2f5; } } diff --git a/server_config.json b/server_config.json new file mode 100644 index 0000000..b99122e --- /dev/null +++ b/server_config.json @@ -0,0 +1,8 @@ +{ + "test_mode": false, + "default_media": "continuous58", + "default_lang": "EPL", + "lock_controls": false, + "printer_name": "Zebra_LP2844", + "test_mode_delay_ms": 0 +} diff --git a/tests/test_printer.py b/tests/test_printer.py index a9fbd4b..aefb883 100644 --- a/tests/test_printer.py +++ b/tests/test_printer.py @@ -1,5 +1,8 @@ +from unittest.mock import patch, mock_open, MagicMock + from PIL import Image import pytest +from ditherbooth.printer.cups import spool_raw from ditherbooth.printer.epl import img_to_epl_gw from ditherbooth.printer.zpl import img_to_zpl_gf @@ -38,3 +41,28 @@ def test_printer_functions_require_1bit(): img_to_epl_gw(img) with pytest.raises(ValueError): img_to_zpl_gf(img) + + +def test_spool_raw_dev_path(): + m = mock_open() + with patch("builtins.open", m): + spool_raw("/dev/usb/lp0", b"hello printer") + m.assert_called_once_with("/dev/usb/lp0", "wb") + m().write.assert_called_once_with(b"hello printer") + + +def test_spool_raw_dev_path_with_str_payload(): + m = mock_open() + with patch("builtins.open", m): + spool_raw("/dev/usb/lp0", "string payload") + m().write.assert_called_once_with(b"string payload") + + +def test_spool_raw_lpr_path(tmp_path, monkeypatch): + with patch("subprocess.run") as mock_run: + spool_raw("TestPrinter", b"test data") + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert args[0] == "lpr" + assert args[1] == "-P" + assert args[2] == "TestPrinter" diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..51276f7 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,100 @@ +import importlib +import json + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + monkeypatch.setenv("DITHERBOOTH_CONFIG_PATH", str(tmp_path / "cfg.json")) + import ditherbooth.app as app_module + + importlib.reload(app_module) + return TestClient(app_module.app) + + +def test_list_templates_empty(client): + res = client.get("/api/templates") + assert res.status_code == 200 + assert res.json() == [] + + +def test_create_template(client): + payload = {"name": "My Label", "canvas_json": {"objects": [], "version": "5.3.1"}} + res = client.post("/api/templates", json=payload) + assert res.status_code == 200 + data = res.json() + assert data["name"] == "My Label" + assert "id" in data + assert "created_at" in data + assert data["canvas_json"] == payload["canvas_json"] + + +def test_create_template_missing_name(client): + res = client.post("/api/templates", json={"canvas_json": {}}) + assert res.status_code == 400 + + +def test_create_template_missing_canvas(client): + res = client.post("/api/templates", json={"name": "Test"}) + assert res.status_code == 400 + + +def test_list_templates_after_create(client): + client.post("/api/templates", json={"name": "T1", "canvas_json": {}}) + client.post("/api/templates", json={"name": "T2", "canvas_json": {}}) + res = client.get("/api/templates") + assert res.status_code == 200 + templates = res.json() + assert len(templates) == 2 + names = {t["name"] for t in templates} + assert names == {"T1", "T2"} + + +def test_delete_template(client): + res = client.post("/api/templates", json={"name": "Del", "canvas_json": {}}) + tpl_id = res.json()["id"] + + res = client.delete(f"/api/templates/{tpl_id}") + assert res.status_code == 200 + assert res.json() == {"status": "deleted"} + + # Verify it's gone + res = client.get("/api/templates") + assert res.json() == [] + + +def test_delete_template_not_found(client): + res = client.delete("/api/templates/nonexistent-id") + assert res.status_code == 404 + + +def test_get_template_by_id(client): + payload = {"name": "Fetch Me", "canvas_json": {"objects": [1, 2]}} + res = client.post("/api/templates", json=payload) + tpl_id = res.json()["id"] + + res = client.get(f"/api/templates/{tpl_id}") + assert res.status_code == 200 + data = res.json() + assert data["name"] == "Fetch Me" + assert data["canvas_json"] == {"objects": [1, 2]} + + +def test_get_template_not_found(client): + res = client.get("/api/templates/nonexistent-id") + assert res.status_code == 404 + + +def test_public_config_has_media_dimensions(client): + res = client.get("/api/public-config") + assert res.status_code == 200 + data = res.json() + assert "media_dimensions" in data + dims = data["media_dimensions"] + assert "continuous80" in dims + assert dims["continuous80"]["width"] == 640 + assert "label100x150" in dims + assert dims["label100x150"]["width"] == 800 + assert dims["label100x150"]["height"] == 1200