233 lines
9.3 KiB
Python
233 lines
9.3 KiB
Python
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
|