Files
Igor20264 510f7e7adf
Python application / build (push) Waiting to run
v0.5.2
Что то сделал
2026-09-08 19:37:54 +03:00

415 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 13; «СОДЕРЖАНИЕ» 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