Initial import of liberta convert service
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.12
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libreoffice && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["python3", "app.py"]
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
#!/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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
flask
|
||||||
Reference in New Issue
Block a user