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