from copy import copy from typing import Generator from docx.shared import Pt, Cm, Twips from . import Paragraph from .renderable import Renderable from ..layout_tracker import LayoutState from ..rendered_info import RenderedInfo # ТЗ табл. 2.2: маркер 1,25 см; текст / табуляция 2,25 см MARKER_POS = Cm(1.25) TEXT_POS = Cm(2.25) BULLET = "–" # en dash, единый маркер на весь документ class List(Renderable): def __init__(self, parent, ordered: bool): self._parent = parent self._ordered = ordered self._paragraphs: list[Paragraph] = [] self._last_paragraph_space_after = 0 self._numbering = [0 for _ in range(10)] self._item_count = 0 def add_item(self, level: int) -> Paragraph: self._numbering[level - 1] += 1 for i in range(level, len(self._numbering)): self._numbering[i] = 0 self._item_count += 1 paragraph = Paragraph(self._parent) if self._ordered: marker = ".".join(str(n) for n in self._numbering[:level]) + "." else: marker = BULLET paragraph.add_run(marker + "\t") # hanging indent: left = TEXT_POS (+ nesting), first_line = MARKER_POS - TEXT_POS nest = TEXT_POS * (level - 1) paragraph._docx_paragraph.paragraph_format.tab_stops.add_tab_stop(TEXT_POS + nest) paragraph._docx_paragraph.paragraph_format.left_indent = TEXT_POS + nest paragraph._docx_paragraph.paragraph_format.first_line_indent = MARKER_POS - TEXT_POS self._last_paragraph_space_after = paragraph._docx_paragraph.paragraph_format.space_after paragraph._docx_paragraph.paragraph_format.space_before = 0 paragraph._docx_paragraph.paragraph_format.space_after = 0 self._paragraphs.append(paragraph) return paragraph @staticmethod def _is_hyphen_run(run) -> bool: """True for a run that carries only w:noBreakHyphen (text is empty).""" if run.text: return False return bool(run._element.xpath("./w:noBreakHyphen")) @classmethod def _logical_text(cls, runs) -> str: parts = [] for run in runs: if cls._is_hyphen_run(run): parts.append("-") else: parts.append(run.text or "") return "".join(parts) def finalize_punctuation(self) -> None: """Apply TZ list punctuation: bullet → lowercase + ';' (last '.'); numbered → Capital + '.'. Case and the final mark are written into existing runs. Runs are not merged: merging drops w:noBreakHyphen and inline code formatting, so «REST-интерфейс» became «RESTинтерфейс». """ for i, paragraph in enumerate(self._paragraphs): runs = paragraph._docx_paragraph.runs if len(runs) < 2: continue text_runs = runs[1:] logical = self._logical_text(text_runs) full = logical.strip() if not full: continue is_last = i == len(self._paragraphs) - 1 if self._ordered: fixed = full[0].upper() + full[1:] if not fixed.endswith("."): fixed = fixed.rstrip(";,. ") + "." else: fixed = full[0].lower() + full[1:] if is_last: fixed = fixed.rstrip(";,. ") + "." else: fixed = fixed.rstrip(";,. ") + ";" if logical == fixed: continue self._apply_list_punctuation(text_runs, full, fixed) @classmethod def _apply_list_punctuation(cls, text_runs, full: str, fixed: str) -> None: if full[0] != fixed[0]: for run in text_runs: if cls._is_hyphen_run(run) or not run.text: continue run.text = fixed[0] + run.text[1:] break desired_end = fixed[-1] last = None for run in text_runs: if cls._is_hyphen_run(run) or not run.text: continue last = run if last is None: return trimmed = last.text.rstrip(";,. ") if trimmed.endswith(desired_end): last.text = trimmed else: last.text = trimmed + desired_end def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[ RenderedInfo | Renderable, None, None]: self.finalize_punctuation() if self._paragraphs: self._paragraphs[-1]._docx_paragraph.paragraph_format.space_after = self._last_paragraph_space_after if getattr(self, "_space_before_mm6", False) and self._paragraphs: from docx.shared import Mm self._paragraphs[0]._docx_paragraph.paragraph_format.space_before = Mm(6) for paragraph in self._paragraphs: for x in paragraph.render(previous_rendered, copy(layout_state)): layout_state.add_height(x.height) previous_rendered = x yield x