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