add text to hpgl tab
This commit is contained in:
105
text_to_hpgl.py
Normal file
105
text_to_hpgl.py
Normal 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) + ';'
|
||||
@@ -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`).
|
||||
|
||||
65
webui/app.py
65
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:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
Flask>=2.0
|
||||
pyserial>=3.5
|
||||
matplotlib>=3.0
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
<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 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>
|
||||
|
||||
Reference in New Issue
Block a user