227 lines
8.0 KiB
Python
227 lines
8.0 KiB
Python
# 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/<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)
|