feat: drhider PDF→DOCX conversion (LibreOffice), collision dialog (v1.9)
Deploy contracts-flask / validate (push) Successful in 0s
Deploy contracts-flask / validate (push) Successful in 0s
This commit is contained in:
@@ -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)."""
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user