fix: убраны пустые строки между итерациями цикла + сохранены разрывы страниц
Word оставляет пустые параграфы и обрывки вокруг маркеров <!-- loop:... -->. При размножении цикла они копировались между строками (комиссия слипалась с пустыми строками). Теперь: - template разделяется на prefix/core/suffix, пустые параграфы в core чистятся - остатки маркеров удаляются после обработки (не трогая таблицы и page breaks) - разрыв страницы из параграфа loop_end выносится между итерациями Проверено: комиссия без пустых строк, акт 6 с 2 ИС — 2 разрыва страниц, XML валиден.
This commit is contained in:
+166
-9
@@ -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 оставляет их вокруг маркеров <!-- loop:... -->, из-за чего
|
||||
при размножении цикла между строками появляются пустые строки."""
|
||||
para_re = re.compile(r'<w:p\b[^>]*?(?:/>|>.*?</w:p>)', re.S)
|
||||
|
||||
def _is_empty(p: str) -> bool:
|
||||
# Не пустой, если есть разрыв страницы, рисунок, таблица и т.п.
|
||||
if '<w:br' in p or '<w:drawing' in p or '<w:tbl' in p or '<w:object' in p:
|
||||
return False
|
||||
texts = re.findall(r'<w:t[^>]*>(.*?)</w:t>', 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-фрагменте. Таблицы считаются одним элементом.
|
||||
Обрывки (незакрытые <w:p в конце фрагмента) не включаются —
|
||||
они относятся к параграфу маркера конца (suffix)."""
|
||||
ranges = []
|
||||
i = 0
|
||||
n = len(fragment)
|
||||
while i < n:
|
||||
if fragment.startswith('<w:tbl>', i) or fragment.startswith('<w:tbl ', i):
|
||||
j = i
|
||||
d = 0
|
||||
while j < n:
|
||||
if fragment.startswith('<w:tbl>', j) or fragment.startswith('<w:tbl ', j):
|
||||
d += 1
|
||||
j += 5
|
||||
elif fragment.startswith('</w:tbl>', j):
|
||||
d -= 1
|
||||
j += 8
|
||||
if d == 0:
|
||||
break
|
||||
else:
|
||||
j += 1
|
||||
ranges.append((i, j))
|
||||
i = j
|
||||
elif fragment.startswith('<w:p>', i) or fragment.startswith('<w:p ', i):
|
||||
j = fragment.find('</w:p>', 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 — начало параграфа маркера конца (до <!--)."""
|
||||
ranges = _top_level_ranges(template)
|
||||
if not ranges:
|
||||
return '', template, ''
|
||||
prefix = template[:ranges[0][0]]
|
||||
suffix = template[ranges[-1][1]:]
|
||||
core = template[ranges[0][0]:ranges[-1][1]]
|
||||
return prefix, core, suffix
|
||||
|
||||
|
||||
def _cleanup_marker_paras(doc_xml: str) -> str:
|
||||
"""Удаляет параграфы-остатки маркеров циклов: <w:p>...<w:r><w:t></w:t></w:r></w:p>
|
||||
(пустой run — бывший маркер <!-- loop:... -->). Намеренные пустые строки
|
||||
(<w:p/> без 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 в параграфе с <!-- loop_end -->). Если он есть —
|
||||
# каждый акт/блок цикла должен начинаться с новой страницы.
|
||||
probe = doc_xml[max(0, outer_end.start() - 800):outer_end.end()]
|
||||
has_page_break = '<w:br w:type="page"/>' 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 '<w:br w:type="page"/>' in suffix
|
||||
if br_in_suffix:
|
||||
suffix = re.sub(r'<w:br\b[^>]*?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('<w:p><w:r><w:br w:type="page"/></w:r></w:p>')
|
||||
|
||||
|
||||
# Собираем новый 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
|
||||
|
||||
# Финальная зачистка: пустые параграфы-остатки маркеров (<!-- loop:... -->)
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user