diff --git a/dokogen/generator.py b/dokogen/generator.py index 4e0a314..19c7b72 100644 --- a/dokogen/generator.py +++ b/dokogen/generator.py @@ -154,6 +154,134 @@ def _normalize_loop_markers(doc_xml: str) -> str: return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True).decode('utf-8') +def _strip_edge_empty_paras(fragment: str) -> str: + """Удаляет пустые параграфы (без текста) с начала и конца XML-фрагмента. + Word оставляет их вокруг маркеров , из-за чего + при размножении цикла между строками появляются пустые строки.""" + para_re = re.compile(r']*?(?:/>|>.*?)', re.S) + + def _is_empty(p: str) -> bool: + # Не пустой, если есть разрыв страницы, рисунок, таблица и т.п. + if ']*>(.*?)', p, re.S) + if not texts: + return True + return all(t.strip() == '' for t in texts) + + # С начала + while True: + m = para_re.match(fragment) + if not m: + break + if _is_empty(m.group(0)): + fragment = fragment[m.end():] + else: + break + + # С конца + while True: + m = para_re.search(fragment) + if not m: + break + if m.end() != len(fragment): + break + if _is_empty(m.group(0)): + fragment = fragment[:m.start()] + else: + break + + return fragment + + +def _top_level_ranges(fragment: str): + """Возвращает список (start, end) ПОЛНЫХ элементов верхнего уровня + (w:p, w:tbl) в XML-фрагменте. Таблицы считаются одним элементом. + Обрывки (незакрытые ', i) or fragment.startswith('', j) or fragment.startswith('', j): + d -= 1 + j += 8 + if d == 0: + break + else: + j += 1 + ranges.append((i, j)) + i = j + elif fragment.startswith('', i) or fragment.startswith('', i) + if j == -1: + break # обрывок — не полный параграф, дальше suffix + j += 6 + ranges.append((i, j)) + i = j + else: + i += 1 + return ranges + + +def _split_template_edges(template: str): + """Разделяет шаблон цикла на три части: + prefix — хвост параграфа маркера начала (после -->), + core — полные элементы верхнего уровня (параграфы/таблицы) между маркерами, + suffix — начало параграфа маркера конца (до ). Намеренные пустые строки + ( без run'ов) не трогаем.""" + try: + root = etree.fromstring(doc_xml.encode('utf-8')) + except Exception: + return doc_xml + removed = False + for p in list(root.iter(f'{{{W_NS}}}p')): + # Не трогаем параграфы внутри таблиц (пустые ячейки валидны) + parent = p.getparent() + if parent is None: + continue + if parent.tag == f'{{{W_NS}}}tc': + continue + # Не трогаем параграфы с разрывом страницы/строки, рисунками и т.п. + if (p.find(f'{{{W_NS}}}r/{{{W_NS}}}br') is not None + or p.find(f'{{{W_NS}}}r/{{{W_NS}}}drawing') is not None + or p.find(f'{{{W_NS}}}r/{{{W_NS}}}object') is not None + or p.find(f'{{{W_NS}}}r/{{{W_NS}}}pict') is not None): + continue + # Есть непустой текст — не трогаем + texts = [t.text or '' for t in p.iter(f'{{{W_NS}}}t')] + if any(t.strip() for t in texts): + continue + # Есть run'ы (признак бывшего маркера), но текста нет — удаляем + runs = p.findall(f'{{{W_NS}}}r') + if not runs: + continue + parent.remove(p) + removed = True + if not removed: + return doc_xml + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True).decode('utf-8') + + def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str: """Обрабатывает циклы напрямую в ZIP/DOCX на уровне XML. Работает до загрузки python-docx — гарантирует корректную вложенность. @@ -237,17 +365,28 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str: wrote = True continue + # Извлекаем шаблон (всё между start и end маркерами) + template = doc_xml[outer.end():outer_end.start()] + + # Word оставляет обрывки параграфов вокруг маркеров и пустые параграфы. + # Отделяем обрывки (prefix/suffix) от тела цикла и чистим пустые + # параграфы в теле — иначе между итерациями появляются пустые строки. + prefix, core, suffix = _split_template_edges(template) + core = _strip_edge_empty_paras(core) + + # НЕ удаляем loop_end из шаблона — они принадлежат вложенным циклам! + template_clean = core + # Разрыв страницы рядом с маркером конца цикла (Word хранит его # отдельным run в параграфе с ). Если он есть — # каждый акт/блок цикла должен начинаться с новой страницы. probe = doc_xml[max(0, outer_end.start() - 800):outer_end.end()] has_page_break = '' in probe - - # Извлекаем шаблон (всё между start и end маркерами) - template = doc_xml[outer.end():outer_end.start()] - - # НЕ удаляем loop_end из шаблона — они принадлежат вложенным циклам! - template_clean = template + # Если разрыв живёт в «хвосте» (параграф loop_end) — выносим его + # между итерациями, а из хвоста убираем (иначе акты слипаются). + br_in_suffix = has_page_break and '' in suffix + if br_in_suffix: + suffix = re.sub(r']*?w:type="page"[^>]*?/>', '', suffix) # Размножаем шаблон для каждого элемента данных expanded_parts = [] @@ -262,10 +401,14 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str: clone = clone.replace('{{item.number}}', str(idx)) clone = clone.replace('{{number}}', str(idx)) expanded_parts.append(clone) + if br_in_suffix: + expanded_parts.append('') - - # Собираем новый XML - new_xml = doc_xml[:outer.start()] + ''.join(expanded_parts) + doc_xml[outer_end.end():] + # Собираем новый XML: обрывок параграфа маркера начала (prefix) один раз, + # затем размноженное тело цикла, затем обрывок параграфа маркера конца (suffix) + new_xml = (doc_xml[:outer.start()] + prefix + + ''.join(expanded_parts) + suffix + + doc_xml[outer_end.end():]) # Сохраняем ВСЕ файлы ZIP, обновляя только document.xml all_files['word/document.xml'] = new_xml.encode('utf-8') @@ -283,6 +426,20 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str: pass return docx_path + # Финальная зачистка: пустые параграфы-остатки маркеров () + try: + with zipfile.ZipFile(tmp.name, 'r') as z: + all_files = {name: z.read(name) for name in z.namelist()} + doc_xml = all_files.get('word/document.xml', b'').decode('utf-8') + cleaned = _cleanup_marker_paras(doc_xml) + if cleaned != doc_xml: + all_files['word/document.xml'] = cleaned.encode('utf-8') + with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z: + for name, data in all_files.items(): + z.writestr(name, data) + except Exception: + pass + return tmp.name