import logging from copy import deepcopy from dataclasses import dataclass, replace from functools import cached_property, lru_cache from math import ceil from lxml import etree 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._advance / 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 _metrics(self) -> tuple[bool, int]: # The advance is snapshotted here instead of being read back from # self._face.glyph later: the face is shared between callers via # _get_font, and any other load_char would otherwise change the glyph # slot under us. self._face.load_char("i") i_width = self._face.glyph.advance.x self._face.load_char("m") m_width = self._face.glyph.advance.x return i_width == m_width, m_width @property def is_mono(self) -> bool: return self._metrics[0] @property def _advance(self) -> int: return self._metrics[1] @lru_cache(maxsize=256) def _get_font(name: str, bold: bool, italic: bool, size_pt: float) -> Font: """Fonts are immutable once built, so identical descriptors share one instance. Constructing a Font costs ~2.6 ms (FreeType face + PIL canvas) and calculate_height() would otherwise build 2 + len(runs) of them per call. """ return Font(name, bold, italic, size_pt) @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 = _get_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 = _get_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 == " ": # word_parts_widths[-1] is only ever read here and at the run # boundary below, so the prefix width is measured once per # word instead of once per character. if word_part: word_parts_widths[-1] = font.get_text_width(word_part) 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 # A word may span runs; the next iteration appends a fresh slot for # the continuation, so this run's fragment has to be measured now. if word_part: 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 = _get_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) def _is_blank_pPr(pPr) -> bool: """An empty w:pPr carries no formatting and is equivalent to none at all. Measuring a paragraph materialises one: paragraph_format reads go through get_or_add_pPr. Treating it as absent keeps a paragraph's key the same before and after its first measurement. """ return pPr is None or (not len(pPr) and not pPr.attrib) def _content_key(paragraph: Paragraph) -> bytes: """Everything calculate_height() reads from the measured paragraph.""" p = deepcopy(paragraph._p) if p.pPr is not None and _is_blank_pPr(p.pPr): p.remove(p.pPr) return etree.tostring(p) def _predecessor_key(paragraph: Paragraph) -> bytes: """Everything calculate_height() reads from the *preceding* paragraph. Only its style and its direct formatting matter (space_after feeds the collapsed space before the measured paragraph), so the runs are left out of the key. That also keeps the key stable once the predecessor has been appended to the body: attaching a w:p makes lxml move the drawing namespace declarations onto it, which would otherwise look like an edit. """ pPr = paragraph._p.pPr return b"" if _is_blank_pPr(pPr) else etree.tostring(deepcopy(pPr)) def measure_paragraph(cache: dict, paragraph: Paragraph, previous_paragraph: Paragraph | None, max_width: Length) -> ParagraphSizerResult: """calculate_height() memoised per owning renderable. The same paragraph is measured several times per document: a table row is peeked before it is built, a listing's first line is probed before the loop, a figure caption is sized once to see whether the figure fits and again when it is emitted. One measurement costs milliseconds, serialising the two paragraphs costs microseconds, so the XML itself is the key — an edit between two calls misses the cache instead of returning a stale height. The cache lives on the caller, so it never spans documents (style definitions are not part of the key). """ key = ( _content_key(paragraph), _predecessor_key(previous_paragraph) if previous_paragraph is not None else None, int(max_width), ) result = cache.get(key) if result is None: result = ParagraphSizer(paragraph, previous_paragraph, max_width).calculate_height() cache[key] = result # Callers zero out .before on a new page, so hand out a private copy. return replace(result)