From 1e14f4225f4ba02eeb15ab1218255b83cf24d939 Mon Sep 17 00:00:00 2001 From: Bastien GIMBERT Date: Thu, 29 Jan 2026 11:57:51 +0100 Subject: [PATCH] feat(text): add shrink_text_to_fit method for dynamic font resizing --- src/pptx/text/layout.py | 12 ++++++++++-- src/pptx/text/text.py | 40 ++++++++++++++++++++++++++++++++++++++++ tests/text/test_text.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/pptx/text/layout.py b/src/pptx/text/layout.py index d2b43993..2b18b868 100644 --- a/src/pptx/text/layout.py +++ b/src/pptx/text/layout.py @@ -82,6 +82,8 @@ def predicate(point_size): when rendered at `point_size` using the font defined in `font_file`. """ text_lines = self._wrap_lines(self._line_source, point_size) + if text_lines is None: + return False cy = _rendered_size("Ty", point_size, self._font_file)[1] return (cy * len(text_lines)) <= self._height @@ -109,10 +111,16 @@ def _wrap_lines(self, line_source, point_size): *line_source* wrapped within this fitter when rendered at *point_size*. """ - text, remainder = self._break_line(line_source, point_size) + result = self._break_line(line_source, point_size) + if result is None: + return None + text, remainder = result lines = [text] if remainder: - lines.extend(self._wrap_lines(remainder, point_size)) + remaining_lines = self._wrap_lines(remainder, point_size) + if remaining_lines is None: + return None + lines.extend(remaining_lines) return lines diff --git a/src/pptx/text/text.py b/src/pptx/text/text.py index e139410c..26499904 100644 --- a/src/pptx/text/text.py +++ b/src/pptx/text/text.py @@ -101,6 +101,44 @@ def fit_text( font_size = self._best_fit_font_size(font_family, max_size, bold, italic, font_file) self._apply_fit(font_family, font_size, bold, italic) + def shrink_text_to_fit( + self, + font_family: str = "Calibri", + max_size: int = 18, + bold: bool = False, + italic: bool = False, + font_file: str | None = None, + min_size: int = 6, + ): + """Shrink text to fit within bounds and enable TEXT_TO_FIT_SHAPE autofit. + + Similar to :meth:`fit_text`, but sets :attr:`TextFrame.auto_size` to + :attr:`MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE` so PowerPoint will continue to + auto-shrink text if it's modified later. + + This method calculates the best-fit font size and applies it immediately, + solving the issue where PowerPoint doesn't recalculate text size on file open. + + :attr:`TextFrame.auto_size` is set to :attr:`MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE`. + The font size will not be set larger than `max_size` points or smaller than + `min_size` points. + """ + if self.text == "": + return + + try: + font_size = self._best_fit_font_size(font_family, max_size, bold, italic, font_file) + if font_size is None: + font_size = min_size + else: + font_size = max(font_size - 2, min_size) + except (TypeError, RecursionError): + font_size = min_size + + self.word_wrap = True + self._set_font(font_family, font_size, bold, italic) + self.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE + @property def margin_bottom(self) -> Length: """|Length| value representing the inset of text from the bottom text frame border. @@ -261,6 +299,8 @@ def _set_font(self, family: str, size: int, bold: bool, italic: bool): def iter_rPrs(txBody: CT_TextBody) -> Iterator[CT_TextCharacterProperties]: for p in txBody.p_lst: + pPr = p.get_or_add_pPr() + yield pPr.get_or_add_defRPr() for elm in p.content_children: yield elm.get_or_add_rPr() # generate a:endParaRPr for each element diff --git a/tests/text/test_text.py b/tests/text/test_text.py index 3a1a7a0b..5b34de82 100644 --- a/tests/text/test_text.py +++ b/tests/text/test_text.py @@ -197,6 +197,34 @@ def it_can_resize_its_text_to_best_fit(self, request, text_prop_): ) _apply_fit_.assert_called_once_with(text_frame, family, font_size, bold, italic) + def it_fits_text_and_shrinks_with_autosize_enabled(self, request): + """Test shrink_text_to_fit calculates font size and sets TEXT_TO_FIT_SHAPE.""" + family, max_size, bold, italic, font_file, font_size = ( + "family", + 42, + "bold", + "italic", + "font_file", + 21, + ) + expected_font_size = font_size - 2 + text_prop_ = property_mock(request, TextFrame, "text") + text_prop_.return_value = "some text" + _best_fit_font_size_ = method_mock( + request, TextFrame, "_best_fit_font_size", return_value=font_size + ) + _set_font_ = method_mock(request, TextFrame, "_set_font") + text_frame = TextFrame(element("p:txBody/a:bodyPr"), None) + + text_frame.shrink_text_to_fit(family, max_size, bold, italic, font_file) + + _best_fit_font_size_.assert_called_once_with( + text_frame, family, max_size, bold, italic, font_file + ) + _set_font_.assert_called_once_with(text_frame, family, expected_font_size, bold, italic) + assert text_frame.word_wrap is True + assert text_frame.auto_size is MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE + def it_calculates_its_best_fit_font_size_to_help_fit_text(self, size_font_fixture): text_frame, family, max_size, bold, italic = size_font_fixture[:5] FontFiles_, TextFitter_, text, extents = size_font_fixture[5:9]