415 lines
17 KiB
Python
415 lines
17 KiB
Python
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
|