@@ -0,0 +1,232 @@
|
||||
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
|
||||
Reference in New Issue
Block a user