""" 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' )