convert-service: веб-морда для конвертации .doc → .docx

This commit is contained in:
“Naeel”
2026-07-16 10:08:48 +04:00
parent 0e0fb11c28
commit 470cc0a404
+104 -17
View File
@@ -1,20 +1,85 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""LibreOffice converter — Flask HTTP service. POST .doc → .docx.""" """LibreOffice converter — Flask HTTP service. .doc → .docx with UI + API."""
import subprocess, tempfile, os import subprocess, tempfile, os
from flask import Flask, request, send_file from flask import Flask, request, send_file, render_template_string
app = Flask(__name__) app = Flask(__name__)
@app.route("/convert", methods=["POST"]) HTML = """<!DOCTYPE html>
def convert(): <html lang="ru">
if "file" not in request.files: <head>
return {"ok": False, "error": "no file"}, 400 <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Конвертер .doc → .docx</title>
<style>
body{font-family:system-ui,sans-serif;max-width:600px;margin:40px auto;padding:0 16px;color:#1a1a1a}
h1{font-size:20px;margin-bottom:8px}
.sub{color:#888;font-size:13px;margin-bottom:24px}
form{background:#f5f5f5;padding:24px;border-radius:8px}
input[type=file]{margin-bottom:16px;display:block}
button{background:#0066cc;color:#fff;border:none;padding:10px 24px;border-radius:6px;cursor:pointer;font-size:14px}
button:hover{background:#0052a3}
button:disabled{opacity:0.5;cursor:not-allowed}
.result{margin-top:20px;padding:16px;border-radius:8px}
.ok{background:#e6ffe6;border:1px solid #b3e6b3}
.err{background:#ffe6e6;border:1px solid #e6b3b3}
.info{background:#e6f0ff;border:1px solid #b3d4e6;margin:16px 0;padding:12px;border-radius:6px;font-size:13px}
code{background:#eee;padding:1px 4px;border-radius:3px;font-size:12px}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid #ccc;border-top-color:#0066cc;border-radius:50%;animation:spin 0.8s linear infinite;margin-right:6px;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<h1>📄 Конвертер .doc → .docx</h1>
<p class="sub">LibreOffice в headless-режиме. Поддерживает .doc, .rtf, .odt.</p>
<div class="info">
<strong>API:</strong> <code>POST /convert</code> с <code>multipart/form-data</code>, поле <code>file</code>.<br>
Ответ: файл .docx или <code>{"ok":false,"error":"..."}</code>.<br>
<strong>Health:</strong> <code>GET /health</code> → <code>{"ok":true}</code>
</div>
<hr style="margin:24px 0;border:none;border-top:1px solid #e0e0e0">
<h2 style="font-size:16px;margin-bottom:12px">Конвертировать файл</h2>
<form id="convForm" enctype="multipart/form-data">
<input type="file" name="file" accept=".doc,.rtf,.odt" required id="fileInput">
<button type="submit" id="submitBtn">Конвертировать</button>
</form>
<div id="result"></div>
<script>
document.getElementById('convForm').onsubmit = async function(e) {
e.preventDefault();
var btn = document.getElementById('submitBtn');
var result = document.getElementById('result');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Конвертирую...';
result.innerHTML = '';
try {
var fd = new FormData();
fd.append('file', document.getElementById('fileInput').files[0]);
var resp = await fetch('/convert', { method: 'POST', body: fd });
if (resp.ok && resp.headers.get('Content-Type').includes('application/vnd')) {
var blob = await resp.blob();
var url = URL.createObjectURL(blob);
var fn = document.getElementById('fileInput').files[0].name.replace(/\\.[^.]+$/,'') + '.docx';
result.innerHTML = '<div class="result ok">✅ Готово! <a href="'+url+'" download="'+fn+'">Скачать '+fn+'</a></div>';
} else {
var err = await resp.json();
result.innerHTML = '<div class="result err">❌ ' + (err.error || 'Ошибка') + '</div>';
}
} catch(ex) {
result.innerHTML = '<div class="result err">❌ Сеть: ' + ex.message + '</div>';
}
btn.disabled = false;
btn.textContent = 'Конвертировать';
};
</script>
</body>
</html>"""
f = request.files["file"] def _convert_file(f):
"""Convert one file to .docx, return (bytes, download_name) or raise."""
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
f.save(tmp) f.save(tmp)
doc_path = tmp.name doc_path = tmp.name
tmpdir = tempfile.mkdtemp() tmpdir = tempfile.mkdtemp()
try: try:
subprocess.run( subprocess.run(
@@ -23,15 +88,9 @@ def convert():
) )
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")] docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
if not docx_files: if not docx_files:
return {"ok": False, "error": "conversion produced no .docx"}, 500 raise ValueError("conversion produced no .docx")
return send_file(os.path.join(tmpdir, docx_files[0]), with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document", return out.read(), os.path.splitext(f.filename)[0] + ".docx"
as_attachment=True,
download_name=os.path.splitext(f.filename)[0] + ".docx")
except subprocess.TimeoutExpired:
return {"ok": False, "error": "conversion timeout"}, 500
except subprocess.CalledProcessError as e:
return {"ok": False, "error": f"libreoffice: {e.stderr.decode() if e.stderr else 'unknown'}"}, 500
finally: finally:
if os.path.exists(doc_path): if os.path.exists(doc_path):
os.unlink(doc_path) os.unlink(doc_path)
@@ -39,9 +98,37 @@ def convert():
os.unlink(os.path.join(tmpdir, x)) os.unlink(os.path.join(tmpdir, x))
os.rmdir(tmpdir) os.rmdir(tmpdir)
@app.route("/", methods=["GET"])
def index():
return render_template_string(HTML)
@app.route("/convert", methods=["POST"])
def convert():
if "file" not in request.files:
return {"ok": False, "error": "no file"}, 400
f = request.files["file"]
if not f.filename:
return {"ok": False, "error": "empty filename"}, 400
try:
data, name = _convert_file(f)
return send_file(
io.BytesIO(data),
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
as_attachment=True,
download_name=name,
)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "conversion timeout"}, 500
except subprocess.CalledProcessError as e:
err = e.stderr.decode() if e.stderr else "unknown"
return {"ok": False, "error": f"libreoffice: {err}"}, 500
except ValueError as e:
return {"ok": False, "error": str(e)}, 500
@app.route("/health", methods=["GET"]) @app.route("/health", methods=["GET"])
def health(): def health():
return {"ok": True} return {"ok": True}
if __name__ == "__main__": if __name__ == "__main__":
import io
app.run(host="0.0.0.0", port=5000) app.run(host="0.0.0.0", port=5000)