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.
## 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
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
cp config.example.ini config.ini
```
@@ -35,25 +24,24 @@ From the **project root** (mimaki/):
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.
- **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`).

View File

@@ -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:

View File

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

View File

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

View File

@@ -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;

View File

@@ -10,6 +10,10 @@
<div class="app">
<header class="header">
<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">
<span class="status-file" id="statusFile">No file</span>
<span class="status-plotter" id="statusPlotter">Plotter: —</span>
@@ -18,11 +22,28 @@
<main class="main">
<aside class="sidebar">
<div id="panelFile" class="tab-panel">
<section class="upload-section">
<label class="upload-label" for="fileInput">Upload HPGL</label>
<input type="file" id="fileInput" accept=".hpgl,.plt" class="file-input">
<p class="hint">.hpgl or .plt files only</p>
</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">
<h2>Size (plotter units)</h2>