feat: drhider PDF→DOCX conversion (LibreOffice), collision dialog (v1.9)
Deploy contracts-flask / validate (push) Successful in 0s

This commit is contained in:
2026-07-07 16:14:00 +04:00
parent e128b2614a
commit 211f391c6c
4 changed files with 144 additions and 13 deletions
+42 -4
View File
@@ -207,6 +207,8 @@ class TwoPassObfuscator:
""" """
# --- Распаковать ZIP-файлы --- # --- Распаковать ZIP-файлы ---
files = self._expand_zips(files) files = self._expand_zips(files)
# --- Конвертировать PDF → DOCX ---
files = self._convert_pdfs_to_docx(files)
try: try:
# --- Проход 1: сбор сущностей --- # --- Проход 1: сбор сущностей ---
@@ -236,10 +238,6 @@ class TwoPassObfuscator:
pass pass
elif fname in all_docx: elif fname in all_docx:
obf_content = self._replace_in_docx(all_docx[fname]) obf_content = self._replace_in_docx(all_docx[fname])
elif fname.endswith('.pdf'):
txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname)
fname = fname[:-4] + '.txt'
else: else:
txt = all_texts.get(fname, '') txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname) obf_content = self._replace_in_text(txt, fname)
@@ -253,6 +251,46 @@ class TwoPassObfuscator:
self._regex_replacements.clear() self._regex_replacements.clear()
self._sorted_keys.clear() self._sorted_keys.clear()
def _convert_pdfs_to_docx(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Конвертировать PDF в DOCX через LibreOffice. При совпадении имён — _из_pdf."""
import subprocess, tempfile
result = []
existing_names = {f[0] for f in files}
for fname, content, ctype in files:
if not fname.lower().endswith('.pdf'):
result.append((fname, content, ctype))
continue
try:
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
tmp.write(content)
pdf_path = tmp.name
outdir = tempfile.mkdtemp()
subprocess.run(['libreoffice', '--headless', '--convert-to', 'docx',
'--outdir', outdir, pdf_path], timeout=30, capture_output=True)
docx_files = [f for f in os.listdir(outdir) if f.endswith('.docx')]
if docx_files:
with open(os.path.join(outdir, docx_files[0]), 'rb') as f:
docx_content = f.read()
new_name = fname[:-4] + '.docx'
if new_name in existing_names:
new_name = fname[:-4] + '_из_pdf.docx'
existing_names.add(new_name)
result.append((new_name, docx_content, ctype))
else:
log.warning("PDF→DOCX failed for %s", fname)
result.append((fname, content, ctype))
except Exception as e:
log.warning("PDF→DOCX error for %s: %s", fname, e)
result.append((fname, content, ctype))
finally:
if os.path.exists(pdf_path):
os.unlink(pdf_path)
if os.path.exists(outdir):
for f in os.listdir(outdir):
os.unlink(os.path.join(outdir, f))
os.rmdir(outdir)
return result
def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]: def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть. """Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть.
Защита от ZIP-бомб: ratio + накопительный размер (как в compare/unzip.py).""" Защита от ZIP-бомб: ratio + накопительный размер (как в compare/unzip.py)."""
+42 -4
View File
@@ -207,6 +207,8 @@ class TwoPassObfuscator:
""" """
# --- Распаковать ZIP-файлы --- # --- Распаковать ZIP-файлы ---
files = self._expand_zips(files) files = self._expand_zips(files)
# --- Конвертировать PDF → DOCX ---
files = self._convert_pdfs_to_docx(files)
try: try:
# --- Проход 1: сбор сущностей --- # --- Проход 1: сбор сущностей ---
@@ -236,10 +238,6 @@ class TwoPassObfuscator:
pass pass
elif fname in all_docx: elif fname in all_docx:
obf_content = self._replace_in_docx(all_docx[fname]) obf_content = self._replace_in_docx(all_docx[fname])
elif fname.endswith('.pdf'):
txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname)
fname = fname[:-4] + '.txt'
else: else:
txt = all_texts.get(fname, '') txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname) obf_content = self._replace_in_text(txt, fname)
@@ -253,6 +251,46 @@ class TwoPassObfuscator:
self._regex_replacements.clear() self._regex_replacements.clear()
self._sorted_keys.clear() self._sorted_keys.clear()
def _convert_pdfs_to_docx(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Конвертировать PDF в DOCX через LibreOffice. При совпадении имён — _из_pdf."""
import subprocess, tempfile
result = []
existing_names = {f[0] for f in files}
for fname, content, ctype in files:
if not fname.lower().endswith('.pdf'):
result.append((fname, content, ctype))
continue
try:
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
tmp.write(content)
pdf_path = tmp.name
outdir = tempfile.mkdtemp()
subprocess.run(['libreoffice', '--headless', '--convert-to', 'docx',
'--outdir', outdir, pdf_path], timeout=30, capture_output=True)
docx_files = [f for f in os.listdir(outdir) if f.endswith('.docx')]
if docx_files:
with open(os.path.join(outdir, docx_files[0]), 'rb') as f:
docx_content = f.read()
new_name = fname[:-4] + '.docx'
if new_name in existing_names:
new_name = fname[:-4] + '_из_pdf.docx'
existing_names.add(new_name)
result.append((new_name, docx_content, ctype))
else:
log.warning("PDF→DOCX failed for %s", fname)
result.append((fname, content, ctype))
except Exception as e:
log.warning("PDF→DOCX error for %s: %s", fname, e)
result.append((fname, content, ctype))
finally:
if os.path.exists(pdf_path):
os.unlink(pdf_path)
if os.path.exists(outdir):
for f in os.listdir(outdir):
os.unlink(os.path.join(outdir, f))
os.rmdir(outdir)
return result
def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]: def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть.""" """Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть."""
result = [] result = []
+59 -4
View File
@@ -84,7 +84,7 @@
<a href="https://contractor.pythonk8s.services.ngcloud.ru/">Сверка договоров</a> <a href="https://contractor.pythonk8s.services.ngcloud.ru/">Сверка договоров</a>
<span class="sep">|</span> <span class="sep">|</span>
<strong>DrHider</strong> <strong>DrHider</strong>
<span style="font-size:11px;color:var(--muted);">v1.8</span> <span style="font-size:11px;color:var(--muted);">v1.9</span>
<button class="help-btn" onclick="openModal()" title="О сервисе">?</button> <button class="help-btn" onclick="openModal()" title="О сервисе">?</button>
</header> </header>
@@ -123,7 +123,7 @@
<h3>✅ Форматы файлов</h3> <h3>✅ Форматы файлов</h3>
<ul> <ul>
<li><strong>.docx</strong> — полная замена с сохранением структуры документа. ⚠️ Форматирование (жирный, курсив) заменённых фрагментов может сброситься.</li> <li><strong>.docx</strong> — полная замена с сохранением структуры документа. ⚠️ Форматирование (жирный, курсив) заменённых фрагментов может сброситься.</li>
<li><strong>.pdf</strong>текст извлекается и обфусцируется. Результат: <code>.txt</code>. Форматирование, таблицы и графика не сохраняются.</li> <li><strong>.pdf</strong>конвертируется в .docx (через LibreOffice), затем обфусцируется с сохранением форматирования. При совпадении имён с существующим .docx добавляется суффикс <code>_из_pdf</code>.</li>
<li><strong>.doc</strong> — бинарный формат, <strong>не обфусцируется</strong>. Файл возвращается как есть. Конвертируйте в .docx перед загрузкой.</li> <li><strong>.doc</strong> — бинарный формат, <strong>не обфусцируется</strong>. Файл возвращается как есть. Конвертируйте в .docx перед загрузкой.</li>
<li><strong>.zip</strong> — автоматически распаковывается, все файлы внутри обрабатываются.</li> <li><strong>.zip</strong> — автоматически распаковывается, все файлы внутри обрабатываются.</li>
</ul> </ul>
@@ -139,7 +139,7 @@
<strong>.doc не обрабатывается:</strong> старые Word-файлы возвращаются без изменений. <strong>.doc не обрабатывается:</strong> старые Word-файлы возвращаются без изменений.
</div> </div>
<div class="warn"> <div class="warn">
<strong>PDF → текст:</strong> таблицы, графика и форматирование теряются. <strong>PDF → DOCX:</strong> конвертация через LibreOffice может незначительно изменить форматирование сложных PDF.
</div> </div>
<div class="warn"> <div class="warn">
<strong>Сканы/изображения:</strong> текст на картинках не распознаётся (нет OCR). <strong>Сканы/изображения:</strong> текст на картинках не распознаётся (нет OCR).
@@ -211,6 +211,44 @@ function render() {
BTN.onclick = () => { BTN.onclick = () => {
if (!files.length) return; if (!files.length) return;
// Проверить коллизии PDF→DOCX
const collisions = [];
const names = files.map(f => f.name);
files.forEach(f => {
if (f.name.toLowerCase().endsWith('.pdf')) {
const docxName = f.name.slice(0, -4) + '.docx';
if (names.includes(docxName)) {
collisions.push({pdf: f.name, docx: docxName});
}
}
});
if (collisions.length) {
showCollisionModal(collisions);
return;
}
doSubmit();
};
function showCollisionModal(collisions) {
const list = collisions.map(c => `<div style="margin-bottom:4px">📄 <b>${esc(c.pdf)}</b> → <b>${esc(c.docx)}</b> (уже есть)</div>`).join('');
document.getElementById('collisionList').innerHTML = list;
document.getElementById('collisionOverlay').classList.add('show');
window._collisions = collisions;
}
function closeCollision(skipAll) {
document.getElementById('collisionOverlay').classList.remove('show');
if (skipAll) {
// Удалить PDF-файлы из списка
const pdfNames = window._collisions.map(c => c.pdf);
files = files.filter(f => !pdfNames.includes(f.name));
render();
}
// В любом случае отправляем (бэкенд сам переименует)
setTimeout(doSubmit, 100);
}
function doSubmit() {
BTN.disabled = true; BTN.disabled = true;
setStatus('progress', '⏳ Отправка...'); setStatus('progress', '⏳ Отправка...');
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@@ -248,7 +286,24 @@ function fmt(n) { return n>1e6 ? (n/1e6).toFixed(1)+' MB' : n>1e3 ? (n/1e3).toFi
// ── Модальное окно ───────────────────────────────────────────── // ── Модальное окно ─────────────────────────────────────────────
function openModal() { document.getElementById('modalOverlay').classList.add('show'); } function openModal() { document.getElementById('modalOverlay').classList.add('show'); }
function closeModal() { document.getElementById('modalOverlay').classList.remove('show'); } function closeModal() { document.getElementById('modalOverlay').classList.remove('show'); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); // ── Коллизия PDF→DOCX ──────────────────────────────────────────
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeCollision(true); });
</script> </script>
<!-- Модальное окно: коллизия PDF→DOCX -->
<div class="modal-overlay" id="collisionOverlay">
<div class="modal" style="max-width:480px">
<div class="modal-header">⚠️ Конфликт имён</div>
<div class="modal-body">
<p style="margin-bottom:10px">PDF-файлы конвертируются в DOCX. Имена совпадают:</p>
<div id="collisionList" style="margin-bottom:12px"></div>
<p style="font-size:12px;color:var(--muted)">«Перезаписать» — PDF-файл заменит DOCX.<br>«Пропустить» — PDF будут удалены из списка.</p>
</div>
<div style="display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--brand-gray)">
<button class="btn" onclick="closeCollision(true)" style="flex:1">Пропустить PDF</button>
<button class="btn btn-primary" onclick="closeCollision(false)" style="flex:1">Перезаписать</button>
</div>
</div>
</div>
</body> </body>
</html> </html>
+1 -1
View File
@@ -73,7 +73,7 @@
<body> <body>
<div class="topbar"> <div class="topbar">
<img src="/static/logo.svg" alt="Nubes"> <img src="/static/logo.svg" alt="Nubes">
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.195-flask</span></span> <span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.196-flask</span></span>
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);"> <div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
<span id="stepUpload">○ Загрузка</span><span></span> <span id="stepUpload">○ Загрузка</span><span></span>
<span id="stepClassify">○ Классификация</span><span></span> <span id="stepClassify">○ Классификация</span><span></span>