convert-service: веб-морда для конвертации .doc → .docx
This commit is contained in:
+104
-17
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user