Files
Umbraformat/umbraformat/app.py

224 lines
7.9 KiB
Python

"""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/<filename>")
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/<filename>/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/<name>")
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)