302 lines
11 KiB
Python
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
|