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')
|
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:
|
def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
|
||||||
"""Обрабатывает циклы напрямую в ZIP/DOCX на уровне XML.
|
"""Обрабатывает циклы напрямую в ZIP/DOCX на уровне XML.
|
||||||
Работает до загрузки python-docx — гарантирует корректную вложенность.
|
Работает до загрузки python-docx — гарантирует корректную вложенность.
|
||||||
@@ -237,17 +365,28 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
|
|||||||
wrote = True
|
wrote = True
|
||||||
continue
|
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 хранит его
|
# Разрыв страницы рядом с маркером конца цикла (Word хранит его
|
||||||
# отдельным run в параграфе с <!-- loop_end -->). Если он есть —
|
# отдельным run в параграфе с <!-- loop_end -->). Если он есть —
|
||||||
# каждый акт/блок цикла должен начинаться с новой страницы.
|
# каждый акт/блок цикла должен начинаться с новой страницы.
|
||||||
probe = doc_xml[max(0, outer_end.start() - 800):outer_end.end()]
|
probe = doc_xml[max(0, outer_end.start() - 800):outer_end.end()]
|
||||||
has_page_break = '<w:br w:type="page"/>' in probe
|
has_page_break = '<w:br w:type="page"/>' in probe
|
||||||
|
# Если разрыв живёт в «хвосте» (параграф loop_end) — выносим его
|
||||||
# Извлекаем шаблон (всё между start и end маркерами)
|
# между итерациями, а из хвоста убираем (иначе акты слипаются).
|
||||||
template = doc_xml[outer.end():outer_end.start()]
|
br_in_suffix = has_page_break and '<w:br w:type="page"/>' in suffix
|
||||||
|
if br_in_suffix:
|
||||||
# НЕ удаляем loop_end из шаблона — они принадлежат вложенным циклам!
|
suffix = re.sub(r'<w:br\b[^>]*?w:type="page"[^>]*?/>', '', suffix)
|
||||||
template_clean = template
|
|
||||||
|
|
||||||
# Размножаем шаблон для каждого элемента данных
|
# Размножаем шаблон для каждого элемента данных
|
||||||
expanded_parts = []
|
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('{{item.number}}', str(idx))
|
||||||
clone = clone.replace('{{number}}', str(idx))
|
clone = clone.replace('{{number}}', str(idx))
|
||||||
expanded_parts.append(clone)
|
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: обрывок параграфа маркера начала (prefix) один раз,
|
||||||
# Собираем новый XML
|
# затем размноженное тело цикла, затем обрывок параграфа маркера конца (suffix)
|
||||||
new_xml = doc_xml[:outer.start()] + ''.join(expanded_parts) + doc_xml[outer_end.end():]
|
new_xml = (doc_xml[:outer.start()] + prefix
|
||||||
|
+ ''.join(expanded_parts) + suffix
|
||||||
|
+ doc_xml[outer_end.end():])
|
||||||
|
|
||||||
# Сохраняем ВСЕ файлы ZIP, обновляя только document.xml
|
# Сохраняем ВСЕ файлы ZIP, обновляя только document.xml
|
||||||
all_files['word/document.xml'] = new_xml.encode('utf-8')
|
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
|
pass
|
||||||
return docx_path
|
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
|
return tmp.name
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user