diff --git a/text_to_hpgl.py b/text_to_hpgl.py new file mode 100644 index 0000000..98cf862 --- /dev/null +++ b/text_to_hpgl.py @@ -0,0 +1,105 @@ +""" +Convert text to HPGL using a font file (.otf, .ttf). +Uses matplotlib TextPath; flattens curves to line segments. +""" +from __future__ import division +import os + +# Path codes from matplotlib.path.Path +MOVETO = 1 +LINETO = 2 +CURVE3 = 3 +CURVE4 = 4 +CLOSEPOLY = 79 + + +def _flatten_quadratic(p0, p1, p2, n=10): + """Quadratic Bezier: B(t) = (1-t)^2 P0 + 2(1-t)t P1 + t^2 P2.""" + for i in range(1, n + 1): + t = i / n + u = 1 - t + x = u * u * p0[0] + 2 * u * t * p1[0] + t * t * p2[0] + y = u * u * p0[1] + 2 * u * t * p1[1] + t * t * p2[1] + yield (x, y) + + +def _flatten_cubic(p0, p1, p2, p3, n=10): + """Cubic Bezier: B(t) = (1-t)^3 P0 + 3(1-t)^2 t P1 + 3(1-t)t^2 P2 + t^3 P3.""" + for i in range(1, n + 1): + t = i / n + u = 1 - t + x = u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0] + y = u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1] + yield (x, y) + + +def text_to_hpgl(text, font_path, size_pt=72, units_per_pt=14): + """ + Convert text to HPGL string using the given font file. + size_pt: font size in points. + units_per_pt: HPGL units per point (default ~0.35 mm per pt). + Returns HPGL string (IN;SP1;PU x,y;PD;PA x,y;...). + """ + try: + from matplotlib.textpath import TextPath + from matplotlib.font_manager import FontProperties + except ImportError: + raise RuntimeError('matplotlib is required for text-to-HPGL. Install with: pip install matplotlib') + + if not text or not font_path or not os.path.isfile(font_path): + raise ValueError('Need non-empty text and a valid font file path.') + + prop = FontProperties(fname=font_path) + path = TextPath((0, 0), text, size=size_pt, prop=prop) + vertices = path.vertices + codes = path.codes + + if codes is None: + codes = [MOVETO] + [LINETO] * (len(vertices) - 1) + + # Scale to HPGL units (y flip: matplotlib y up, HPGL typically y up too; we keep same) + scale = units_per_pt + segments = [] + i = 0 + contour_start = None + + while i < len(codes): + code = codes[i] + if code == MOVETO: + x, y = vertices[i] + segments.append(('PU', int(round(x * scale)), int(round(y * scale)))) + contour_start = (int(round(x * scale)), int(round(y * scale))) + i += 1 + elif code == LINETO: + x, y = vertices[i] + segments.append(('PD', int(round(x * scale)), int(round(y * scale)))) + i += 1 + elif code == CLOSEPOLY: + if contour_start: + segments.append(('PD', contour_start[0], contour_start[1])) + contour_start = None + i += 1 + elif code == CURVE3 and i + 1 < len(vertices): + for (x, y) in _flatten_quadratic( + (vertices[i - 1][0], vertices[i - 1][1]), + (vertices[i][0], vertices[i][1]), + (vertices[i + 1][0], vertices[i + 1][1]), + ): + segments.append(('PD', int(round(x * scale)), int(round(y * scale)))) + i += 2 + elif code == CURVE4 and i + 2 < len(vertices): + p0, p1, p2, p3 = vertices[i - 1], vertices[i], vertices[i + 1], vertices[i + 2] + for (x, y) in _flatten_cubic(p0, p1, p2, p3): + segments.append(('PD', int(round(x * scale)), int(round(y * scale)))) + i += 3 + else: + i += 1 + + out = ['IN', 'SP1'] + for seg in segments: + if seg[0] == 'PU': + out.append('PU{},{}'.format(seg[1], seg[2])) + else: + out.append('PD{},{}'.format(seg[1], seg[2])) + out.append('PU') + return ';'.join(out) + ';' diff --git a/webui/README.md b/webui/README.md index 5eba220..79ea33c 100644 --- a/webui/README.md +++ b/webui/README.md @@ -2,26 +2,15 @@ Flask web interface for uploading HPGL files, previewing and transforming them (flip, rotate, scale, center), and sending to the plotter. Intended to run on a Raspberry Pi connected to the plotter via USB. -## Setup (Raspberry Pi) +## Setup (Raspberry Pi / Linux Mint / Debian) -1. From the **project root** (mimaki/), install dependencies: +From the **project root** (mimaki/), install dependencies. On Debian-based systems Python is externally managed (PEP 668), so use: - If you get **externally-managed-environment** (Python 3.11+ / PEP 668) and don’t want a venv, use: - ```bash - pip install --break-system-packages -r webui/requirements.txt - ``` - Otherwise: - ```bash - pip install -r webui/requirements.txt - ``` - Optional – use a venv to avoid touching system Python: - ```bash - python3 -m venv venv - source venv/bin/activate - pip install -r webui/requirements.txt - ``` +```bash +pip install --break-system-packages -r webui/requirements.txt +``` -2. **Config:** Copy `config.example.ini` to `config.ini` and adjust (port, baudrate, web UI host/port, etc.). `config.ini` is gitignored. +Then copy `config.example.ini` to `config.ini` and set the plotter port etc. (optional). ```bash cp config.example.ini config.ini ``` @@ -35,25 +24,24 @@ From the **project root** (mimaki/): python webui/app.py ``` -Then open http://<raspi-ip>:5000 in a browser (e.g. http://192.168.1.10:5000). +Then open http://localhost:5000 (or http://<raspi-ip>:5000 from another device). Host is `0.0.0.0`, port 5000 (see `config.ini`). -- **Host:** `0.0.0.0` so other devices on the network can connect. -- **Port:** 5000 (override with `PORT=8080 python webui/app.py` if needed). - -For production, run behind gunicorn and optionally a reverse proxy: - -```bash -pip install --break-system-packages gunicorn # or omit if not using system Python -gunicorn -w 1 -b 0.0.0.0:5000 "webui.app:app" -``` +For production with gunicorn: `pip install --break-system-packages gunicorn` then `gunicorn -w 1 -b 0.0.0.0:5000 "webui.app:app"`. ## Usage +**File tab** 1. **Upload** – Choose an `.hpgl` or `.plt` file. It is validated and stored in `webui/uploads/`. 2. **Preview** – The drawing is converted to SVG and shown in the browser. -3. **Transform** – Flip, Rotate 90° / 180°, Scale + / −, Center. Each action updates the stored program and the preview. +3. **Transform** – Flip, Rotate 90° / 180°, Scale + / −, Center, Scale to bounds. Each action updates the stored program and the preview. 4. **Print** – Sends the current (transformed) program to the plotter: scale to fit, center, then write HPGL to serial. +**Text tab** +1. Type text, select a font (from `font/` or `.git/font/`, e.g. `Melange-Bold.otf`), set size (pt), then **Generate HPGL**. +2. The generated HPGL becomes the current program; use the same **Preview**, **Transform**, and **Print** as in the File tab. + +Fonts: put `.otf` or `.ttf` files in the project `font/` folder (or they are read from `.git/font/` if present). The Text tab requires **matplotlib** (`pip install matplotlib`). + ## API - `POST /api/upload` – Upload HPGL file (form field `file`). diff --git a/webui/app.py b/webui/app.py index 8196976..bd186be 100644 --- a/webui/app.py +++ b/webui/app.py @@ -16,6 +16,7 @@ from Command import Command from Program import Program from Plotter import Plotter from hpgl_svg import program_to_svg +from text_to_hpgl import text_to_hpgl try: from config import webui_secret_key, webui_max_upload_mb @@ -28,6 +29,36 @@ app.secret_key = webui_secret_key() app.config['MAX_CONTENT_LENGTH'] = webui_max_upload_mb() * 1024 * 1024 UPLOAD_DIR = os.path.join(ROOT, 'webui', 'uploads') os.makedirs(UPLOAD_DIR, exist_ok=True) +FONT_DIRS = [ + os.path.join(ROOT, 'font'), + os.path.join(ROOT, '.git', 'font'), +] + + +def _resolve_font(filename): + """Return full path to font file if found in FONT_DIRS, else None.""" + for d in FONT_DIRS: + if not os.path.isdir(d): + continue + path = os.path.join(d, filename) + if os.path.isfile(path): + return path + return None + + +def _list_fonts(): + """Return list of font filenames (.otf, .ttf) from FONT_DIRS.""" + seen = set() + out = [] + for d in FONT_DIRS: + if not os.path.isdir(d): + continue + for name in sorted(os.listdir(d)): + low = name.lower() + if (low.endswith('.otf') or low.endswith('.ttf')) and name not in seen: + seen.add(name) + out.append(name) + return out def _get_program(): @@ -54,6 +85,40 @@ def index(): return render_template('index.html') +@app.route('/api/fonts') +def list_fonts(): + """List font filenames in font/ and .git/font/.""" + return jsonify({'fonts': _list_fonts()}) + + +@app.route('/api/text_to_hpgl', methods=['POST']) +def api_text_to_hpgl(): + """Generate HPGL from text and font; save as current program.""" + data = request.get_json() or {} + text = (data.get('text') or '').strip() + font_name = data.get('font') or '' + size_pt = data.get('size_pt', 72) + if not text: + return jsonify({'error': 'Enter some text.'}), 400 + if not font_name: + return jsonify({'error': 'Select a font.'}), 400 + font_path = _resolve_font(font_name) + if not font_path: + return jsonify({'error': 'Font not found: {}'.format(font_name)}), 400 + try: + hpgl = text_to_hpgl(text, font_path, size_pt=size_pt) + Program.parse(hpgl) # validate + except Exception as e: + return jsonify({'error': 'Text to HPGL failed: {}'.format(str(e))}), 400 + name = str(uuid.uuid4()) + '.hpgl' + path = os.path.join(UPLOAD_DIR, name) + with open(path, 'w') as out: + out.write(hpgl) + session['upload_path'] = path + session['upload_filename'] = 'text_{}.hpgl'.format(font_name) + return jsonify({'ok': True, 'filename': session['upload_filename'], 'preview_url': '/api/svg'}) + + @app.route('/api/upload', methods=['POST']) def upload(): if 'file' not in request.files: diff --git a/webui/requirements.txt b/webui/requirements.txt index ae919ce..7a57735 100644 --- a/webui/requirements.txt +++ b/webui/requirements.txt @@ -1,2 +1,3 @@ Flask>=2.0 pyserial>=3.5 +matplotlib>=3.0 diff --git a/webui/static/app.js b/webui/static/app.js index 9a3e681..7cf0391 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -22,8 +22,17 @@ centralize: document.getElementById('btnCentralize'), scaleToBounds: document.getElementById('btnScaleToBounds'), print: document.getElementById('btnPrint'), - checkPlotter: document.getElementById('btnCheckPlotter') + checkPlotter: document.getElementById('btnCheckPlotter'), + generateHpgl: document.getElementById('btnGenerateHpgl') }; + const tabFile = document.getElementById('tabFile'); + const tabText = document.getElementById('tabText'); + const panelFile = document.getElementById('panelFile'); + const panelText = document.getElementById('panelText'); + const textInput = document.getElementById('textInput'); + const fontSelect = document.getElementById('fontSelect'); + const textSize = document.getElementById('textSize'); + const textMessage = document.getElementById('textMessage'); function setMessage(el, text, type) { el.textContent = text || ''; @@ -84,11 +93,13 @@ }); } + var transformPrintButtons = ['flip', 'rotate90', 'rotate180', 'scaleUp', 'scaleDown', 'centralize', 'scaleToBounds', 'print']; + function setLoaded(loaded) { if (loaded) { preview.classList.add('loaded'); previewPlaceholder.classList.add('hidden'); - Object.keys(buttons).forEach(function (k) { + transformPrintButtons.forEach(function (k) { var b = buttons[k]; if (b) b.disabled = false; }); @@ -96,7 +107,7 @@ preview.classList.remove('loaded'); previewPlaceholder.classList.remove('hidden'); preview.src = ''; - Object.keys(buttons).forEach(function (k) { + transformPrintButtons.forEach(function (k) { var b = buttons[k]; if (b) b.disabled = true; }); @@ -247,6 +258,71 @@ previewPlaceholder.classList.add('hidden'); }); + function showTab(name) { + var isFile = (name === 'file'); + tabFile.classList.toggle('active', isFile); + tabText.classList.toggle('active', !isFile); + if (panelFile) panelFile.classList.toggle('hidden', !isFile); + if (panelText) panelText.classList.toggle('hidden', isFile); + if (!isFile && fontSelect && fontSelect.options.length <= 1) loadFonts(); + } + + function loadFonts() { + if (!fontSelect) return; + fetch('/api/fonts') + .then(function (r) { return r.json(); }) + .then(function (d) { + var opts = fontSelect.querySelectorAll('option'); + for (var i = opts.length - 1; i >= 1; i--) opts[i].remove(); + (d.fonts || []).forEach(function (f) { + var o = document.createElement('option'); + o.value = f; + o.textContent = f; + fontSelect.appendChild(o); + }); + }) + .catch(function () {}); + } + + if (tabFile) tabFile.addEventListener('click', function () { showTab('file'); }); + if (tabText) tabText.addEventListener('click', function () { showTab('text'); }); + + if (buttons.generateHpgl && textInput && fontSelect) { + buttons.generateHpgl.addEventListener('click', function () { + var text = (textInput.value || '').trim(); + var font = (fontSelect.value || '').trim(); + var size = parseInt(textSize.value, 10) || 72; + setMessage(textMessage, ''); + if (!text) { + setMessage(textMessage, 'Enter some text.', 'error'); + return; + } + if (!font) { + setMessage(textMessage, 'Select a font.', 'error'); + return; + } + setMessage(textMessage, 'Generating…'); + fetch('/api/text_to_hpgl', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: text, font: font, size_pt: size }) + }) + .then(function (r) { + return r.json().then(function (d) { + if (!r.ok) throw new Error(d.error || 'Generate failed'); + return d; + }); + }) + .then(function () { + setMessage(textMessage, 'Generated.', 'success'); + refreshStatus(); + }) + .catch(function (e) { + setMessage(textMessage, e.message || 'Generate failed', 'error'); + }); + }); + } + refreshStatus(); updatePlotterInfo(); setInterval(updatePlotterInfo, 10000); diff --git a/webui/static/style.css b/webui/static/style.css index a34a847..e44c331 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -48,6 +48,113 @@ body { font-weight: 600; } +.header .tabs { + display: flex; + gap: 0; + margin: 0; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.header .tabs .tab { + margin: 0; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + background: var(--surface); + border: none; + border-right: 1px solid var(--border); + color: var(--muted); + cursor: pointer; +} + +.header .tabs .tab:last-child { + border-right: none; +} + +.header .tabs .tab:hover { + color: var(--text); + background: var(--border); +} + +.header .tabs .tab.active { + background: var(--accent); + color: #fff; +} + +.tab-panel { + display: block; +} + +.tab-panel.hidden { + display: none; +} + +.text-section { + padding: 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 1.5rem; +} + +.text-input { + display: block; + width: 100%; + padding: 0.5rem; + font-size: 0.875rem; + font-family: inherit; + background: #fff; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + resize: vertical; + margin-bottom: 0.5rem; +} + +.font-select { + display: block; + width: 100%; + padding: 0.5rem; + font-size: 0.875rem; + background: #fff; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + margin-bottom: 0.5rem; +} + +.text-size { + display: block; + width: 100%; + padding: 0.5rem; + font-size: 0.875rem; + background: #fff; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + margin-bottom: 0.5rem; +} + +.btn-generate { + width: 100%; + margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-weight: 500; + background: var(--accent); + border: none; + border-radius: 6px; + color: #fff; + cursor: pointer; +} + +.btn-generate:hover { + background: var(--accent-hover); +} + .status { display: flex; gap: 1rem; diff --git a/webui/templates/index.html b/webui/templates/index.html index f304765..7b2fdbf 100644 --- a/webui/templates/index.html +++ b/webui/templates/index.html @@ -10,6 +10,10 @@

HPGL Plotter

+
No file Plotter: — @@ -18,11 +22,28 @@