from typing import TYPE_CHECKING from itertools import chain import re from docx.document import Document from docx.shared import Length, Cm, Parented, Pt, Mm from docx.enum.text import WD_PARAGRAPH_ALIGNMENT from .numberer import Numberer, APPENDIX_LETTERS from .renderable import Renderable from .renderable.requires_numbering import RequiresNumbering from .renderable.heading import Heading from .renderable.equation import Equation from .rendered_info import RenderedInfo from .sub_renderable import SubRenderable from .util import create_element from .layout_tracker import LayoutTracker if TYPE_CHECKING: from .debugger import Debugger BOTTOM_MARGIN = Mm(20) APPENDIX_RE = re.compile( r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-Z])\b", re.IGNORECASE, ) SPECIAL_CENTER = { "СОДЕРЖАНИЕ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ", "СПИСОК ИСПОЛЬЗУЕМЫХ ИСТОЧНИКОВ", } class Renderer: """Renders Renderable elements to docx file""" def __init__(self, document: Document, debugger: "Debugger | None" = None, numbered_equations: set[str] | None = None, skip_numbering: bool = False, numbering_scope: str = "section"): self._document: Document = document self._numberer = Numberer(mode=numbering_scope) self._debugger = debugger self._numbered_equations = numbered_equations or set() self._skip_numbering = skip_numbering self._section_count = 0 self._appendix_index = 0 self._after_toc = False self._body_section_started = False max_height = (document.sections[0].page_height - document.sections[0].top_margin - BOTTOM_MARGIN) max_width = (self._document.sections[0].page_width - self._document.sections[0].left_margin - self._document.sections[0].right_margin) self._layout_tracker = LayoutTracker(max_height, max_width) # Front-matter section: no page numbers (титул / задание / содержание) self._clear_footer(self._document.sections[0]) self.previous_rendered = None self._to_new_page: list[Renderable] = [] @staticmethod def _clear_footer(section): footer = section.footer footer.is_linked_to_previous = False for p in footer.paragraphs: p.clear() if not footer.paragraphs: footer.add_paragraph() def _ensure_body_section_with_page_numbers(self): """After TOC: start a new section with centered PAGE (TNR 12).""" if self._body_section_started: return self._body_section_started = True # Continuous/new page section break is inserted via last paragraph sectPr; # python-docx: add_section creates new section. from docx.enum.section import WD_ORIENT, WD_SECTION new_section = self._document.add_section(WD_SECTION.NEW_PAGE) new_section.page_width = self._document.sections[0].page_width new_section.page_height = self._document.sections[0].page_height new_section.left_margin = Mm(30) new_section.right_margin = Mm(10) new_section.top_margin = Mm(20) new_section.bottom_margin = Mm(20) paragraph = new_section.footer.paragraphs[0] paragraph.paragraph_format.first_line_indent = 0 paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER run = paragraph.add_run() run.font.name = "Times New Roman" run.font.size = Pt(12) # PAGE field paragraph._p.append(create_element("w:fldSimple", { "w:instr": "PAGE \\* MERGEFORMAT" })) def process(self, renderables: list[Renderable]): for i in range(len(renderables)): self.render(renderables[i]) self._flush_to_new_screen() # If document had no TOC, still add page numbers to the only section if not self._body_section_started: section = self._document.sections[0] paragraph = section.footer.paragraphs[0] paragraph.paragraph_format.first_line_indent = 0 paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER paragraph._p.append(create_element("w:fldSimple", { "w:instr": "PAGE \\* MERGEFORMAT" })) if self._debugger: self._debugger.after_rendered() def _handle_heading(self, heading: Heading): text = (heading.text or "").strip() upper = text.upper() if heading.level == 1 and heading.is_numbered: self._section_count += 1 self._numberer.enter_section(self._section_count) # Appendix: «Приложение А» / «ПРИЛОЖЕНИЕ А» m = APPENDIX_RE.match(upper.replace("Ё", "Е")) if m or (heading.level <= 3 and upper.startswith("ПРИЛОЖЕНИЕ")): letter = None if m: letter = m.group(1).upper() else: parts = upper.split() if len(parts) >= 2 and parts[1] in APPENDIX_LETTERS: letter = parts[1] elif self._appendix_index < len(APPENDIX_LETTERS): letter = APPENDIX_LETTERS[self._appendix_index] self._appendix_index += 1 if letter: self._numberer.enter_appendix(letter) # Center special unnumbered headings that must be centered (already centered if unnumbered) if upper in SPECIAL_CENTER or upper.startswith("ПРИЛОЖЕНИЕ"): heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER # СОДЕРЖАНИЕ / СПИСОК — center even if somehow numbered if upper in ("СОДЕРЖАНИЕ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"): heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER # Native Word TOC picks up Heading 1–3; «СОДЕРЖАНИЕ» must not list itself if upper == "СОДЕРЖАНИЕ": heading.exclude_from_toc() def render(self, renderable: Renderable): if isinstance(renderable, Heading): self._handle_heading(renderable) # After ToC page-break renderable we open body section — detected via ToC's PageBreak from .renderable.toc import ToC from .renderable.page_break import PageBreak if isinstance(renderable, PageBreak) and self._after_toc: self._ensure_body_section_with_page_numbers() if isinstance(renderable, ToC): self._after_toc = True if not self._skip_numbering and isinstance(renderable, RequiresNumbering): if isinstance(renderable, Equation): label = renderable.unique_name if renderable.needs_numbering or (label and label in self._numbered_equations): renderable.enable_numbering() number = self._numberer.next_number( renderable.numbering_category, label) renderable.set_number(number) else: number = self._numberer.next_number( renderable.numbering_category, getattr(renderable, "unique_name", None), ) renderable.set_number(number) infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state) try: first = next(infos) if isinstance(first, RenderedInfo) and first.height \ >= self._layout_tracker.current_state.remaining_page_height: self._flush_to_new_screen() infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state) else: infos = chain([first], infos) except StopIteration: pass for info in infos: if isinstance(info, SubRenderable): if info.add_to_new_page: self._to_new_page.append(info.renderable) else: self.render(info.renderable) else: self._add(info.docx_element, info.height) self.previous_rendered = info def _flush_to_new_screen(self): while self._to_new_page: renderable = self._to_new_page.pop(0) if not self._skip_numbering and isinstance(renderable, RequiresNumbering): number = self._numberer.next_number( renderable.numbering_category, getattr(renderable, "unique_name", None), ) renderable.set_number(number) for info_ in renderable.render(self.previous_rendered, self._layout_tracker.current_state): if isinstance(info_, SubRenderable): continue self._add(info_.docx_element, info_.height) self.previous_rendered = info_ def _add(self, element: Parented, height: Length): self._document._body._element.append( element._element ) self._layout_tracker.add_height(height) if self._debugger: self._debugger.add(element, height) @property def numberer(self) -> Numberer: return self._numberer