Files
mimaki/webui/app.py
2026-02-05 22:50:36 +01:00

365 lines
12 KiB
Python

"""
Flask web UI for HPGL plotter.
Run from project root so Command, Program, Plotter, hpgl_svg, config can be imported.
"""
import os
import sys
import uuid
from flask import Flask, request, jsonify, session, send_from_directory, render_template
# Run from project root (parent of webui)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
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
except ImportError:
webui_secret_key = lambda: os.environ.get('SECRET_KEY', 'change-me-in-production')
webui_max_upload_mb = lambda: 16
app = Flask(__name__, static_folder='static', template_folder='templates')
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)
OUTPUT_HPGL_DIR = os.path.join(ROOT, 'output', 'hpgl')
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():
path = session.get('upload_path')
if not path or not os.path.isfile(path):
return None
try:
return Program.parsefile(path)
except Exception:
return None
def _save_program(program, path=None):
path = path or session.get('upload_path')
if not path:
return None
with open(path, 'w') as f:
f.write(str(program))
return path
@app.route('/')
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/hpgl_files')
def list_hpgl_files():
"""List HPGL filenames in output/hpgl/."""
if not os.path.isdir(OUTPUT_HPGL_DIR):
return jsonify({'files': []})
files = []
for name in sorted(os.listdir(OUTPUT_HPGL_DIR)):
if name.startswith('.'):
continue
path = os.path.join(OUTPUT_HPGL_DIR, name)
if not os.path.isfile(path):
continue
low = name.lower()
if low.endswith('.hpgl') or low.endswith('.plt') or '.' not in name:
files.append(name)
return jsonify({'files': files})
@app.route('/api/load_hpgl', methods=['POST'])
def load_hpgl_from_folder():
"""Set current program from a file in output/hpgl/."""
data = request.get_json() or {}
filename = (data.get('filename') or '').strip()
if not filename:
return jsonify({'error': 'No filename'}), 400
if os.path.basename(filename) != filename or '..' in filename:
return jsonify({'error': 'Invalid filename'}), 400
path = os.path.join(OUTPUT_HPGL_DIR, filename)
if not os.path.isfile(path):
return jsonify({'error': 'File not found'}), 404
try:
Program.parsefile(path)
except Exception as e:
return jsonify({'error': 'Invalid HPGL: {}'.format(str(e))}), 400
session['upload_path'] = path
session['upload_filename'] = filename
return jsonify({'ok': True, 'filename': filename, 'preview_url': '/api/svg'})
@app.route('/api/upload', methods=['POST'])
def upload():
if 'file' not in request.files:
return jsonify({'error': 'No file'}), 400
f = request.files['file']
if not f.filename or not (f.filename.lower().endswith('.hpgl') or f.filename.lower().endswith('.plt')):
return jsonify({'error': 'Need an .hpgl or .plt file'}), 400
try:
content = f.read().decode('utf-8', errors='replace')
Program.parse(content) # validate
except Exception as e:
return jsonify({'error': 'Invalid HPGL: {}'.format(str(e))}), 400
name = str(uuid.uuid4()) + '.hpgl'
path = os.path.join(UPLOAD_DIR, name)
with open(path, 'w') as out:
out.write(content)
session['upload_path'] = path
session['upload_filename'] = f.filename
return jsonify({
'ok': True,
'filename': f.filename,
'preview_url': '/api/svg'
})
@app.route('/api/download_hpgl')
def download_hpgl():
"""Return current program as downloadable HPGL file."""
p = _get_program()
if p is None:
return jsonify({'error': 'No file loaded'}), 404
filename = (session.get('upload_filename') or 'export').strip()
if not filename.lower().endswith('.hpgl') and not filename.lower().endswith('.plt'):
filename = filename + '.hpgl'
return (
str(p),
200,
{
'Content-Type': 'application/octet-stream',
'Content-Disposition': 'attachment; filename="{}"'.format(filename),
},
)
@app.route('/api/dimensions')
def dimensions():
"""Return current program size in plotter units (e.g. 0.025 mm per unit)."""
p = _get_program()
if p is None:
return jsonify({'error': 'No file loaded'}), 404
w, h = p.winsize
return jsonify({
'width': round(w, 1),
'height': round(h, 1),
'width_mm': round(w * 0.025, 2),
'height_mm': round(h * 0.025, 2),
})
@app.route('/api/svg')
def get_svg():
p = _get_program()
if p is None:
return jsonify({'error': 'No file loaded'}), 404
width = request.args.get('width', 800, type=int)
height = request.args.get('height', 600, type=int)
show_scale = request.args.get('scale', '1').lower() in ('1', 'true', 'yes')
svg = program_to_svg(p, width=width, height=height, show_scale_bar=show_scale)
return svg, 200, {'Content-Type': 'image/svg+xml; charset=utf-8'}
@app.route('/api/transform', methods=['POST'])
def transform():
p = _get_program()
if p is None:
return jsonify({'error': 'No file loaded'}), 404
data = request.get_json() or {}
action = data.get('action')
if action == 'flip':
p = p.flip()
elif action == 'rotate':
angle = data.get('angle', 90)
p = p.rotate(angle)
elif action == 'scale':
factor = data.get('factor', 1.5)
p = p * factor
elif action == 'centralize':
p = p - p.center
elif action == 'scale_to_bounds':
xmin = data.get('xmin')
ymin = data.get('ymin')
xmax = data.get('xmax')
ymax = data.get('ymax')
if xmin is not None and ymin is not None and xmax is not None and ymax is not None:
if xmax <= xmin or ymax <= ymin:
return jsonify({'error': 'Invalid bounds: xmax > xmin and ymax > ymin required.'}), 400
p = p.fitin((xmin, ymin, xmax, ymax), (0.5, 0.5))
else:
try:
plt = Plotter()
if getattr(plt, 'boundaries', None) is None:
return jsonify({'error': 'Plotter not connected. Connect plotter or send bounds (xmin, ymin, xmax, ymax).'}), 400
p = plt.full(p)
except (OSError, AttributeError, TypeError):
return jsonify({'error': 'Plotter not connected. Connect plotter or send bounds (xmin, ymin, xmax, ymax).'}), 400
else:
return jsonify({'error': 'Unknown action'}), 400
_save_program(p)
return jsonify({'ok': True, 'preview_url': '/api/svg'})
@app.route('/api/print', methods=['POST'])
def print_to_plotter():
p = _get_program()
if p is None:
return jsonify({'error': 'No file loaded'}), 404
try:
plt = Plotter()
if getattr(plt, 'ser', None) is None:
return jsonify({'error': 'Plotter not connected. Connect plotter and try again.'}), 503
p = plt.full(p)
p = plt.centralize(p)
plt.write(p)
return jsonify({'ok': True})
except OSError as e:
return jsonify({'error': 'Plotter not connected: {}'.format(str(e))}), 503
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/status')
def status():
has_file = bool(session.get('upload_path')) and os.path.isfile(session.get('upload_path'))
plotter_ready = False
try:
plt = Plotter()
if getattr(plt, 'ser', None) is not None and getattr(plt, 'boundaries', None) is not None:
plotter_ready = plt.ready
except (OSError, AttributeError, TypeError):
pass
return jsonify({
'has_file': has_file,
'filename': session.get('upload_filename'),
'plotter_ready': plotter_ready
})
@app.route('/api/plotter_info')
def plotter_info():
"""Return plotter connection and media dimensions for display under the print button."""
try:
from config import plotter_port, plotter_baudrate
except ImportError:
plotter_port = lambda: '/dev/ttyUSB0'
plotter_baudrate = lambda: 9600
port = plotter_port()
baudrate = plotter_baudrate()
connected = False
ready = False
boundaries = None
winsize_units = None
winsize_mm = None
try:
plt = Plotter()
if getattr(plt, 'ser', None) is not None and getattr(plt, 'boundaries', None) is not None:
connected = True
ready = plt.ready
try:
boundaries = {
'xmin': plt.xmin,
'ymin': plt.ymin,
'xmax': plt.xmax,
'ymax': plt.ymax,
}
w, h = plt.winsize
winsize_units = {'width': round(w, 1), 'height': round(h, 1)}
winsize_mm = {'width': round(w * 0.025, 1), 'height': round(h * 0.025, 1)}
except (AttributeError, TypeError):
pass
except (OSError, AttributeError, TypeError):
pass
return jsonify({
'port': port,
'baudrate': baudrate,
'connected': connected,
'ready': ready,
'boundaries': boundaries,
'winsize_units': winsize_units,
'winsize_mm': winsize_mm,
})
if __name__ == '__main__':
try:
from config import webui_host, webui_port
except ImportError:
webui_host = lambda: '0.0.0.0'
webui_port = lambda: 5000
app.run(
host=webui_host(),
port=webui_port(),
debug=os.environ.get('FLASK_DEBUG', '0') == '1'
)