@@ -0,0 +1,9 @@
|
||||
from .renderable import Renderable
|
||||
from .paragraph import Paragraph
|
||||
from .listing import Listing
|
||||
|
||||
__all__ = [
|
||||
"Renderable",
|
||||
"Paragraph",
|
||||
"Listing"
|
||||
]
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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 1–3 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))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 1–3, 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))
|
||||
Reference in New Issue
Block a user