180 lines
11 KiB
Python
180 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""LibreOffice converter — Flask HTTP service. .doc → .docx with UI + API."""
|
||
import subprocess, tempfile, os
|
||
from flask import Flask, request, send_file, render_template_string
|
||
|
||
app = Flask(__name__)
|
||
|
||
HTML = """<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Конвертер документов — Nubes</title>
|
||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='57' height='57' xml:space='preserve' overflow='hidden'><defs><clipPath id='c0'><rect x='652' y='420' width='64' height='75'/></clipPath><clipPath id='c1'><rect x='652' y='420' width='64' height='70'/></clipPath></defs><g clip-path='url(%23c0)' transform='matrix(1.01357 0 0 1.01357 -664.97 -440.306)'><g clip-path='url(%23c1)'><path d='m114.028 43.149v7.659c0 3.785-3.08 6.863-6.866 6.863h-21.825c-3.786 0-6.866-3.08-6.866-6.864v-2.528l0.056.009V15.815l-10.382 2.013 0.004 3.877v28.971c0 9.481 7.713 17.192 17.187 17.192l21.836.132c9.474 0 17.187-7.712 17.187-17.193v-2.654l0.027.004V15.815l-10.382 2.012 0.028 25.322z' fill='%23001c34' fill-rule='evenodd' transform='matrix(1 0 0 1.01337 587.92 420.059)'/></g></g></svg>">
|
||
<style>
|
||
:root{--brand-primary:#2563eb;--brand-primary-dark:#1d4ed8;--brand-grey-light:#f3f4f6;--brand-gray:#d1d5db;--nubes-dark:#001C34}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:system-ui,-apple-system,sans-serif;color:#1a1a1a;min-height:100vh;background:linear-gradient(135deg,#f0f4ff 0%,#fff 50%,#f0f4ff 100%)}
|
||
.logo-bar{display:flex;align-items:center;gap:12px;padding:20px 24px}
|
||
.logo{font-size:22px;font-weight:700;color:var(--nubes-dark);letter-spacing:-0.5px}
|
||
.logo-bar .badge{background:var(--brand-primary);color:#fff;font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600;letter-spacing:.5px}
|
||
main{max-width:640px;margin:0 auto;padding:0 16px 40px}
|
||
.card{background:#fff;border:1px solid var(--brand-gray);border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.06);overflow:hidden;margin-bottom:20px}
|
||
.card-header{background:var(--brand-grey-light);padding:12px 16px;font-size:14px;font-weight:600;color:#374151}
|
||
.card-body{padding:16px}
|
||
h1{font-size:22px;font-weight:700;margin-bottom:6px}
|
||
.sub{color:#6b7280;font-size:14px;line-height:1.5;margin-bottom:20px}
|
||
.api-info{font-size:13px;color:#6b7280;line-height:1.7}
|
||
.api-info code{background:#eef2ff;color:var(--brand-primary);padding:2px 6px;border-radius:4px;font-size:12px;font-weight:500}
|
||
.form-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
||
.file-label{flex:1;min-width:200px;display:block;border:2px dashed var(--brand-gray);border-radius:8px;padding:28px 16px;text-align:center;cursor:pointer;transition:border-color .2s;color:#6b7280;font-size:14px}
|
||
.file-label:hover{border-color:var(--brand-primary)}
|
||
.file-label.has-file{border-style:solid;border-color:var(--brand-primary);background:#eef2ff}
|
||
.file-label input{display:none}
|
||
.file-label .filename{color:#1a1a1a;font-weight:500;word-break:break-all}
|
||
.btn{background:var(--brand-primary);color:#fff;border:none;padding:10px 28px;border-radius:8px;cursor:pointer;font-size:14px;font-weight:500;white-space:nowrap;transition:background .15s}
|
||
.btn:hover{background:var(--brand-primary-dark)}
|
||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||
.result{margin-top:16px;padding:12px 16px;border-radius:8px;font-size:14px;display:none}
|
||
.result.show{display:block}
|
||
.result.ok{background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46}
|
||
.result.err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b}
|
||
.result a{color:var(--brand-primary);font-weight:600}
|
||
.spinner{display:inline-block;width:14px;height:14px;border:2px solid #ccc;border-top-color:var(--brand-primary);border-radius:50%;animation:spin .8s linear infinite;margin-right:6px;vertical-align:middle}
|
||
.footer{text-align:center;padding:0 16px 40px;font-size:12px;color:#9ca3af}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="logo-bar">
|
||
<svg viewBox="0 0 8222 1821" style="height:22px"><path fill="#001C34" d="M477 480l-414 0-64 276 179 0 1 1029 0 27 276 0 0-872c0-101 82-184 184-184l589 4c101 0 184 82 184 184l0 871 276 0 0-871c0-254-206-460-460-460l-589-4-162 1 0-1zM6665 1813l1181 0c212 0 376-175 376-387 0-212-173-385-385-385l-653 0c-60 0-109-49-109-109l0-66c0-60 49-109 109-109l696 0 64-276-760 0c-212 0-384 173-384 385l0 66c0 212 172 385 384 385l654 0c60 0 109 49 109 109s-49 116-109 116l-1119 0-54 271zM6201 484l-723 0c-216 0-392 175-392 391l0 486c0 248 202 450 450 450l990 0 62-268-1052 0c-96 0-175-86-175-182l-1-40 923 0 309-2 0-238 0-206c0-216-176-391-392-391zm116 560l-955 0 0-169c0-64 52-116 116-116l723 0c64 0 116 52 116 116l0 169zM4683 1194l0 168c0 101-81 179-182 179l-590 1c-93 0-170-70-182-160l0-458c12-90 89-160 182-160l589 3c101 0 183 83 183 184l0 30 0 213zm-1230-345l-1-795 277-54 0 526c56-24 117-38 182-38l589 4c253 0 459 206 459 458l0 31 0 213 0 168c0 253-206 459-459 459l-589-4c-253 0-459-206-459-459l0-171 0-241 1-98zM3041 1151l0 204c0 101-82 183-183 183l-582-3c-101 0-183-82-183-183l0-68 2 0 0-862-277 54 0 103 0 773c0 253 205 458 458 458l582 4c253 0 459-206 459-459l0-70 0 0 0-863-277 54 1 675z"/></svg>
|
||
<span class="badge">КОНВЕРТЕР</span>
|
||
</div>
|
||
<main>
|
||
<h1>Конвертация старых документов Word</h1>
|
||
<p class="sub">
|
||
Загрузите файл в формате <strong>.doc</strong> (Microsoft Word 97–2003),
|
||
<strong>.rtf</strong> или <strong>.odt</strong> — получите современный
|
||
<strong>.docx</strong>. Конвертация происходит на сервере через LibreOffice,
|
||
исходный файл никуда не сохраняется.
|
||
</p>
|
||
<div class="card">
|
||
<div class="card-header">📦 Конвертировать файл</div>
|
||
<div class="card-body">
|
||
<form id="convForm">
|
||
<div class="form-row">
|
||
<label class="file-label" id="fileLabel">
|
||
<span id="fileText">Выберите файл .doc, .rtf или .odt</span>
|
||
<input type="file" name="file" accept=".doc,.rtf,.odt" required id="fileInput">
|
||
</label>
|
||
<button type="submit" class="btn" id="submitBtn">Конвертировать</button>
|
||
</div>
|
||
</form>
|
||
<div class="result" id="result"></div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-header">🔌 API для разработчиков</div>
|
||
<div class="card-body">
|
||
<div class="api-info">
|
||
<strong>Адрес:</strong> <code>https://liberta.containerk8s.dev.nubes.ru/convert</code><br>
|
||
<strong>Метод:</strong> <code>POST</code>, файл в <code>multipart/form-data</code>, поле <code>file</code>.<br>
|
||
<strong>Успех:</strong> в ответ придёт готовый <code>.docx</code> — сразу сохраняйте.<br>
|
||
<strong>Ошибка:</strong> <code>{"ok": false, "error": "..."}</code> с кодом 500.<br>
|
||
<strong>Проверка:</strong> <code>GET /health</code> → <code>{"ok": true}</code><br><br>
|
||
<strong>Пример curl:</strong><br>
|
||
<code style="word-break:break-all">curl -X POST -F "file=@документ.doc" https://liberta.containerk8s.dev.nubes.ru/convert -o результат.docx</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
<footer class="footer">Nubes © 2026</footer>
|
||
<script>
|
||
var f = document.getElementById('fileInput');
|
||
var t = document.getElementById('fileText');
|
||
var l = document.getElementById('fileLabel');
|
||
f.onchange = function() {
|
||
if (f.files.length) { t.className='filename'; t.textContent=f.files[0].name; l.classList.add('has-file'); }
|
||
else { t.className=''; t.textContent='Выберите файл .doc, .rtf или .odt'; l.classList.remove('has-file'); }
|
||
};
|
||
document.getElementById('convForm').onsubmit = async function(e) {
|
||
e.preventDefault();
|
||
var b = document.getElementById('submitBtn'), r = document.getElementById('result');
|
||
b.disabled = true; b.innerHTML = '<span class="spinner"></span>Идёт конвертация...';
|
||
r.className = 'result'; r.innerHTML = '';
|
||
try {
|
||
var fd = new FormData(); fd.append('file', f.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(), url = URL.createObjectURL(blob);
|
||
var fn = f.files[0].name.replace(/\\.[^.]+$/,'') + '.docx';
|
||
r.className = 'result ok show';
|
||
r.innerHTML = '✅ Готово! <a href=\"'+url+'\" download=\"'+fn+'\">Скачать '+fn+'</a>';
|
||
} else { var j = await resp.json();
|
||
r.className = 'result err show'; r.textContent = '❌ ' + (j.error || 'Неизвестная ошибка'); }
|
||
} catch(ex) { r.className = 'result err show'; r.textContent = '❌ Ошибка соединения: ' + ex.message; }
|
||
b.disabled = false; b.textContent = 'Конвертировать';
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
def _convert_file(f):
|
||
"""Convert one file to .docx, return (bytes, download_name) or raise."""
|
||
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
|
||
f.save(tmp)
|
||
doc_path = tmp.name
|
||
tmpdir = tempfile.mkdtemp()
|
||
try:
|
||
subprocess.run(
|
||
["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
|
||
timeout=120, capture_output=True, check=True
|
||
)
|
||
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
|
||
if not docx_files:
|
||
raise ValueError("conversion produced no .docx")
|
||
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
|
||
return out.read(), os.path.splitext(f.filename)[0] + ".docx"
|
||
finally:
|
||
if os.path.exists(doc_path):
|
||
os.unlink(doc_path)
|
||
for x in os.listdir(tmpdir):
|
||
os.unlink(os.path.join(tmpdir, x))
|
||
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"])
|
||
def health():
|
||
return {"ok": True}
|
||
|
||
if __name__ == "__main__":
|
||
import io
|
||
app.run(host="0.0.0.0", port=5000)
|