BigUpdate
Python application / build (push) Has been cancelled

This commit is contained in:
Igor20264
2026-09-03 10:44:08 +03:00
parent d2da20fdb2
commit 516abe7b83
177 changed files with 40178 additions and 2 deletions
+9
View File
@@ -0,0 +1,9 @@
from .renderable import Renderable
from .paragraph import Paragraph
from .listing import Listing
__all__ = [
"Renderable",
"Paragraph",
"Listing"
]
+76
View File
@@ -0,0 +1,76 @@
from copy import copy
from dataclasses import dataclass
from typing import Generator
from docx.shared import Parented
from docx.text.paragraph import Paragraph as DocxParagraph
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
from md2gost.layout_tracker import LayoutState
from md2gost.renderable import Renderable
from md2gost.rendered_info import RenderedInfo
from .paragraph_sizer import ParagraphSizer
from ..util import create_element
# Map category → Word style name
CAPTION_STYLES = {
"Рисунок": "Caption Figure",
"Таблица": "Caption Table",
"Листинг": "Caption Listing",
}
@dataclass
class CaptionInfo:
unique_name: str | None
text: str | None
with_listing: bool = False
class Caption(Renderable):
def __init__(self, parent: Parented, category: str, caption_info: CaptionInfo | None,
number: str | None = None, before: bool = True):
self._parent = parent
self._before = before
self._category = category
self._docx_paragraph = DocxParagraph(create_element("w:p"), parent)
style_name = CAPTION_STYLES.get(category, "Caption")
try:
self._docx_paragraph.style = style_name
except KeyError:
self._docx_paragraph.style = "Caption"
# Format: «Рисунок 1.1 — Название» (or « - » if --emdash-to-hyphen)
from ..profiles import dash_separator
self._docx_paragraph.add_run(f"{category} ")
self._numbering_run = self._docx_paragraph.add_run(str(number) if number else "?")
if caption_info and caption_info.text:
text = caption_info.text.strip().rstrip(".")
if text:
self._docx_paragraph.add_run(f"{dash_separator()}{text}")
def center(self):
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
"RenderedInfo | Renderable", None, None]:
height_data = ParagraphSizer(
self._docx_paragraph,
previous_rendered.docx_element
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
layout_state.max_width
).calculate_height()
if self._before and ((height_data.lines + 2 - 1) * height_data.line_spacing + 1) * height_data.line_height \
> layout_state.remaining_page_height:
self._docx_paragraph.paragraph_format.page_break_before = True
height_data = ParagraphSizer(
self._docx_paragraph,
None,
layout_state.max_width
).calculate_height()
yield RenderedInfo(self._docx_paragraph, height_data.full + (layout_state.remaining_page_height
if self._docx_paragraph.paragraph_format.page_break_before else 0))
+68
View File
@@ -0,0 +1,68 @@
from dataclasses import dataclass
from typing import Generator
from docx.shared import Parented
from .caption import CaptionInfo
from .image import Image
from .listing import Listing
from .renderable import Renderable
from .requires_numbering import RequiresNumbering
from ..diagram_renderer import render_diagram
from ..layout_tracker import LayoutState
from ..rendered_info import RenderedInfo
class DiagramFigure(Renderable, RequiresNumbering):
"""UML/BPMN/C4 fence → PNG figure (+ optional source listing)."""
def __init__(
self,
parent: Parented,
lang: str,
source: str,
caption_info: CaptionInfo | None = None,
with_listing: bool = False,
):
super().__init__("Рисунок")
self._parent = parent
self._lang = lang
self._source = source
self._caption_info = caption_info
self._with_listing = with_listing or (
caption_info.with_listing if caption_info else False
)
self._number = None
self._listing_number = None
if caption_info and caption_info.unique_name:
self.unique_name = caption_info.unique_name
self.listing: Listing | None = None
if self._with_listing:
listing_caption = CaptionInfo(
self.unique_name, # same id → @Листинг:id
caption_info.text if caption_info else None,
)
self.listing = Listing(parent, lang, listing_caption)
self.listing.set_text(source)
def set_number(self, number: str):
self._number = number
def set_listing_number(self, number: str):
self._listing_number = number
if self.listing:
self.listing.set_number(number)
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) \
-> Generator[RenderedInfo, None, None]:
png_path = render_diagram(self._lang, self._source)
image = Image(self._parent, png_path, self._caption_info)
if self._number is not None:
image.set_number(self._number)
yield from image.render(previous_rendered, layout_state)
if self.listing is not None:
if self._listing_number is not None:
self.listing.set_number(self._listing_number)
yield from self.listing.render(None, layout_state)
+91
View File
@@ -0,0 +1,91 @@
from typing import Generator
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
from docx.oxml import CT_Tbl
from docx.shared import Pt, Twips
from docx.table import Table
from docx.text.paragraph import Paragraph as DocxParagraph
from .requires_numbering import RequiresNumbering
from ..layout_tracker import LayoutState
from ..renderable import Renderable
from ..rendered_info import RenderedInfo
from ..util import create_element
from ..latex_math import latex_to_omml
_HEIGHT = Pt(50)
class Equation(Renderable, RequiresNumbering):
def __init__(self, parent, latex_formula: str, unique_name: str | None = None,
numbered: bool = False):
super().__init__("Формула")
self.unique_name = unique_name
self._numbered = numbered
word_math = latex_to_omml(latex_formula)
sect = parent.part.document.sections[0]
left_margin = Twips(int(
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
right_margin = Twips(int(
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:right")[0].attrib[
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
table_width = sect.page_width - sect.right_margin - sect.left_margin + left_margin + right_margin
self._parent = parent
self._table = table = Table(CT_Tbl.new_tbl(1, 2, table_width), parent)
left_cell = table.cell(0, 0)
right_cell = table.cell(0, 1)
right_cell.width = Pt(40)
left_cell.width = table_width - right_cell.width
table.rows[0].height = _HEIGHT
left_paragraph = left_cell.paragraphs[0]
left_paragraph.style = "Formula Content"
left_paragraph._p.append(word_math)
left_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
self._right_paragraph = right_cell.paragraphs[0]
self._right_paragraph.style = "Formula Numbering"
self._number_text = "?"
right_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
self._spacer_before = create_element("w:p")
self._spacer_after = create_element("w:p")
def enable_numbering(self):
self._numbered = True
@property
def needs_numbering(self) -> bool:
return self._numbered
def set_number(self, number: str):
self._number_text = str(number)
self._numbered = True
# Rebuild right cell content
p = self._right_paragraph._p
for child in list(p):
if child.tag.endswith("}r"):
p.remove(child)
p.append(create_element("w:r", "("))
p.append(create_element("w:r", self._number_text))
p.append(create_element("w:r", ")"))
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
"RenderedInfo | Renderable", None, None]:
spacer_h = Pt(21) # ~ one empty line at 14pt * 1.5
height = _HEIGHT + spacer_h * 2
if height > layout_state.remaining_page_height:
height += layout_state.remaining_page_height
yield RenderedInfo(DocxParagraph(self._spacer_before, self._parent), spacer_h)
yield RenderedInfo(self._table, _HEIGHT)
yield RenderedInfo(DocxParagraph(self._spacer_after, self._parent), spacer_h)
+43
View File
@@ -0,0 +1,43 @@
from sys import platform, exit
import subprocess
import logging
from functools import cache
def __find_font_linux(name: str, bold: bool, italic: bool):
result = subprocess.run(
"fc-list", shell=True, check=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
if result.returncode == 0:
fonts = \
[line.split(":") for line in result.stdout.strip().split("\n")]
fonts = [font for font in fonts if len(font) == 3]
else:
logging.log(logging.ERROR, "fc-list not found")
exit(1)
for path, names, styles in fonts:
if (name in names
and ("Bold" in styles) == bool(bold)
and ("Italic" in styles) == bool(italic)):
return path
raise ValueError(f"Font {name} not found")
@cache
def find_font(name: str, bold: bool, italic: bool):
if not name:
raise ValueError("Invalid font")
if platform == "linux":
return __find_font_linux(name, bold, italic)
else:
from matplotlib.font_manager import findfont, FontProperties
return findfont(FontProperties(
family=name,
weight="bold" if bold else "normal",
style="italic" if italic else "normal"), fallback_to_default=False)
if __name__ == "__main__":
print(find_font("Courier New", False, False))
+162
View File
@@ -0,0 +1,162 @@
from copy import copy
from typing import Generator
import re
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
from docx.text.paragraph import Paragraph as DocxParagraph
from docx.shared import Parented, Length
from .paragraph_sizer import ParagraphSizer
from ..layout_tracker import LayoutState
from .paragraph import Paragraph
from ..rendered_info import RenderedInfo
from ..sub_renderable import SubRenderable
from ..util import create_element
# Leading section numbers written in markdown: "1 ", "1.1 ", "1.2.3. "
_LEADING_NUMBER_RE = re.compile(r"^\d+(?:\.\d+)*\.?\s+")
class Heading(Paragraph):
def __init__(self, parent: Parented, level: int, numbered: bool,
numbering_mode: str = "manual"):
super().__init__(parent)
self._numbered = numbered
self._numbering_mode = numbering_mode # auto | manual
self._parent = parent
self._level = level
if not 1 <= level <= 9:
raise ValueError("Heading level must be in range from 1 to 9")
self.style = f"Heading {level}"
if not numbered:
self._remove_numbering()
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
elif numbering_mode == "manual":
# Digits already in markdown text — kill Word list numbering to avoid "1 1 …"
self._remove_numbering()
self._rendered_page = 0
self._title_stripped = False
@property
def is_numbered(self) -> bool:
return self._numbered
@property
def numbering_mode(self) -> str:
return self._numbering_mode
@numbering_mode.setter
def numbering_mode(self, value: str):
self._numbering_mode = value
if self._numbered and value == "manual":
self._remove_numbering()
@property
def rendered_page(self) -> int:
return self._rendered_page
@property
def level(self) -> int:
return self._level
@property
def text(self) -> str:
return self._docx_paragraph.text
def apply_numbering_mode(self, mode: str):
"""Apply auto/manual after runs are filled."""
self._numbering_mode = mode
if not self._numbered:
return
if mode == "manual":
self._remove_numbering()
elif mode == "auto":
self._strip_leading_number_from_runs()
def _strip_leading_number_from_runs(self):
"""Remove '1 ', '1.1 ' etc. from text so Word list provides the number."""
if self._title_stripped or not self._numbered:
return
full = self._docx_paragraph.text or ""
m = _LEADING_NUMBER_RE.match(full)
if not m:
return
# Rewrite runs: put remaining text into first run, clear the rest
remaining = full[m.end():]
runs = self._docx_paragraph.runs
if not runs:
return
runs[0].text = remaining
for r in runs[1:]:
r.text = ""
self._title_stripped = True
def _remove_numbering(self):
pPr = self._docx_paragraph._p.get_or_add_pPr()
# Remove existing numPr if any, then set numId=0
for child in list(pPr):
if child.tag.endswith("}numPr"):
pPr.remove(child)
pPr.append(
create_element("w:numPr", [
create_element("w:ilvl", {
"w:val": "0"
}),
create_element("w:numId", {
"w:val": "0"
})
])
)
def exclude_from_toc(self):
"""Keep Heading N look, but outline level = Body Text so Word TOC skips it."""
pPr = self._docx_paragraph._p.get_or_add_pPr()
for child in list(pPr):
if child.tag.endswith("}outlineLvl"):
pPr.remove(child)
# 9 = body text (not outline levels 13 used by TOC \o "1-3")
pPr.append(create_element("w:outlineLvl", {"w:val": "9"}))
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
-> Generator[RenderedInfo | SubRenderable, None, None]:
remaining_height = layout_state.remaining_page_height
if self._level == 1 and layout_state.page != 1 and\
not (isinstance(previous_rendered.docx_element, DocxParagraph)
and previous_rendered.docx_element.text == "\n"):
self.page_break_before = True
height_data = ParagraphSizer(
self._docx_paragraph,
previous_rendered.docx_element
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
layout_state.max_width).calculate_height()
if layout_state.current_page_height == 0 and layout_state.page != 1:
height_data.before = 0
# if a heading + 3 lines don't fit to the page, they go to the next page
if ((height_data.lines + 3 - 1) * height_data.line_spacing + 1) * height_data.line_height\
> layout_state.remaining_page_height:
self._docx_paragraph.paragraph_format.space_before = 0 # libreoffice fix
height = height_data.full - height_data.before
# force this behaviour as there could be a table or an image instead of text
self.page_break_before = True
else:
height = height_data.full
if self.page_break_before:
height += remaining_height
layout_state.add_height(height)
self._rendered_page = layout_state.page
yield RenderedInfo(self._docx_paragraph, Length(height))
+88
View File
@@ -0,0 +1,88 @@
import logging
from copy import copy
from io import BytesIO
from typing import Generator
from os import environ
import os.path
import requests
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
from docx.shared import Parented, Length
from docx.text.paragraph import Paragraph
from .caption import Caption, CaptionInfo
from .renderable import Renderable
from .requires_numbering import RequiresNumbering
from ..layout_tracker import LayoutState
from ..rendered_info import RenderedInfo
from ..sub_renderable import SubRenderable
from ..util import create_element
class Image(Renderable, RequiresNumbering):
def __init__(self, parent: Parented, path: str, caption_info: CaptionInfo | None = None):
super().__init__("Рисунок")
self._parent = parent
self._caption_info = caption_info
self._docx_paragraph = Paragraph(create_element("w:p"), parent)
self._docx_paragraph.paragraph_format.space_before = 0
self._docx_paragraph.paragraph_format.space_after = 0
self._docx_paragraph.paragraph_format.first_line_indent = 0
self._docx_paragraph.paragraph_format.line_spacing = 1
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
self._invalid = False
run = self._docx_paragraph.add_run()
if path.startswith("http"):
bytesio = BytesIO()
bytesio.write(requests.get(path).content)
self._image = run.add_picture(bytesio)
else:
try:
path = os.path.expanduser(path)
if not os.path.isabs(path):
path = os.path.join(environ.get("WORKING_DIR", "."), path)
self._image = run.add_picture(path)
except FileNotFoundError:
logging.warning(f"Invalid image path: {path}, skipping...")
self._invalid = True
self._number = None
if caption_info and caption_info.unique_name:
self.unique_name = caption_info.unique_name
def set_number(self, number: str):
self._number = number
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
-> Generator[RenderedInfo | SubRenderable, None, None]:
if self._invalid:
yield from []
return
# limit width
if self._image.width > layout_state.max_width:
height_by_width = self._image.height / self._image.width
self._image.width = layout_state.max_width
self._image.height = Length(self._image.width * height_by_width)
# limit height
if self._image.height > layout_state.max_height:
width_by_height = self._image.width / self._image.height
self._image.height = layout_state.max_height
self._image.width = Length(self._image.height * width_by_height)
height = self._image.height
if layout_state.remaining_page_height < height:
height += layout_state.remaining_page_height
yield (rendered_image := RenderedInfo(self._docx_paragraph, Length(height)))
layout_state.add_height(rendered_image.height)
caption = Caption(self._parent, "Рисунок", self._caption_info, self._number, False)
caption.center()
yield from caption.render(rendered_image, copy(layout_state))
+92
View File
@@ -0,0 +1,92 @@
from copy import copy
from typing import Generator
from docx.shared import Pt, Cm, Twips
from . import Paragraph
from .renderable import Renderable
from ..layout_tracker import LayoutState
from ..rendered_info import RenderedInfo
# ТЗ табл. 2.2: маркер 1,25 см; текст / табуляция 2,25 см
MARKER_POS = Cm(1.25)
TEXT_POS = Cm(2.25)
BULLET = "" # en dash, единый маркер на весь документ
class List(Renderable):
def __init__(self, parent, ordered: bool):
self._parent = parent
self._ordered = ordered
self._paragraphs: list[Paragraph] = []
self._last_paragraph_space_after = 0
self._numbering = [0 for _ in range(10)]
self._item_count = 0
def add_item(self, level: int) -> Paragraph:
self._numbering[level - 1] += 1
for i in range(level, len(self._numbering)):
self._numbering[i] = 0
self._item_count += 1
paragraph = Paragraph(self._parent)
if self._ordered:
marker = f"{self._numbering[level - 1]}."
else:
marker = BULLET
paragraph.add_run(marker + "\t")
# hanging indent: left = TEXT_POS (+ nesting), first_line = MARKER_POS - TEXT_POS
nest = TEXT_POS * (level - 1)
paragraph._docx_paragraph.paragraph_format.tab_stops.add_tab_stop(TEXT_POS + nest)
paragraph._docx_paragraph.paragraph_format.left_indent = TEXT_POS + nest
paragraph._docx_paragraph.paragraph_format.first_line_indent = MARKER_POS - TEXT_POS
self._last_paragraph_space_after = paragraph._docx_paragraph.paragraph_format.space_after
paragraph._docx_paragraph.paragraph_format.space_before = 0
paragraph._docx_paragraph.paragraph_format.space_after = 0
self._paragraphs.append(paragraph)
return paragraph
def finalize_punctuation(self) -> None:
"""Apply TZ list punctuation: bullet → lowercase + ';' (last '.'); numbered → Capital + '.'."""
for i, paragraph in enumerate(self._paragraphs):
runs = paragraph._docx_paragraph.runs
if len(runs) < 2:
continue
# Text after marker run
text_runs = runs[1:]
full = "".join(r.text or "" for r in text_runs).strip()
if not full:
continue
is_last = i == len(self._paragraphs) - 1
if self._ordered:
fixed = full[0].upper() + full[1:] if full else full
if not fixed.endswith("."):
fixed = fixed.rstrip(";,. ") + "."
else:
fixed = full[0].lower() + full[1:] if full else full
if is_last:
fixed = fixed.rstrip(";,. ") + "."
else:
fixed = fixed.rstrip(";,. ") + ";"
# Put all text into first content run, clear others
text_runs[0].text = fixed
for r in text_runs[1:]:
r.text = ""
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
RenderedInfo | Renderable, None, None]:
self.finalize_punctuation()
if self._paragraphs:
self._paragraphs[-1]._docx_paragraph.paragraph_format.space_after = self._last_paragraph_space_after
for paragraph in self._paragraphs:
for x in paragraph.render(previous_rendered, copy(layout_state)):
layout_state.add_height(x.height)
previous_rendered = x
yield x
+156
View File
@@ -0,0 +1,156 @@
from copy import copy
import os
from typing import Generator, Callable
from docx.oxml import CT_Tbl
from docx.shared import Length, Pt, RGBColor, Twips
from docx.table import Table
from pygments import highlight
from pygments.formatter import Formatter
from pygments.lexers import get_lexer_by_name
from .caption import Caption, CaptionInfo
from .paragraph import Paragraph
from .renderable import Renderable
from .requires_numbering import RequiresNumbering
from ..docx_elements import create_table, _twips
from ..layout_tracker import LayoutState
from ..rendered_info import RenderedInfo
from ..sub_renderable import SubRenderable
class DocxParagraphPygmentsFormatter(Formatter):
def __init__(self, paragraphs: list[Paragraph], creator: Callable[[], Paragraph], **options):
Formatter.__init__(self, style="sas", **options)
self._creator = creator
self._paragraphs = paragraphs
self._styles = {}
for token, style in self.style:
self._styles[token] = style
def _add_run_to_last_paragraph(self, text, style):
self._paragraphs[-1].add_run(text, style["bold"] or None, style["italic"] in style or None,
RGBColor.from_string(style['color']) if style['color'] else None)
def format(self, tokensource, outfile):
self._paragraphs.append(self._creator())
for ttype, value in tokensource:
style = self._styles[ttype]
lines = iter(value.split("\n"))
self._add_run_to_last_paragraph(next(lines), style)
for line in lines:
self._paragraphs.append(self._creator())
self._add_run_to_last_paragraph(line, style)
self._paragraphs.pop(-1) # remove last empty line
LISTING_OFFSET = Pt(31) - Twips(108 * 2) # todo: fix
class Listing(Renderable, RequiresNumbering):
def __init__(self, parent, language: str, caption_info: CaptionInfo):
super().__init__("Листинг")
self._caption_info = caption_info
self._language = language
self._parent = parent
self.paragraphs: list[Paragraph] = []
self._number = None
if caption_info and caption_info.unique_name:
self.unique_name = caption_info.unique_name
def _create_table(self, parent, width: Length):
# todo: style inheritance
left_margin = Twips(int(
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
right_margin = Twips(int(
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:right")[0].attrib[
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
return create_table(
parent, 1, 1,
Twips(_twips(width) + _twips(left_margin) + _twips(right_margin)),
)
def set_text(self, text: str):
def create_paragraph() -> Paragraph:
paragraph = Paragraph(self._parent)
paragraph.style = "Code"
return paragraph
text = text.removesuffix("\n")
if self._language and "SYNTAX_HIGHLIGHTING" in os.environ and os.environ["SYNTAX_HIGHLIGHTING"] == "1":
formatter = DocxParagraphPygmentsFormatter(self.paragraphs, lambda: create_paragraph())
highlight(text, get_lexer_by_name(self._language), formatter)
else:
for line in text.removesuffix("\n").split("\n"):
paragraph = create_paragraph()
paragraph.add_run(line)
self.paragraphs.append(paragraph)
def set_number(self, number: str):
self._number = number
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
-> Generator[RenderedInfo | SubRenderable, None, None]:
caption_rendered_infos = list(
Caption(self._parent, "Листинг", self._caption_info, self._number, True)
.render(previous_rendered, copy(layout_state))
)
layout_state.add_height(sum([info.height for info in caption_rendered_infos]))
yield from caption_rendered_infos
table = self._create_table(self._parent, layout_state.max_width)
previous = None
table_height = Pt(1) # table borders, 4 eights of point for each border
# if first line doesn't fit move listing to the next page
paragraph_layout_state = copy(layout_state)
paragraph_layout_state.max_width -= LISTING_OFFSET
paragraph_rendered_info = next(self.paragraphs[0].render(previous, paragraph_layout_state))
if paragraph_rendered_info.height + table_height > layout_state.remaining_page_height:
table_height += layout_state.remaining_page_height
layout_state.add_height(layout_state.remaining_page_height)
for paragraph in self.paragraphs:
paragraph_layout_state = copy(layout_state)
paragraph_layout_state.max_width -= LISTING_OFFSET
paragraph_rendered_info = next(paragraph.render(previous, paragraph_layout_state))
if paragraph_rendered_info.height > layout_state.remaining_page_height: # todo add before after
table_rendered_info = RenderedInfo(table, table_height)
yield table_rendered_info
table_height = Pt(1) # table borders, 4 eights of point for each border
continuation_paragraph = Paragraph(self._parent)
continuation_paragraph.add_run(f"Продолжение Листинга {self._number}")
continuation_paragraph.style = "Caption Listing"
continuation_paragraph.first_line_indent = 0
continuation_paragraph.page_break_before = True
continuation_rendered_info = next(
continuation_paragraph.render(None, copy(layout_state)))
layout_state.add_height(continuation_rendered_info.height)
yield continuation_rendered_info
table = self._create_table(self._parent, layout_state.max_width)
previous = None
paragraph_layout_state = copy(layout_state)
paragraph_layout_state.max_width -= LISTING_OFFSET
paragraph_rendered_info = next(paragraph.render(previous, paragraph_layout_state))
table._cells[0]._element.append(paragraph_rendered_info.docx_element._element)
layout_state.add_height(paragraph_rendered_info.height)
table_height += paragraph_rendered_info.height
previous = paragraph_rendered_info
yield RenderedInfo(table, table_height)
+30
View File
@@ -0,0 +1,30 @@
from typing import Generator
from docx.shared import Parented, Pt
from docx.text.paragraph import Paragraph as DocxParagraph
from .paragraph_sizer import ParagraphSizer
from ..layout_tracker import LayoutState
from ..rendered_info import RenderedInfo
from ..sub_renderable import SubRenderable
from ..util import create_element
from .renderable import Renderable
class PageBreak(Renderable):
def __init__(self, parent: Parented):
self._docx_paragraph = DocxParagraph(create_element("w:p", [
create_element("w:r", [
create_element("w:br", {"w:type": "page"})
])
]), parent)
self._docx_paragraph.runs[0].font.size = Pt(1)
self._docx_paragraph.paragraph_format.space_before = 0
self._docx_paragraph.paragraph_format.space_after = 0
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
-> Generator[RenderedInfo | SubRenderable, None, None]:
yield RenderedInfo(
self._docx_paragraph,
max(layout_state.remaining_page_height, ParagraphSizer(self._docx_paragraph, None, layout_state.max_width).calculate_height().line_height)
)
+184
View File
@@ -0,0 +1,184 @@
from copy import copy
from typing import Generator, Any
from docx.shared import Length, Parented, RGBColor
from docx.text.paragraph import Paragraph as DocxParagraph
from docx.text.paragraph import Run as DocxRun
from docx.enum.text import WD_LINE_SPACING
from docx.opc.constants import RELATIONSHIP_TYPE
from . import Renderable
from .caption import CaptionInfo
from .image import Image
from .paragraph_sizer import ParagraphSizer
from ..layout_tracker import LayoutState
from ..sub_renderable import SubRenderable
from ..util import create_element
from ..rendered_info import RenderedInfo
from ..latex_math import latex_to_omml, inline_omml
class Link:
def __init__(self, url, docx_paragraph: DocxParagraph):
self._docx_paragraph = docx_paragraph
r_id = docx_paragraph.part.relate_to(url, RELATIONSHIP_TYPE.HYPERLINK, is_external=True)
self._hyperlink = create_element("w:hyperlink", {
"r:id": r_id
})
def add_run(self, text: str, is_bold: bool = None, is_italic: bool = None, color: RGBColor = None,
strike_through: bool = None):
parts = text.split("-")
for i, part in enumerate(parts):
docx_run = DocxRun(create_element("w:r"), self._docx_paragraph)
self._hyperlink.append(docx_run._element)
docx_run.text = text
docx_run.style = "Hyperlink"
docx_run.bold = is_bold
docx_run.italic = is_italic
docx_run.font.color.rgb = color
docx_run.font.strike = strike_through
if i != len(parts) - 1:
self._hyperlink.append(create_element("w:r", [create_element("w:noBreakHyphen")]))
@property
def element(self):
return self._hyperlink
class Paragraph(Renderable):
def __init__(self, parent: Parented):
self._parent = parent
self._docx_paragraph = DocxParagraph(create_element("w:p"), parent)
self._docx_paragraph.style = "Normal"
self._images: list[Image] = []
self._pending_refs: list[tuple[object, str, str]] = [] # (run, type, name)
def add_run(self, text: str, is_bold: bool = None, is_italic: bool = None, color: RGBColor = None,
strike_through: bool = None):
# replace all hyphens with non-breaking hyphens
parts = text.split("-")
for i, part in enumerate(parts):
docx_run = self._docx_paragraph.add_run(part)
docx_run.bold = is_bold
docx_run.italic = is_italic
docx_run.font.color.rgb = color
docx_run.font.strike = strike_through
if i != len(parts)-1:
self._docx_paragraph.add_run()._element.\
append(create_element("w:noBreakHyphen"))
def add_reference(self, type_: str, name: str):
"""Placeholder run resolved later via resolve_pending_refs()."""
run = self._docx_paragraph.add_run(f"{type_}?")
self._pending_refs.append((run, type_, name))
def resolve_pending_refs(self, resolve_fn):
for run, type_, name in self._pending_refs:
run.text = resolve_fn(type_, name)
self._pending_refs.clear()
def add_image(self, path: str, caption_info: CaptionInfo):
self._images.append(Image(self._parent, path, caption_info))
def add_link(self, url: str):
link = Link(url, self._docx_paragraph)
self._docx_paragraph._p.append(link.element)
return link
def add_inline_equation(self, formula: str):
# omml = inline_omml(latex_to_omml(formula))
# for r in omml.xpath("//m:r", namespaces=omml.nsmap):
# r.append(create_element("w:rPr", [
# create_element("w:sz", {"w:val": "24"}),
# create_element("w:szCs", {"w:val": "24"}),
# ]))
# self._docx_paragraph._element.append(omml)
self.add_run(formula, is_italic=True)
@property
def page_break_before(self) -> bool:
return self._docx_paragraph.paragraph_format.page_break_before
@page_break_before.setter
def page_break_before(self, value: bool):
self._docx_paragraph.paragraph_format.page_break_before = value
@property
def style(self):
return self._docx_paragraph.style
@style.setter
def style(self, value: str):
self._docx_paragraph.style = value
@property
def first_line_indent(self):
return self._docx_paragraph.paragraph_format.first_line_indent
@first_line_indent.setter
def first_line_indent(self, value: Length):
self._docx_paragraph.paragraph_format.first_line_indent = value
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
-> Generator[RenderedInfo | SubRenderable, None, None]:
remaining_space = layout_state.remaining_page_height
if self.page_break_before:
layout_state.add_height(layout_state.remaining_page_height)
if self._docx_paragraph.text or not self._images:
height_data = ParagraphSizer(
self._docx_paragraph,
previous_rendered.docx_element
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
layout_state.max_width).calculate_height()
if layout_state.current_page_height == 0 and layout_state.page > 1:
height_data.before = 0
fitting_lines = 0
for lines in range(1, height_data.lines+1):
if height_data.before + ((lines - 1) * height_data.line_spacing + 1) * height_data.line_height \
> layout_state.remaining_page_height:
break
fitting_lines += 1
if fitting_lines == height_data.lines:
# the whole paragraph fits page
height = min(height_data.full, layout_state.remaining_page_height)
elif fitting_lines <= 1 or (height_data.lines-fitting_lines == 1 and height_data.lines == 3):
# if only no or only one line fits the page, paragraph goes to the next page
height = layout_state.remaining_page_height + height_data.full
elif height_data.lines-fitting_lines == 1:
# if all lines except last fit the page, the last two lines go to the new page
height = layout_state.remaining_page_height + \
height_data.before + height_data.line_height * height_data.line_spacing * 2 \
+ height_data.after
else:
height = layout_state.remaining_page_height + \
height_data.before + height_data.line_height * height_data.line_spacing * \
(height_data.lines-fitting_lines) + height_data.after
if self.page_break_before:
height += remaining_space
yield (previous_rendered := RenderedInfo(self._docx_paragraph, Length(height)))
layout_state.add_height(height)
images = iter(self._images)
for image in images:
rendered_image = list(image.render(previous_rendered, copy(layout_state)))
rendered_image_height = sum([x.height for x in rendered_image])
if rendered_image:
previous_rendered = rendered_image[-1]
if rendered_image_height <= layout_state.remaining_page_height:
yield SubRenderable(image, False)
layout_state.add_height(rendered_image_height)
else:
yield SubRenderable(image, True)
break
yield from images
+267
View File
@@ -0,0 +1,267 @@
import logging
import os
from dataclasses import dataclass
from functools import cached_property
from math import ceil
from docx.enum.text import WD_LINE_SPACING
from docx.oxml import CT_R
from docx.text.run import Run
from freetype import Face
from docx.text.paragraph import Paragraph
from docx.text.font import Font as DocxFont
from docx.shared import Length, Pt, Inches
from docx.text.parfmt import ParagraphFormat
from docx.styles.style import _ParagraphStyle
from PIL import Image, ImageDraw, ImageFont
from .find_font import find_font
def _merge_objects(*objects):
from inspect import ismethod
"""
Returns the new object containing attributes from objects, where the latest
one has the highest priority.
"""
class MergedObject:
pass
merged_object = MergedObject()
def safe_attrs(obj):
names = set(dir(obj))
for name in names:
if name.startswith("_"):
continue
try:
value = getattr(obj, name)
except (AttributeError, ValueError, TypeError):
continue
if ismethod(value) or callable(value):
continue
yield name, value
for name, value in safe_attrs(objects[0]):
merged_object.__setattr__(name, value)
for object_ in objects[1:]:
for name, value in safe_attrs(object_):
if value is not None:
merged_object.__setattr__(name, value)
return merged_object
class Font:
def __init__(self, name: str, bold: bool, italic: bool, size_pt: int):
path = find_font(name, bold, italic)
self._freetypefont = ImageFont.truetype(path, size_pt)
self._draw = ImageDraw.Draw(Image.new("RGB", (1000, 1000)))
self._face = Face(path)
self._face.set_char_size(int(size_pt * 64))
def get_text_width(self, text: str) -> Length:
if not self.is_mono:
bbox = self._draw.textbbox((0, 0), text, self._freetypefont)
return Pt(bbox[2] - bbox[0])
else:
return Pt(len(text) * self._face.glyph.advance.x / 64)
def get_line_height(self) -> Length:
# TODO: make it work for all fonts
if "Times" in str(self._face.family_name) and self._freetypefont.size == 14:
return Pt(16.05)
if "Courier" in str(self._face.family_name) and self._freetypefont.size == 12:
return Pt(13.61)
else:
return Pt(self._face.size.height / 64)
@cached_property
def is_mono(self):
self._face.load_char("i")
i_width = self._face.glyph.advance.x
self._face.load_char("m")
return i_width == self._face.glyph.advance.x
# return self._face.glyph.bitmap.width
@dataclass
class ParagraphSizerResult:
before: Length
lines: int
line_height: Length
line_spacing: float
after: Length
@property
def base(self) -> Length:
return self.before + ((self.lines - 1) * self.line_spacing + 1) * self.line_height
@property
def full(self) -> Length:
return Length(self.before + self.line_height * self.line_spacing * self.lines + self.after)
class ParagraphSizer:
def __init__(self, paragraph: Paragraph, previous_paragraph: Paragraph | None, max_width: Length):
self.previous_paragraph = previous_paragraph
self.max_width = max_width
self.paragraph = paragraph
self.same_style_as_previous = (paragraph.style == previous_paragraph.style) if previous_paragraph else False
@cached_property
def _default_style(self):
default_style_element = type("DefaultStyle", (), {})
default_style_element.rPr = \
self.paragraph.part.document.styles.element.xpath(
'w:docDefaults/w:rPrDefault/w:rPr')[0]
default_style_element.pPr = \
self.paragraph.part.document.styles.element.xpath(
'w:docDefaults/w:pPrDefault/w:pPr')[0]
default_style = _ParagraphStyle(default_style_element)
return default_style
@cached_property
def _styles(self) -> list[_ParagraphStyle]:
styles = [self.paragraph.style]
while styles[-1].base_style:
styles.append(styles[-1].base_style)
styles.append(self._default_style)
return styles
@cached_property
def _is_contextual_spacing(self) -> bool:
contextual_spacing = False
pPrs = [self.paragraph.paragraph_format._element.pPr] + \
[style._element.pPr for style in self._styles]
for pPr in pPrs:
if pPr.xpath("./w:contextualSpacing"):
contextual_spacing = True
break
return contextual_spacing
def count_lines(self, runs: list[Run], max_width: Length, docx_font: DocxFont, first_line_indent: Length,
is_mono: bool = False):
lines = 1
line_width = first_line_indent
space_width = Font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt).get_text_width(" ")
if not is_mono:
space_width *= 0.81
word_part = ""
word_parts_widths = [0]
spaces = 0
for i, run in enumerate(runs):
if word_part:
word_part = ""
word_parts_widths.append(0)
run_docx_font = _merge_objects(
docx_font,
run.font
)
font = Font(run_docx_font.name, run_docx_font.bold, run_docx_font.italic, run_docx_font.size.pt)
run_text = run.text
if run_text == "" and run._element.xpath("w:noBreakHyphen"):
run_text = "-"
if i == len(runs) - 1:
run_text += " " # add space to the end of the last run, so it adds the last word
for c in run_text:
if c == " ":
if any(word_parts_widths):
width = spaces*space_width + sum(word_parts_widths)
if width <= max_width - line_width:
line_width += width
elif width > max_width - first_line_indent:
if lines == 1 and line_width == first_line_indent and not spaces:
lines += ceil((width - (max_width - first_line_indent)) / max_width)
line_width = (width - (max_width - first_line_indent)) % max_width
else:
lines += ceil(width / max_width)
line_width = width % max_width
else:
lines += 1
line_width = sum(word_parts_widths)
word_part = ""
word_parts_widths = [0]
spaces = 1
else:
spaces += 1
else:
word_part += c
word_parts_widths[-1] = font.get_text_width(word_part)
return int(lines)
def calculate_height(self) -> ParagraphSizerResult:
max_width = self.max_width
docx_font: DocxFont = _merge_objects(
*[style.font for style in self._styles[::-1] if style.font],
self.paragraph.style.font)
paragraph_format: ParagraphFormat = _merge_objects(
*[style.paragraph_format for style in self._styles[::-1]
if style.paragraph_format],
self.paragraph.paragraph_format
)
max_width -= (paragraph_format.left_indent or 0) + \
(paragraph_format.right_indent or 0)
font = Font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt)
# here self.paragraph.runs is not used because
# it does not always return all runs (e.g. if they are inside hyperlink)
runs = []
for element in self.paragraph._element.getiterator():
if isinstance(element, CT_R):
runs.append(Run(element, self.paragraph))
lines = self.count_lines(runs, max_width, docx_font, paragraph_format.first_line_indent or 0,
font.is_mono)
previous_paragraph_format: ParagraphFormat = None
if self.previous_paragraph:
previous_paragraph_styles = [self.previous_paragraph.style]
while previous_paragraph_styles[-1].base_style:
previous_paragraph_styles.append(
previous_paragraph_styles[-1].base_style
)
previous_paragraph_styles.append(self._default_style)
previous_paragraph_format = _merge_objects(
*[style.paragraph_format for style in previous_paragraph_styles[::-1]
if style.paragraph_format],
self.previous_paragraph.paragraph_format
)
if self._is_contextual_spacing and self.same_style_as_previous:
before = (previous_paragraph_format.space_after or 0)
else:
before = (paragraph_format.space_before or 0)
if previous_paragraph_format:
before = max(0, before - (previous_paragraph_format.space_after or 0))
after = (paragraph_format.space_after or 0)
line_height = font.get_line_height()
line_spacing = paragraph_format.line_spacing
if paragraph_format.line_spacing_rule == WD_LINE_SPACING.EXACTLY:
line_spacing /= line_height
# raise NotImplementedError("Line spacing rule AT_LEAST is not supported")
elif paragraph_format.line_spacing_rule == WD_LINE_SPACING.AT_LEAST:
raise NotImplementedError("Line spacing rule AT_LEAST is not supported")
return ParagraphSizerResult(before, lines, line_height, line_spacing, after)
+18
View File
@@ -0,0 +1,18 @@
from typing import TYPE_CHECKING
from collections.abc import Generator
from abc import ABC, abstractmethod
from ..layout_tracker import LayoutState
from ..rendered_info import RenderedInfo
if TYPE_CHECKING:
from ..sub_renderable import SubRenderable
class Renderable(ABC):
@abstractmethod
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
-> Generator["RenderedInfo | SubRenderable", None, None]:
"""Renders the object to one or multiple Parented objects or Renderables to be rendered on the next page"""
def added_to_document(self):
pass
+11
View File
@@ -0,0 +1,11 @@
from abc import ABC, abstractmethod
class RequiresNumbering(ABC):
def __init__(self, category: str):
self.numbering_category = category
self.unique_name: str | None = None
@abstractmethod
def set_number(self, number: str):
pass
+218
View File
@@ -0,0 +1,218 @@
from copy import copy
from typing import Generator
from docx.shared import Length, Parented, Pt, Twips
from . import Paragraph
from .caption import Caption, CaptionInfo
from .page_break import PageBreak
from .renderable import Renderable
from .requires_numbering import RequiresNumbering
from ..docx_elements import *
from ..docx_elements import _twips
from ..layout_tracker import LayoutState
from ..profiles import DEFAULT_TABLE_CONTINUATION, TABLE_CONTINUATION_MODES
from ..rendered_info import RenderedInfo
CELL_OFFSET = Pt(9) - Twips(108 * 2)
# Slack only for modes that fragment by our height estimate (legacy/caption).
ROW_HEIGHT_SLACK = Pt(4)
# Modes that do NOT cut the table into fragments — Word owns page breaks.
_WORD_PAGED_MODES = frozenset({"off", "soft"})
class Table(Renderable, RequiresNumbering):
def __init__(self, parent: Parented, rows: int, cols: int, caption_info: CaptionInfo):
super().__init__("Таблица")
self._parent = parent
self._caption_info = caption_info
self._cols = cols
self._continuation_mode = DEFAULT_TABLE_CONTINUATION
left_margin = Twips(int(parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib["{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
right_margin = Twips(int(parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:right")[0].attrib["{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
self._cell_margin_lr = left_margin + right_margin
self._number = "?"
if caption_info and caption_info.unique_name:
self.unique_name = caption_info.unique_name
self._rows: list[list[list[Paragraph]]] = [[[] for i in range(cols)] for j in range(rows)]
self._merge: list[list[tuple[str, str]]] = [
[("none", "none") for _ in range(cols)] for _ in range(rows)
]
def set_continuation_mode(self, mode: str) -> None:
if mode not in TABLE_CONTINUATION_MODES:
raise ValueError(
f"table continuation must be one of {TABLE_CONTINUATION_MODES}, got {mode!r}"
)
self._continuation_mode = mode
def _table_width(self, layout_state: LayoutState) -> Length:
return Twips(_twips(layout_state.max_width) + _twips(self._cell_margin_lr))
def add_paragraph_to_cell(self, row: int, col: int) -> Paragraph:
paragraph = Paragraph(self._parent)
try:
paragraph.style = "Table Text"
except KeyError:
pass
paragraph.first_line_indent = 0
paragraph._docx_paragraph.paragraph_format.space_before = 0
paragraph._docx_paragraph.paragraph_format.space_after = 0
paragraph._docx_paragraph.paragraph_format.line_spacing = 1
self._rows[row][col].append(paragraph)
return paragraph
def set_cell_merge(self, row: int, col: int, merge_v: str = "none", merge_h: str = "none") -> None:
self._merge[row][col] = (merge_v or "none", merge_h or "none")
def set_number(self, number: str):
self._number = number
def _col_width(self, layout_state: LayoutState) -> Length:
return self._table_width(layout_state) / self._cols
def _grid_span(self, merge_row: list[tuple[str, str]], col: int) -> int:
_, merge_h = merge_row[col]
if merge_h == "continue":
return 0
span = 1
while col + span < self._cols and merge_row[col + span][1] == "continue":
span += 1
return span
def _build_docx_row(self, docx_table, row_idx: int, apply_merge: bool, layout_state: LayoutState):
row = self._rows[row_idx]
merge_row = self._merge[row_idx]
docx_row = create_table_row(docx_table, header=(row_idx == 0))
row_height = 0
col_w = self._col_width(layout_state)
col = 0
while col < self._cols:
merge_v, merge_h = merge_row[col]
if apply_merge and merge_h == "continue":
col += 1
continue
span = self._grid_span(merge_row, col) if apply_merge else 1
if span < 1:
span = 1
docx_cell = create_table_cell(docx_row, col_w * span)
v_merge = merge_v if apply_merge and merge_v in ("restart", "continue") else None
grid_span = span if apply_merge and span > 1 else None
if v_merge or grid_span:
apply_cell_merge(docx_cell, v_merge=v_merge, grid_span=grid_span)
cell_height = 0
if not (apply_merge and merge_v == "continue"):
for paragraph in row[col]:
cell_layout_state = LayoutState(
layout_state.max_height, layout_state.max_width
)
cell_layout_state.max_width = col_w * span - CELL_OFFSET
for paragraph_rendered_info in paragraph.render(None, cell_layout_state):
docx_cell._element.append(paragraph_rendered_info.docx_element._element)
cell_height += paragraph_rendered_info.height
else:
from ..util import create_element
docx_cell._element.append(create_element("w:p"))
row_height = max(cell_height, row_height)
docx_row._element.append(docx_cell._element)
col += span
row_height += Pt(0.5) + (
ROW_HEIGHT_SLACK if self._continuation_mode not in _WORD_PAGED_MODES else Pt(0)
)
return docx_row, row_height
def _should_split_fragment(
self, row_height: Length, layout_state: LayoutState, rows_in_fragment: int
) -> bool:
# off/soft: never fragment — height estimate ≠ Word; mid-page «Продолжение» bug
if self._continuation_mode in _WORD_PAGED_MODES:
return False
if rows_in_fragment == 0:
return False
return row_height > layout_state.remaining_page_height
def _make_continuation_paragraph(self) -> Paragraph:
continuation_paragraph = Paragraph(self._parent)
continuation_paragraph.add_run(f"Продолжение Таблицы {self._number}")
continuation_paragraph.style = "Caption Table"
continuation_paragraph.first_line_indent = 0
return continuation_paragraph
def _emit_page_break_and_optional_caption(
self, layout_state: LayoutState
) -> Generator[RenderedInfo, None, None]:
"""For legacy/caption only: break page and insert «Продолжение Таблицы N»."""
mode = self._continuation_mode
if mode == "legacy":
continuation_paragraph = self._make_continuation_paragraph()
continuation_paragraph.page_break_before = True
info = next(continuation_paragraph.render(None, copy(layout_state)))
layout_state.add_height(info.height)
yield info
return
# caption
page_break_info = next(PageBreak(self._parent).render(None, layout_state))
layout_state.add_height(page_break_info.height)
yield page_break_info
continuation_paragraph = self._make_continuation_paragraph()
continuation_paragraph._docx_paragraph.paragraph_format.keep_with_next = True
info = next(continuation_paragraph.render(None, copy(layout_state)))
layout_state.add_height(info.height)
yield info
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) \
-> Generator[RenderedInfo, None, None]:
caption_rendered_infos = list(
Caption(self._parent, "Таблица", self._caption_info, self._number, True)
.render(previous_rendered, copy(layout_state))
)
layout_state.add_height(sum(info.height for info in caption_rendered_infos))
yield from caption_rendered_infos
docx_table = create_table(
self._parent, 0, self._cols, self._table_width(layout_state)
)
table_height = Pt(0.5)
apply_merge = True
rows_in_fragment = 0
for row_idx in range(len(self._rows)):
docx_row, row_height = self._build_docx_row(
docx_table, row_idx, apply_merge, layout_state
)
if self._should_split_fragment(row_height, layout_state, rows_in_fragment):
yield RenderedInfo(docx_table, table_height)
yield from self._emit_page_break_and_optional_caption(layout_state)
docx_table = create_table(
self._parent, 0, self._cols, self._table_width(layout_state)
)
apply_merge = False
rows_in_fragment = 0
table_height = Pt(0.5)
docx_row, row_height = self._build_docx_row(
docx_table, row_idx, apply_merge, layout_state
)
docx_table._element.append(docx_row._element)
layout_state.add_height(row_height)
table_height += row_height
rows_in_fragment += 1
yield RenderedInfo(docx_table, table_height)
+140
View File
@@ -0,0 +1,140 @@
"""Table of contents: manual (layout-based) or native Word TOC field."""
from copy import copy
from typing import Generator
from docx.enum.text import WD_TAB_LEADER, WD_TAB_ALIGNMENT, WD_PARAGRAPH_ALIGNMENT
from docx.shared import Parented
from . import Paragraph
from .page_break import PageBreak
from .renderable import Renderable
from ..layout_tracker import LayoutState
from ..rendered_info import RenderedInfo
from ..sub_renderable import SubRenderable
from ..util import create_element
SPECIAL_TITLES = {
"СОДЕРЖАНИЕ", "ВВЕДЕНИЕ", "ЗАКЛЮЧЕНИЕ",
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
"СПИСОК ИСПОЛЬЗУЕМЫХ ИСТОЧНИКОВ",
"ПРИЛОЖЕНИЕ", "ПРИЛОЖЕНИЯ",
}
# Word field: outline levels 13, hyperlinks, hide tab/page in web view
_NATIVE_TOC_INSTR = r'TOC \o "1-3" \h \z \u'
class ToC(Renderable):
def __init__(self, parent: Parented, heading_numbering: str = "manual",
toc_mode: str = "native"):
self._parent = parent
self._heading_numbering = heading_numbering # auto | manual
self._toc_mode = toc_mode # manual | native
self._paragraph = Paragraph(parent)
self._paragraph._docx_paragraph.paragraph_format.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
self._paragraph.first_line_indent = 0
self._items: list[tuple[int, str, int, bool]] = []
self._native_ready = False
def set_heading_numbering(self, mode: str):
self._heading_numbering = mode
def set_toc_mode(self, mode: str):
self._toc_mode = mode
@property
def toc_mode(self) -> str:
return self._toc_mode
def add_item(self, level: int, title: str, page: int, numbered: bool):
self._items.append((level, title, page, numbered))
def fill(self):
if self._toc_mode == "native":
self._fill_native()
else:
self._fill_manual()
def _fill_native(self):
"""Insert a Word TOC field. Pages appear after update in Word."""
if self._native_ready:
return
p = self._paragraph._docx_paragraph
# Clear any leftover runs
for child in list(p._p):
if child.tag.endswith("}r"):
p._p.remove(child)
def add_fld_char(fld_char_type: str, dirty: bool = False):
attrs = {"w:fldCharType": fld_char_type}
if dirty:
attrs["w:dirty"] = "true"
run = create_element("w:r", [
create_element("w:fldChar", attrs)
])
p._p.append(run)
def add_instr(text: str):
instr = create_element("w:instrText", {"xml:space": "preserve"}, f" {text} ")
run = create_element("w:r", [instr])
p._p.append(run)
def add_text(text: str):
run = create_element("w:r", [
create_element("w:t", {"xml:space": "preserve"}, text)
])
p._p.append(run)
add_fld_char("begin", dirty=True)
add_instr(_NATIVE_TOC_INSTR)
add_fld_char("separate")
add_text(
"Обновите содержание в Word: ПКМ по полю → Обновить поле → целиком."
)
add_fld_char("end")
self._native_ready = True
def _fill_manual(self):
p = self._paragraph._docx_paragraph
usable = (p.part.document.sections[0].page_width
- p.part.document.sections[0].left_margin
- p.part.document.sections[0].right_margin)
p.paragraph_format.tab_stops.add_tab_stop(
usable, alignment=WD_TAB_ALIGNMENT.RIGHT, leader=WD_TAB_LEADER.DOTS)
p.paragraph_format.tab_stops.add_tab_stop(
0, alignment=WD_TAB_ALIGNMENT.LEFT, leader=WD_TAB_LEADER.SPACES)
numbering = [0 for _ in range(10)]
for level, title, page, numbered in self._items:
numbering[level - 1] += 1
for i in range(level, len(numbering)):
numbering[i] = 0
if title.strip().upper() == "СОДЕРЖАНИЕ":
continue
display = title.strip()
if display.upper() in SPECIAL_TITLES or display.upper().startswith("ПРИЛОЖЕНИЕ"):
display = display.upper()
p.add_run(" " * (level - 1))
if numbered and self._heading_numbering == "auto":
num = ".".join(str(x) for x in numbering[:level])
p.add_run(f"{num} ")
run = p.add_run(display)
run.bold = False
p.add_run(f"\t{page}")
p.add_run("\n")
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
-> Generator[RenderedInfo | SubRenderable, None, None]:
# Native TOC: insert field shell early so it lands in the body before page break.
# Final instr is ensured again in fill() after render.
if self._toc_mode == "native" and not self._native_ready:
self._fill_native()
for rendered_info in self._paragraph.render(previous_rendered, copy(layout_state)):
yield RenderedInfo(rendered_info.docx_element, 0)
yield from PageBreak(self._parent).render(None, copy(layout_state))