from copy import copy from typing import Generator import re from docx.text.paragraph import Paragraph as DocxParagraph from docx.shared import Parented, Length from .paragraph_sizer import ParagraphSizer from ..layout_tracker import LayoutState from .paragraph import Paragraph from ..rendered_info import RenderedInfo from ..sub_renderable import SubRenderable from ..util import create_element # Leading section numbers written in markdown: "1 ", "1.1 ", "1.2.3. " _LEADING_NUMBER_RE = re.compile(r"^\d+(?:\.\d+)*\.?\s+") class Heading(Paragraph): def __init__(self, parent: Parented, level: int, numbered: bool, numbering_mode: str = "manual"): super().__init__(parent) self._numbered = numbered self._numbering_mode = numbering_mode # auto | manual self._parent = parent self._level = level if not 1 <= level <= 9: raise ValueError("Heading level must be in range from 1 to 9") self.style = f"Heading {level}" if not numbered: self._remove_numbering() # Alignment decided later in Renderer: only СОДЕРЖАНИЕ / СПИСОК centered. # ВВЕДЕНИЕ / ЗАКЛЮЧЕНИЕ / ПРИЛОЖЕНИЯ stay left like Heading 1 (1.25 cm indent). elif numbering_mode == "manual": # Digits already in markdown text — kill Word list numbering to avoid "1 1 …" self._remove_numbering() self._rendered_page = 0 self._title_stripped = False @property def is_numbered(self) -> bool: return self._numbered @property def numbering_mode(self) -> str: return self._numbering_mode @numbering_mode.setter def numbering_mode(self, value: str): self._numbering_mode = value if self._numbered and value == "manual": self._remove_numbering() @property def rendered_page(self) -> int: return self._rendered_page @property def level(self) -> int: return self._level @property def text(self) -> str: return self._docx_paragraph.text def apply_numbering_mode(self, mode: str): """Apply auto/manual after runs are filled.""" self._numbering_mode = mode if not self._numbered: return if mode == "manual": self._remove_numbering() elif mode == "auto": self._strip_leading_number_from_runs() def _strip_leading_number_from_runs(self): """Remove '1 ', '1.1 ' etc. from text so Word list provides the number.""" if self._title_stripped or not self._numbered: return full = self._docx_paragraph.text or "" m = _LEADING_NUMBER_RE.match(full) if not m: return # Rewrite runs: put remaining text into first run, clear the rest remaining = full[m.end():] runs = self._docx_paragraph.runs if not runs: return runs[0].text = remaining for r in runs[1:]: r.text = "" self._title_stripped = True def _remove_numbering(self): pPr = self._docx_paragraph._p.get_or_add_pPr() # Remove existing numPr if any, then set numId=0 for child in list(pPr): if child.tag.endswith("}numPr"): pPr.remove(child) pPr.append( create_element("w:numPr", [ create_element("w:ilvl", { "w:val": "0" }), create_element("w:numId", { "w:val": "0" }) ]) ) def exclude_from_toc(self): """Keep Heading N look, but outline level = Body Text so Word TOC skips it.""" pPr = self._docx_paragraph._p.get_or_add_pPr() for child in list(pPr): if child.tag.endswith("}outlineLvl"): pPr.remove(child) # 9 = body text (not outline levels 1–3 used by TOC \o "1-3") pPr.append(create_element("w:outlineLvl", {"w:val": "9"})) def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\ -> Generator[RenderedInfo | SubRenderable, None, None]: remaining_height = layout_state.remaining_page_height if self._level == 1 and layout_state.page != 1 and\ not (isinstance(previous_rendered.docx_element, DocxParagraph) and previous_rendered.docx_element.text == "\n"): self.page_break_before = True height_data = ParagraphSizer( self._docx_paragraph, previous_rendered.docx_element if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None, layout_state.max_width).calculate_height() if layout_state.current_page_height == 0 and layout_state.page != 1: height_data.before = 0 # if a heading + 3 lines don't fit to the page, they go to the next page if ((height_data.lines + 3 - 1) * height_data.line_spacing + 1) * height_data.line_height\ > layout_state.remaining_page_height: self._docx_paragraph.paragraph_format.space_before = 0 # libreoffice fix height = height_data.full - height_data.before # force this behaviour as there could be a table or an image instead of text self.page_break_before = True else: height = height_data.full if self.page_break_before: height += remaining_height layout_state.add_height(height) self._rendered_page = layout_state.page yield RenderedInfo(self._docx_paragraph, Length(height))