191 lines
13 KiB
Python
191 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""LibreOffice converter — Flask HTTP service. .doc → .docx with UI + API."""
|
||
import subprocess, tempfile, os, base64
|
||
from flask import Flask, request, send_file, render_template_string
|
||
|
||
app = Flask(__name__)
|
||
VERSION = "1.0.0"
|
||
|
||
# Логотип как data URI (как в Сверке — через img, не inline SVG)
|
||
_LOGO_SVG = """<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" viewBox="0 0 8221.93 1821.07"><defs><style>.fil0{fill:#001C34}</style></defs><g id="Слой_x0020_1"><path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/><path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/><path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/><path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/><path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/></g></svg>"""
|
||
LOGO_DATA_URI = "data:image/svg+xml;base64," + base64.b64encode(_LOGO_SVG.encode()).decode()
|
||
|
||
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">
|
||
<img src="/static/logo.svg" alt="Nubes" style="height:28px">
|
||
<span class="badge">КОНВЕРТЕР</span>
|
||
<span class="badge" style="background:var(--brand-grey-light);color:var(--nubes-dark);">v__VERSION__</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 · v__VERSION__</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.replace("__VERSION__", VERSION))
|
||
|
||
@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, "version": VERSION}
|
||
|
||
@app.route("/static/logo.svg")
|
||
def logo():
|
||
from flask import Response
|
||
return Response(_LOGO_SVG, mimetype="image/svg+xml")
|
||
|
||
if __name__ == "__main__":
|
||
import io
|
||
app.run(host="0.0.0.0", port=5000)
|