from typing import TYPE_CHECKING from itertools import chain import re from docx.document import Document from docx.shared import Length, Parented, Mm, Cm from docx.enum.text import WD_PARAGRAPH_ALIGNMENT from docx.enum.section import WD_SECTION 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 .renderable.paragraph import Paragraph from .rendered_info import RenderedInfo from .sub_renderable import SubRenderable from .layout_tracker import LayoutTracker from .page_geometry import ( apply_centered_page_footer, apply_section_geometry, content_size, ) if TYPE_CHECKING: from .debugger import Debugger BOTTOM_MARGIN = Mm(20) APPENDIX_RE = re.compile( r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])\b", re.IGNORECASE, ) # Lettered appendix title: «Приложение А» or «Приложение А Название» APPENDIX_ITEM_RE = re.compile( r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])(?:\s+(.+))?$", 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 self._landscape_depth = 0 max_height, max_width = content_size(landscape=False) 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] = [] self._after_current: list[Renderable] = [] self._need_space_after_object = False @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 new_section = self._document.add_section(WD_SECTION.NEW_PAGE) apply_section_geometry(new_section, landscape=False) apply_centered_page_footer(new_section) def _enter_landscape(self) -> None: if self._landscape_depth > 0: self._landscape_depth += 1 return self._flush_to_new_screen() if not self._body_section_started: # Do not add_section(portrait) here. A portrait NEW_PAGE plus the # landscape NEW_PAGE below is two breaks: text → empty portrait → album. self._body_section_started = True apply_centered_page_footer(self._document.sections[-1]) section = self._document.add_section(WD_SECTION.NEW_PAGE) apply_section_geometry(section, landscape=True) apply_centered_page_footer(section) max_height, max_width = content_size(landscape=True) self._layout_tracker.set_page_size(max_height, max_width) self._landscape_depth = 1 def _exit_landscape(self) -> None: if self._landscape_depth <= 0: return self._landscape_depth -= 1 if self._landscape_depth > 0: return section = self._document.add_section(WD_SECTION.NEW_PAGE) apply_section_geometry(section, landscape=False) apply_centered_page_footer(section) max_height, max_width = content_size(landscape=False) self._layout_tracker.set_page_size(max_height, max_width) def process(self, renderables: list[Renderable]): items = self._ensure_appendix_index(list(renderables)) for i in range(len(items)): # Figures that did not fit the previous page must land before the # next top-level block (not after a later table / end of doc). self._flush_to_new_screen() self.render(items[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] apply_centered_page_footer(section) if self._debugger: self._debugger.after_rendered() def _ensure_appendix_index(self, renderables: list[Renderable]) -> list[Renderable]: """Insert «Приложение X — Title» list after # *ПРИЛОЖЕНИЯ if missing (method guide §6).""" sec_i = None first_app_i = None entries: list[tuple[str, str]] = [] for i, r in enumerate(renderables): if not isinstance(r, Heading): continue text = (r.text or "").strip() upper = text.upper().replace("Ё", "Е") if upper in ("ПРИЛОЖЕНИЯ", "ПРИЛОЖЕНИЕ") and sec_i is None: sec_i = i continue m = re.match(r"(?i)^Приложение\s+([А-ЯA-ZЁ])(?:\s+(.+))?$", text) if not m: continue letter = m.group(1).upper().replace("Ё", "Е") title = (m.group(2) or "").strip() if first_app_i is None: first_app_i = i entries.append((letter, title)) if sec_i is None or first_app_i is None or not entries: return renderables if self._has_appendix_list_between(renderables, sec_i, first_app_i): return renderables from .profiles import dash_separator dash = dash_separator() index_paras: list[Renderable] = [] for letter, title in entries: line = f"Приложение {letter}" if title: line = f"{line} {dash} {title}" index_paras.append(self._make_appendix_index_paragraph(line)) return renderables[: sec_i + 1] + index_paras + renderables[sec_i + 1 :] @staticmethod def _has_appendix_list_between( renderables: list[Renderable], sec_i: int, first_app_i: int ) -> bool: """True if author already put a list/prose between ПРИЛОЖЕНИЯ and first appendix.""" for r in renderables[sec_i + 1 : first_app_i]: if isinstance(r, Heading): continue text = "" if isinstance(r, Paragraph): text = (r._docx_paragraph.text or "").strip() elif hasattr(r, "text"): text = (getattr(r, "text", None) or "").strip() if not text: continue if re.search(r"(?i)приложение\s+[А-ЯA-ZЁ]", text): return True if re.search(r"[А-Яа-яA-Za-z]{3,}", text): return True return False def _make_appendix_index_paragraph(self, text: str) -> Paragraph: """Body-text line in the appendix list under ПРИЛОЖЕНИЯ (no first-line indent).""" p = Paragraph(self._document._body) p.add_run(text) p.style = "Normal" p.first_line_indent = Cm(0) pf = p._docx_paragraph.paragraph_format pf.left_indent = Cm(0) return p @staticmethod def _rewrite_heading_text(heading: Heading, text: str) -> None: runs = heading._docx_paragraph.runs if runs: runs[0].text = text for r in runs[1:]: r.text = "" else: heading.add_run(text) def _make_appendix_title_paragraph(self, title: str) -> Paragraph: """Title line under «Приложение А»: Normal, centered, no first-line indent.""" p = Paragraph(self._document._body) p.add_run(title) p.style = "Normal" p.first_line_indent = Cm(0) pf = p._docx_paragraph.paragraph_format pf.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER pf.left_indent = Cm(0) pf.keep_with_next = True return p def _handle_heading(self, heading: Heading): text = (heading.text or "").strip() upper = text.upper().replace("Ё", "Е") if heading.level == 1 and heading.is_numbered: self._section_count += 1 self._numberer.enter_section(self._section_count) # Letter appendix item: «Приложение А» / «Приложение А Название» # (not the section header «ПРИЛОЖЕНИЯ») item = APPENDIX_ITEM_RE.match(upper) if upper.startswith("ПРИЛОЖЕНИЕ ") else None if item: letter = item.group(1).upper().replace("Ё", "Е") title_tail = (item.group(2) or "").strip() if letter in APPENDIX_LETTERS: self._numberer.enter_appendix(letter) elif self._appendix_index < len(APPENDIX_LETTERS): letter = APPENDIX_LETTERS[self._appendix_index] self._appendix_index += 1 self._numberer.enter_appendix(letter) # Style as Heading 3, centered, new page (method guide §6) heading.style = "Heading 3" heading._level = 3 heading._remove_numbering() heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER heading._docx_paragraph.paragraph_format.left_indent = Cm(0) heading._docx_paragraph.paragraph_format.first_line_indent = Cm(0) heading.page_break_before = True heading._docx_paragraph.paragraph_format.keep_with_next = True orig = (heading.text or "").strip() m_orig = re.match(r"(?i)^Приложение\s+([А-ЯA-ZЁ])\b", orig) letter_display = m_orig.group(1).upper().replace("Ё", "Е") if m_orig else letter self._rewrite_heading_text(heading, f"Приложение {letter_display}") if title_tail: m_tail = re.match(r"(?i)^Приложение\s+[А-ЯA-ZЁ]\s+(.+)$", orig) title_text = m_tail.group(1).strip() if m_tail else title_tail self._after_current.append(self._make_appendix_title_paragraph(title_text)) return # Section «ПРИЛОЖЕНИЯ» / bare «ПРИЛОЖЕНИЕ» — left like H1, not centered if upper in ("ПРИЛОЖЕНИЯ", "ПРИЛОЖЕНИЕ"): heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT return # Only СОДЕРЖАНИЕ / СПИСОК … are centered (method guide p. 8) if upper in SPECIAL_CENTER: heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER heading._docx_paragraph.paragraph_format.left_indent = Cm(0) heading._docx_paragraph.paragraph_format.first_line_indent = Cm(0) # 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 from .renderable.table import Table from .renderable.listing import Listing from .renderable.diagram import DiagramFigure from .renderable.list import List as RList if isinstance(renderable, PageBreak) and self._after_toc: self._ensure_body_section_with_page_numbers() if isinstance(renderable, ToC): self._after_toc = True # Space 6 mm before next body paragraph after table/listing if self._need_space_after_object: self._need_space_after_object = False if isinstance(renderable, (Paragraph, RList)) and not isinstance(renderable, Heading): if isinstance(renderable, RList): # Apply to first list item when rendered — mark on list setattr(renderable, "_space_before_mm6", True) else: renderable._docx_paragraph.paragraph_format.space_before = Mm(6) wants_landscape = bool(getattr(renderable, "landscape", False)) deferred_listing = None if wants_landscape and isinstance(renderable, DiagramFigure) and renderable.listing is not None: # Method guide: landscape pages are for figures/tables only — listing after deferred_listing = renderable.listing renderable.listing = None if wants_landscape: self._enter_landscape() try: 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 finally: if wants_landscape: self._exit_landscape() # Appendix title line queued by _handle_heading while self._after_current: extra = self._after_current.pop(0) self.render(extra) if isinstance(renderable, ToC): # One NEXT_PAGE section after the TOC field — not a w:br plus a section. self._ensure_body_section_with_page_numbers() state = self._layout_tracker.current_state if state.current_page_height > 0: self._layout_tracker.new_page() if deferred_listing is not None: self.render(deferred_listing) if isinstance(renderable, (Table, Listing)): self._need_space_after_object = True elif isinstance(renderable, DiagramFigure) and deferred_listing is None and renderable.listing is None: # Diagram figure alone — no table/listing spacing needed after image caption pass def _flush_to_new_screen(self): while self._to_new_page: renderable = self._to_new_page.pop(0) self.render(renderable) def _add(self, element: Parented, height: Length): # MUST insert before the body-level w:sectPr. append() puts content after it, # so section breaks from +landscape never wrap the figure (Word shows portrait). body = self._document._body._element el = element._element from docx.oxml.ns import qn sect_pr = None for child in body: if child.tag == qn("w:sectPr"): sect_pr = child break if sect_pr is not None: sect_pr.addprevious(el) else: body.append(el) self._layout_tracker.add_height(height) if self._debugger: self._debugger.add(element, height) @property def numberer(self) -> Numberer: return self._numberer