add text to hpgl tab

This commit is contained in:
Lukas Cremer
2026-02-03 23:26:44 +01:00
parent cd846577a4
commit 82e5ef4b73
7 changed files with 399 additions and 36 deletions

105
text_to_hpgl.py Normal file
View File

@@ -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) + ';'

View File

@@ -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. 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 dont want a venv, use:
```bash ```bash
pip install --break-system-packages -r webui/requirements.txt 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
```
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 ```bash
cp config.example.ini config.ini cp config.example.ini config.ini
``` ```
@@ -35,25 +24,24 @@ From the **project root** (mimaki/):
python webui/app.py python webui/app.py
``` ```
Then open http://&lt;raspi-ip&gt;:5000 in a browser (e.g. http://192.168.1.10:5000). Then open http://localhost:5000 (or http://&lt;raspi-ip&gt;: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. For production with gunicorn: `pip install --break-system-packages gunicorn` then `gunicorn -w 1 -b 0.0.0.0:5000 "webui.app:app"`.
- **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"
```
## Usage ## Usage
**File tab**
1. **Upload** Choose an `.hpgl` or `.plt` file. It is validated and stored in `webui/uploads/`. 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. 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. 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 ## API
- `POST /api/upload` Upload HPGL file (form field `file`). - `POST /api/upload` Upload HPGL file (form field `file`).

View File

@@ -16,6 +16,7 @@ from Command import Command
from Program import Program from Program import Program
from Plotter import Plotter from Plotter import Plotter
from hpgl_svg import program_to_svg from hpgl_svg import program_to_svg
from text_to_hpgl import text_to_hpgl
try: try:
from config import webui_secret_key, webui_max_upload_mb 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 app.config['MAX_CONTENT_LENGTH'] = webui_max_upload_mb() * 1024 * 1024
UPLOAD_DIR = os.path.join(ROOT, 'webui', 'uploads') UPLOAD_DIR = os.path.join(ROOT, 'webui', 'uploads')
os.makedirs(UPLOAD_DIR, exist_ok=True) 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(): def _get_program():
@@ -54,6 +85,40 @@ def index():
return render_template('index.html') 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']) @app.route('/api/upload', methods=['POST'])
def upload(): def upload():
if 'file' not in request.files: if 'file' not in request.files:

View File

@@ -1,2 +1,3 @@
Flask>=2.0 Flask>=2.0
pyserial>=3.5 pyserial>=3.5
matplotlib>=3.0

View File

@@ -22,8 +22,17 @@
centralize: document.getElementById('btnCentralize'), centralize: document.getElementById('btnCentralize'),
scaleToBounds: document.getElementById('btnScaleToBounds'), scaleToBounds: document.getElementById('btnScaleToBounds'),
print: document.getElementById('btnPrint'), 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) { function setMessage(el, text, type) {
el.textContent = text || ''; el.textContent = text || '';
@@ -84,11 +93,13 @@
}); });
} }
var transformPrintButtons = ['flip', 'rotate90', 'rotate180', 'scaleUp', 'scaleDown', 'centralize', 'scaleToBounds', 'print'];
function setLoaded(loaded) { function setLoaded(loaded) {
if (loaded) { if (loaded) {
preview.classList.add('loaded'); preview.classList.add('loaded');
previewPlaceholder.classList.add('hidden'); previewPlaceholder.classList.add('hidden');
Object.keys(buttons).forEach(function (k) { transformPrintButtons.forEach(function (k) {
var b = buttons[k]; var b = buttons[k];
if (b) b.disabled = false; if (b) b.disabled = false;
}); });
@@ -96,7 +107,7 @@
preview.classList.remove('loaded'); preview.classList.remove('loaded');
previewPlaceholder.classList.remove('hidden'); previewPlaceholder.classList.remove('hidden');
preview.src = ''; preview.src = '';
Object.keys(buttons).forEach(function (k) { transformPrintButtons.forEach(function (k) {
var b = buttons[k]; var b = buttons[k];
if (b) b.disabled = true; if (b) b.disabled = true;
}); });
@@ -247,6 +258,71 @@
previewPlaceholder.classList.add('hidden'); 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(); refreshStatus();
updatePlotterInfo(); updatePlotterInfo();
setInterval(updatePlotterInfo, 10000); setInterval(updatePlotterInfo, 10000);

View File

@@ -48,6 +48,113 @@ body {
font-weight: 600; 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 { .status {
display: flex; display: flex;
gap: 1rem; gap: 1rem;

View File

@@ -10,6 +10,10 @@
<div class="app"> <div class="app">
<header class="header"> <header class="header">
<h1>HPGL Plotter</h1> <h1>HPGL Plotter</h1>
<nav class="tabs">
<button type="button" class="tab active" id="tabFile" data-tab="file">File</button>
<button type="button" class="tab" id="tabText" data-tab="text">Text</button>
</nav>
<div class="status" id="status"> <div class="status" id="status">
<span class="status-file" id="statusFile">No file</span> <span class="status-file" id="statusFile">No file</span>
<span class="status-plotter" id="statusPlotter">Plotter: —</span> <span class="status-plotter" id="statusPlotter">Plotter: —</span>
@@ -18,11 +22,28 @@
<main class="main"> <main class="main">
<aside class="sidebar"> <aside class="sidebar">
<div id="panelFile" class="tab-panel">
<section class="upload-section"> <section class="upload-section">
<label class="upload-label" for="fileInput">Upload HPGL</label> <label class="upload-label" for="fileInput">Upload HPGL</label>
<input type="file" id="fileInput" accept=".hpgl,.plt" class="file-input"> <input type="file" id="fileInput" accept=".hpgl,.plt" class="file-input">
<p class="hint">.hpgl or .plt files only</p> <p class="hint">.hpgl or .plt files only</p>
</section> </section>
</div>
<div id="panelText" class="tab-panel hidden">
<section class="text-section">
<label class="upload-label" for="textInput">Text</label>
<textarea id="textInput" class="text-input" rows="4" placeholder="Enter text to convert to HPGL"></textarea>
<label class="upload-label" for="fontSelect">Font</label>
<select id="fontSelect" class="font-select">
<option value="">— Select font —</option>
</select>
<label class="upload-label" for="textSize">Size (pt)</label>
<input type="number" id="textSize" class="text-size" value="72" min="8" max="500" step="1">
<button type="button" class="btn btn-generate" id="btnGenerateHpgl">Generate HPGL</button>
<p id="textMessage" class="message"></p>
</section>
</div>
<section class="scale-section"> <section class="scale-section">
<h2>Size (plotter units)</h2> <h2>Size (plotter units)</h2>