diff options
Diffstat (limited to '.venv/lib/python3.12/site-packages/pptx/text/layout.py')
-rw-r--r-- | .venv/lib/python3.12/site-packages/pptx/text/layout.py | 325 |
1 files changed, 325 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/pptx/text/layout.py b/.venv/lib/python3.12/site-packages/pptx/text/layout.py new file mode 100644 index 00000000..d2b43993 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/pptx/text/layout.py @@ -0,0 +1,325 @@ +"""Objects related to layout of rendered text, such as TextFitter.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PIL import ImageFont + +if TYPE_CHECKING: + from pptx.util import Length + + +class TextFitter(tuple): + """Value object that knows how to fit text into given rectangular extents.""" + + def __new__(cls, line_source, extents, font_file): + width, height = extents + return tuple.__new__(cls, (line_source, width, height, font_file)) + + @classmethod + def best_fit_font_size( + cls, text: str, extents: tuple[Length, Length], max_size: int, font_file: str + ) -> int: + """Return whole-number best fit point size less than or equal to `max_size`. + + The return value is the largest whole-number point size less than or equal to + `max_size` that allows `text` to fit completely within `extents` when rendered + using font defined in `font_file`. + """ + line_source = _LineSource(text) + text_fitter = cls(line_source, extents, font_file) + return text_fitter._best_fit_font_size(max_size) + + def _best_fit_font_size(self, max_size): + """ + Return the largest whole-number point size less than or equal to + *max_size* that this fitter can fit. + """ + predicate = self._fits_inside_predicate + sizes = _BinarySearchTree.from_ordered_sequence(range(1, int(max_size) + 1)) + return sizes.find_max(predicate) + + def _break_line(self, line_source, point_size): + """ + Return a (line, remainder) pair where *line* is the longest line in + *line_source* that will fit in this fitter's width and *remainder* is + a |_LineSource| object containing the text following the break point. + """ + lines = _BinarySearchTree.from_ordered_sequence(line_source) + predicate = self._fits_in_width_predicate(point_size) + return lines.find_max(predicate) + + def _fits_in_width_predicate(self, point_size): + """ + Return a function taking a text string value and returns |True| if + that text fits in this fitter when rendered at *point_size*. Used as + predicate for _break_line() + """ + + def predicate(line): + """ + Return |True| if *line* fits in this fitter when rendered at + *point_size*. + """ + cx = _rendered_size(line.text, point_size, self._font_file)[0] + return cx <= self._width + + return predicate + + @property + def _fits_inside_predicate(self): + """Return function taking an integer point size argument. + + The function returns |True| if the text in this fitter can be wrapped to fit + entirely within its extents when rendered at that point size. + """ + + def predicate(point_size): + """Return |True| when text in `line_source` can be wrapped to fit. + + Fit means text can be broken into lines that fit entirely within `extents` + when rendered at `point_size` using the font defined in `font_file`. + """ + text_lines = self._wrap_lines(self._line_source, point_size) + cy = _rendered_size("Ty", point_size, self._font_file)[1] + return (cy * len(text_lines)) <= self._height + + return predicate + + @property + def _font_file(self): + return self[3] + + @property + def _height(self): + return self[2] + + @property + def _line_source(self): + return self[0] + + @property + def _width(self): + return self[1] + + def _wrap_lines(self, line_source, point_size): + """ + Return a sequence of str values representing the text in + *line_source* wrapped within this fitter when rendered at + *point_size*. + """ + text, remainder = self._break_line(line_source, point_size) + lines = [text] + if remainder: + lines.extend(self._wrap_lines(remainder, point_size)) + return lines + + +class _BinarySearchTree(object): + """ + A node in a binary search tree. Uniform for root, subtree root, and leaf + nodes. + """ + + def __init__(self, value): + self._value = value + self._lesser = None + self._greater = None + + def find_max(self, predicate, max_=None): + """ + Return the largest item in or under this node that satisfies + *predicate*. + """ + if predicate(self.value): + max_ = self.value + next_node = self._greater + else: + next_node = self._lesser + if next_node is None: + return max_ + return next_node.find_max(predicate, max_) + + @classmethod + def from_ordered_sequence(cls, iseq): + """ + Return the root of a balanced binary search tree populated with the + values in iterable *iseq*. + """ + seq = list(iseq) + # optimize for usually all fits by making longest first + bst = cls(seq.pop()) + bst._insert_from_ordered_sequence(seq) + return bst + + def insert(self, value): + """ + Insert a new node containing *value* into this tree such that its + structure as a binary search tree is preserved. + """ + side = "_lesser" if value < self.value else "_greater" + child = getattr(self, side) + if child is None: + setattr(self, side, _BinarySearchTree(value)) + else: + child.insert(value) + + def tree(self, level=0, prefix=""): + """ + A string representation of the tree rooted in this node, useful for + debugging purposes. + """ + text = "%s%s\n" % (prefix, self.value.text) + prefix = "%sāāā " % (" " * level) + if self._lesser: + text += self._lesser.tree(level + 1, prefix) + if self._greater: + text += self._greater.tree(level + 1, prefix) + return text + + @property + def value(self): + """ + The value object contained in this node. + """ + return self._value + + @staticmethod + def _bisect(seq): + """ + Return a (medial_value, greater_values, lesser_values) 3-tuple + obtained by bisecting sequence *seq*. + """ + if len(seq) == 0: + return [], None, [] + mid_idx = int(len(seq) / 2) + mid = seq[mid_idx] + greater = seq[mid_idx + 1 :] + lesser = seq[:mid_idx] + return mid, greater, lesser + + def _insert_from_ordered_sequence(self, seq): + """ + Insert the new values contained in *seq* into this tree such that + a balanced tree is produced. + """ + if len(seq) == 0: + return + mid, greater, lesser = self._bisect(seq) + self.insert(mid) + self._insert_from_ordered_sequence(greater) + self._insert_from_ordered_sequence(lesser) + + +class _LineSource(object): + """ + Generates all the possible even-word line breaks in a string of text, + each in the form of a (line, remainder) 2-tuple where *line* contains the + text before the break and *remainder* the text after as a |_LineSource| + object. Its boolean value is |True| when it contains text, |False| when + its text is the empty string or whitespace only. + """ + + def __init__(self, text): + self._text = text + + def __bool__(self): + """ + Gives this object boolean behaviors (in Python 3). bool(line_source) + is False if it contains the empty string or whitespace only. + """ + return self._text.strip() != "" + + def __eq__(self, other): + return self._text == other._text + + def __iter__(self): + """ + Generate a (text, remainder) pair for each possible even-word line + break in this line source, where *text* is a str value and remainder + is a |_LineSource| value. + """ + words = self._text.split() + for idx in range(1, len(words) + 1): + line_text = " ".join(words[:idx]) + remainder_text = " ".join(words[idx:]) + remainder = _LineSource(remainder_text) + yield _Line(line_text, remainder) + + def __nonzero__(self): + """ + Gives this object boolean behaviors (in Python 2). bool(line_source) + is False if it contains the empty string or whitespace only. + """ + return self._text.strip() != "" + + def __repr__(self): + return "<_LineSource('%s')>" % self._text + + +class _Line(tuple): + """ + A candidate line broken at an even word boundary from a string of text, + and a |_LineSource| value containing the text that remains after the line + is broken at this spot. + """ + + def __new__(cls, text, remainder): + return tuple.__new__(cls, (text, remainder)) + + def __gt__(self, other): + return len(self.text) > len(other.text) + + def __lt__(self, other): + return not self.__gt__(other) + + def __len__(self): + return len(self.text) + + def __repr__(self): + return "'%s' => '%s'" % (self.text, self.remainder) + + @property + def remainder(self): + return self[1] + + @property + def text(self): + return self[0] + + +class _Fonts(object): + """ + A memoizing cache for ImageFont objects. + """ + + fonts = {} + + @classmethod + def font(cls, font_path, point_size): + if (font_path, point_size) not in cls.fonts: + cls.fonts[(font_path, point_size)] = ImageFont.truetype(font_path, point_size) + return cls.fonts[(font_path, point_size)] + + +def _rendered_size(text, point_size, font_file): + """ + Return a (width, height) pair representing the size of *text* in English + Metric Units (EMU) when rendered at *point_size* in the font defined in + *font_file*. + """ + emu_per_inch = 914400 + px_per_inch = 72.0 + + font = _Fonts.font(font_file, point_size) + try: + px_width, px_height = font.getsize(text) + except AttributeError: + left, top, right, bottom = font.getbbox(text) + px_width, px_height = right - left, bottom - top + + emu_width = int(px_width / px_per_inch * emu_per_inch) + emu_height = int(px_height / px_per_inch * emu_per_inch) + + return emu_width, emu_height |