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

302 lines
11 KiB
Python

from copy import copy
from typing import Generator
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.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
_bookmark_seq = 0
def _next_bookmark_id() -> int:
global _bookmark_seq
_bookmark_seq += 1
return _bookmark_seq
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 InternalLink:
"""Hyperlink to a bookmark in the same document (w:anchor)."""
def __init__(self, anchor: str, docx_paragraph: DocxParagraph):
self._docx_paragraph = docx_paragraph
self._hyperlink = create_element("w:hyperlink", {"w:anchor": anchor})
def add_run(
self,
text: str,
is_bold: bool = None,
is_italic: bool = None,
color: RGBColor = None,
strike_through: bool = None,
):
docx_run = DocxRun(create_element("w:r"), self._docx_paragraph)
self._hyperlink.append(docx_run._element)
docx_run.text = text
try:
docx_run.style = "Hyperlink"
except KeyError:
pass
docx_run.bold = is_bold
docx_run.italic = is_italic
if color is not None:
docx_run.font.color.rgb = color
docx_run.font.strike = strike_through
@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_run_with_citations(
self,
text: str,
is_bold: bool = None,
is_italic: bool = None,
color: RGBColor = None,
strike_through: bool = None,
):
"""Split body text so [1] / [2, 3] become internal links to bibliography bookmarks."""
import re
from ..bibliography import CITE_RE
kw = dict(
is_bold=is_bold, is_italic=is_italic, color=color, strike_through=strike_through,
)
pos = 0
for m in CITE_RE.finditer(text):
if m.start() > pos:
self.add_run(text[pos:m.start()], **kw)
full = m.group(0)
inner = m.group(1)
self.add_run("[", **kw)
for token in re.split(r"(\s*,\s*)", inner):
if re.fullmatch(r"\d+(?:\.\d+)?", (token or "").strip()):
self.add_biblio_link(token.strip(), token, **kw)
elif token:
self.add_run(token, **kw)
suffix = full[1 + len(inner):-1]
if suffix:
self.add_run(suffix, **kw)
self.add_run("]", **kw)
pos = m.end()
if pos < len(text):
self.add_run(text[pos:], **kw)
def add_biblio_link(
self,
key: str,
text: str | None = None,
is_bold: bool = None,
is_italic: bool = None,
color: RGBColor = None,
strike_through: bool = None,
):
from ..bibliography import biblio_bookmark_name
link = InternalLink(biblio_bookmark_name(key), self._docx_paragraph)
link.add_run(
text if text is not None else key,
is_bold=is_bold,
is_italic=is_italic,
color=color,
strike_through=strike_through,
)
self._docx_paragraph._p.append(link.element)
return link
def wrap_with_bookmark(self, name: str) -> None:
"""Surround current paragraph content with a Word bookmark."""
bid = str(_next_bookmark_id())
p = self._docx_paragraph._p
start = create_element("w:bookmarkStart", {"w:id": bid, "w:name": name})
end = create_element("w:bookmarkEnd", {"w:id": bid})
p.insert(0, start)
p.append(end)
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):
try:
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": "28"}),
create_element("w:szCs", {"w:val": "28"}),
]))
self._docx_paragraph._element.append(omml)
except Exception:
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:
# +landscape: section break handles the new page. Do NOT measure against
# portrait remaining (that queues add_to_new_page and leaves an empty page).
if getattr(image, "landscape", False):
yield SubRenderable(image, False)
continue
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:
# Defer this image and any remaining siblings to the next page
# (as SubRenderable — bare Image objects break the renderer).
yield SubRenderable(image, True)
for rest in images:
yield SubRenderable(rest, True)
return