# Copyright 2026 Umbra Tools (umbratools.com) # SPDX-License-Identifier: Apache-2.0 """UmbraFormat Flask web application.""" import os, re, uuid, json from flask import ( Flask, render_template, request, jsonify, send_file, url_for, abort, ) from umbraformat import __version__ from umbraformat.analyzer import analyze_document from umbraformat.formatter import format_document, convert_to_pdf app = Flask(__name__) app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 app.config["UPLOAD_FOLDER"] = os.path.join(os.path.dirname(__file__), "uploads") app.config["FORMATTED_FOLDER"] = os.path.join(os.path.dirname(__file__), "formatted") app.config["VERSION"] = __version__ os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) os.makedirs(app.config["FORMATTED_FOLDER"], exist_ok=True) ALLOWED_EXTENSIONS = {".docx"} def allowed_file(filename): return os.path.splitext(filename.lower())[1] in ALLOWED_EXTENSIONS def _analyze(upload_id): """Run analysis on an uploaded file, return results dict.""" docx_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}.docx") analysis_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}_analysis.json") if os.path.exists(analysis_path): with open(analysis_path) as f: return json.load(f) results = analyze_document(docx_path) results["upload_id"] = upload_id with open(analysis_path, "w") as f: json.dump(results, f, indent=2, default=str) return results # --- Routes --- @app.route("/") def index(): return render_template("index.html") @app.route("/format") def format_page(): return render_template("format.html") @app.route("/api/analyze", methods=["POST"]) def api_analyze(): if "file" not in request.files: return jsonify({"error": "No file provided"}), 400 f = request.files["file"] if not f.filename: return jsonify({"error": "Empty filename"}), 400 if not allowed_file(f.filename): return jsonify({"error": "Only .docx files are supported"}), 400 upload_id = str(uuid.uuid4()) ext = os.path.splitext(f.filename)[1] save_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}{ext}") f.save(save_path) try: results = _analyze(upload_id) results["original_filename"] = f.filename analysis_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}_analysis.json") with open(analysis_path, "w") as af: json.dump(results, af, indent=2, default=str) return jsonify(results) except Exception as e: return jsonify({"error": f"Analysis failed: {str(e)}"}), 500 @app.route("/api/format", methods=["POST"]) def api_format(): data = request.get_json() if not data or "upload_id" not in data: return jsonify({"error": "No upload_id provided"}), 400 upload_id = data["upload_id"] docx_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}.docx") if not os.path.exists(docx_path): return jsonify({"error": "File not found"}), 404 output_filename = f"{upload_id}_formatted.docx" output_path = os.path.join(app.config["FORMATTED_FOLDER"], output_filename) # Extract formatting options font_name = data.get("font", "Caladea") # Body typography is fixed by the WA style family (spec 2.2: Caladea 11pt); # font/fontSize are accepted for backward compatibility and only affect # the invisible runs that host inline images. font_size = int(data.get("fontSize", 11)) inner_margin = float(data.get("innerMargin", 0.500)) # ignored: dynamic gutter trim_size = data.get("trimSize", "5.5x8.5") bleed = float(data.get("bleed", 0.0)) author = data.get("author", "") imprint = data.get("imprint", "") pad_multiple_of_4 = bool(data.get("padMultipleOf4", True)) strip_graphics = bool(data.get("stripGraphics", False)) strip_problematic_graphics = bool(data.get("stripProblematicGraphics", False)) language = data.get("language", "en") try: format_document( docx_path, output_path, font_name=font_name, font_size=font_size, inner_margin=inner_margin, trim_size=trim_size, bleed=bleed, author=author, imprint=imprint, strip_graphics=strip_graphics, strip_problematic_graphics=strip_problematic_graphics, language=language, ) # Read original filename from analysis cache and record it analysis_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}_analysis.json") orig_filename = "formatted_manuscript.docx" if os.path.exists(analysis_path): with open(analysis_path) as af: ad = json.load(af) orig_filename = ad.get("original_filename", orig_filename) orig_stem = os.path.splitext(orig_filename)[0] name_path = os.path.splitext(output_path)[0] + ".name" with open(name_path, "w") as nf: nf.write(f"{orig_stem}_formatted.docx") opts_path = os.path.splitext(output_path)[0] + ".opts.json" with open(opts_path, "w") as of: json.dump({"padMultipleOf4": pad_multiple_of_4}, of) return jsonify( { "success": True, "download_docx": url_for("api_download", filename=output_filename), "download_pdf": url_for("api_download_pdf", filename=output_filename), "formatted_filename": output_filename, } ) except Exception as e: return jsonify({"error": f"Formatting failed: {str(e)}"}), 500 def _get_original_name(filename): """Read the original filename stored alongside a formatted file.""" name_path = os.path.join(app.config["FORMATTED_FOLDER"], os.path.splitext(filename)[0] + ".name") if os.path.exists(name_path): with open(name_path) as f: return f.read().strip() return None @app.route("/api/download/") def api_download(filename): filepath = os.path.join(app.config["FORMATTED_FOLDER"], filename) if not os.path.exists(filepath): abort(404) orig = _get_original_name(filename) or "formatted_manuscript.docx" return send_file(filepath, as_attachment=True, download_name=orig) @app.route("/api/download//pdf") def api_download_pdf(filename): docx_path = os.path.join(app.config["FORMATTED_FOLDER"], filename) if not os.path.exists(docx_path): abort(404) pdf_filename = os.path.splitext(filename)[0] + ".pdf" pdf_path = os.path.join(app.config["FORMATTED_FOLDER"], pdf_filename) # Respect the print-prep option chosen when the document was formatted. opts_path = os.path.join(app.config["FORMATTED_FOLDER"], os.path.splitext(filename)[0] + ".opts.json") pad_multiple_of_4 = True if os.path.exists(opts_path): try: with open(opts_path) as of: pad_multiple_of_4 = bool(json.load(of).get("padMultipleOf4", True)) except (OSError, ValueError): pass if not os.path.exists(pdf_path): result = convert_to_pdf(docx_path, pdf_path, pad_multiple_of_4=pad_multiple_of_4) if result is None: return jsonify({"error": "PDF conversion failed. LibreOffice may not be available."}), 500 orig = _get_original_name(filename) dl_name = os.path.splitext(orig)[0] + ".pdf" if orig else "formatted_manuscript.pdf" return send_file(pdf_path, as_attachment=True, download_name=dl_name) @app.route("/api/diag/") def api_diag(name): # Sanitize: only allow alphanumeric, hyphens, and underscores to prevent path traversal if not re.match(r'^[A-Za-z0-9_-]+$', name): abort(400) filepath = os.path.join(app.config["UPLOAD_FOLDER"], f"diag_{name}.docx") if not os.path.exists(filepath): abort(404) return send_file(filepath, as_attachment=True, download_name=f"{name}.docx") @app.route("/api/version") def api_version(): return jsonify({"version": __version__}) if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=5001)