Update 0.4.0
Python application / build (push) Has been cancelled

- Add\Rework UI
- Add Split Table and Listing
- Add Support Customazeble schems
This commit is contained in:
Igor20264
2026-09-04 22:28:39 +03:00
parent 516abe7b83
commit b38661f588
70 changed files with 69532 additions and 413 deletions
+203 -100
View File
@@ -3,18 +3,24 @@ from itertools import chain
import re
from docx.document import Document
from docx.shared import Length, Cm, Parented, Pt, Mm
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 .util import create_element
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
@@ -22,7 +28,12 @@ if TYPE_CHECKING:
BOTTOM_MARGIN = Mm(20)
APPENDIX_RE = re.compile(
r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-Z])\b",
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 = {
@@ -48,13 +59,9 @@ class Renderer:
self._appendix_index = 0
self._after_toc = False
self._body_section_started = False
self._landscape_depth = 0
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)
max_height, max_width = content_size(landscape=False)
self._layout_tracker = LayoutTracker(max_height, max_width)
# Front-matter section: no page numbers (титул / задание / содержание)
@@ -62,6 +69,8 @@ class Renderer:
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):
@@ -77,27 +86,39 @@ class Renderer:
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)
apply_section_geometry(new_section, landscape=False)
apply_centered_page_footer(new_section)
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 _enter_landscape(self) -> None:
if self._landscape_depth > 0:
self._landscape_depth += 1
return
self._flush_to_new_screen()
if self._after_toc:
self._ensure_body_section_with_page_numbers()
elif not self._body_section_started:
# No TOC — still need page numbers on subsequent sections.
self._body_section_started = True
apply_centered_page_footer(self._document.sections[0])
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]):
for i in range(len(renderables)):
@@ -107,46 +128,84 @@ class Renderer:
# 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"
}))
apply_centered_page_footer(section)
if self._debugger:
self._debugger.after_rendered()
@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()
upper = text.upper().replace("Ё", "Е")
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:
# 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)
# Center special unnumbered headings that must be centered (already centered if unnumbered)
if upper in SPECIAL_CENTER or upper.startswith("ПРИЛОЖЕНИЕ"):
# 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
# СОДЕРЖАНИЕ / СПИСОК — center even if somehow numbered
if upper in ("СОДЕРЖАНИЕ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"):
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 == "СОДЕРЖАНИЕ":
@@ -159,69 +218,113 @@ class Renderer:
# 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
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)
# 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)
infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state)
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:
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)
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:
self.render(info.renderable)
else:
self._add(info.docx_element, info.height)
self.previous_rendered = info
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 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)
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_
self.render(renderable)
def _add(self, element: Parented, height: Length):
self._document._body._element.append(
element._element
)
# 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: