diff --git a/features/iss-16-advanced-text.feature b/features/iss-16-advanced-text.feature new file mode 100644 index 000000000..0265cc889 --- /dev/null +++ b/features/iss-16-advanced-text.feature @@ -0,0 +1,59 @@ +Feature: Issue #16 — advanced text, auto-fit & internationalization + In order to author rich, international, well-fitted text + As a developer using python-pptx-extended + I need run typography, CJK/complex-script fonts, columns, vertical + text, RTL paragraphs, overflow detection and crash-free auto-fit + + Scenario: Author and read back superscript + Given a blank slide text frame with one run + When I set the run superscript + Then the run reports superscript true + + Scenario: Author and read back double strikethrough + Given a blank slide text frame with one run + When I set the run strike to double + Then the run reports strike double after round-trip + + Scenario: Author and read back a yellow highlight + Given a blank slide text frame with one run + When I set the run highlight to FFFF00 + Then the run reports highlight FFFF00 after round-trip + + Scenario: Author and read back character spacing + Given a blank slide text frame with one run + When I set the run character spacing to 2 points + Then the run reports character spacing 2 points + + Scenario: East-Asian font set leaves Latin untouched + Given a blank slide text frame with one run + When I set east_asian to MS Gothic and name to Calibri + Then latin is Calibri and east_asian is MS Gothic and they are independent + + Scenario: Two-column text box + Given a blank slide text frame with one run + When I set the text frame to 2 columns spaced 36 points + Then the text frame reports 2 columns after round-trip + + Scenario: Vertical text direction + Given a blank slide text frame with one run + When I set the text direction to east asian vertical + Then the text frame reports east asian vertical after round-trip + + Scenario: Arabic right-to-left paragraph + Given a blank slide text frame with one run + When I set the paragraph to Arabic right-to-left + Then the paragraph reports rtl true after round-trip + + Scenario: Overflow detection flags oversized content + Given a tiny text frame stuffed with text + Then will_overflow reports true + + Scenario: fit_text survives a single unbreakable long word + Given a tiny text frame with one very long word + When I call fit_text on it + Then no error is raised and auto_size is set + + Scenario: shrink_text_to_fit eagerly reduces the font scale + Given a tiny text frame stuffed with text + When I call shrink_text_to_fit + Then the normAutofit fontScale is below 100 diff --git a/features/steps/iss16.py b/features/steps/iss16.py new file mode 100644 index 000000000..b7b5dc293 --- /dev/null +++ b/features/steps/iss16.py @@ -0,0 +1,182 @@ +"""Step implementations for features/iss-16-advanced-text.feature (issue #16). + +Self-contained: every scenario builds an in-memory blank presentation. +""" + +import io +import os + +from behave import given, then, when + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import MSO_AUTO_SIZE, MSO_TEXT_DIRECTION, MSO_TEXT_STRIKE_TYPE +from pptx.util import Inches, Pt + +TEST_FONT = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "..", "tests", "test_files", "calibriz.ttf" +) +TEST_FONT = os.path.abspath(TEST_FONT) + + +def _blank_run(context): + prs = Presentation() + s = prs.slides.add_slide(prs.slide_layouts[6]) + tf = s.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(2)).text_frame + r = tf.paragraphs[0].add_run() + r.text = "Sample" + context.prs = prs + context.tf = tf + context.run = r + + +def _roundtrip(context): + buf = io.BytesIO() + context.prs.save(buf) + buf.seek(0) + context.prs2 = Presentation(buf) + context.tf2 = list(context.prs2.slides[0].shapes)[0].text_frame + return context.tf2 + + +@given("a blank slide text frame with one run") +def given_blank_run(context): + _blank_run(context) + + +@given("a tiny text frame stuffed with text") +def given_tiny_stuffed(context): + prs = Presentation() + s = prs.slides.add_slide(prs.slide_layouts[6]) + tf = s.shapes.add_textbox(Inches(0), Inches(0), Inches(1), Inches(0.3)).text_frame + tf.text = "Supercalifragilistic " * 14 + for r in tf.paragraphs[0].runs: + r.font.size = Pt(18) + context.prs = prs + context.tf = tf + + +@given("a tiny text frame with one very long word") +def given_tiny_longword(context): + prs = Presentation() + s = prs.slides.add_slide(prs.slide_layouts[6]) + tf = s.shapes.add_textbox(Inches(0), Inches(0), Inches(0.5), Inches(0.5)).text_frame + tf.text = "Supercalifragilisticexpialidocious" + context.prs = prs + context.tf = tf + + +@when("I set the run superscript") +def when_superscript(context): + context.run.font.superscript = True + + +@then("the run reports superscript true") +def then_superscript(context): + assert context.run.font.superscript is True + + +@when("I set the run strike to double") +def when_strike_double(context): + context.run.font.strike = MSO_TEXT_STRIKE_TYPE.DOUBLE + + +@then("the run reports strike double after round-trip") +def then_strike_double(context): + f2 = _roundtrip(context).paragraphs[0].runs[0].font + assert f2.strike == MSO_TEXT_STRIKE_TYPE.DOUBLE + + +@when("I set the run highlight to FFFF00") +def when_highlight(context): + context.run.font.highlight.rgb = RGBColor(0xFF, 0xFF, 0x00) + + +@then("the run reports highlight FFFF00 after round-trip") +def then_highlight(context): + f2 = _roundtrip(context).paragraphs[0].runs[0].font + assert f2.highlight.rgb == RGBColor(0xFF, 0xFF, 0x00) + + +@when("I set the run character spacing to 2 points") +def when_spacing(context): + context.run.font.character_spacing = Pt(2) + + +@then("the run reports character spacing 2 points") +def then_spacing(context): + assert context.run.font.character_spacing.pt == 2.0 + + +@when("I set east_asian to MS Gothic and name to Calibri") +def when_trio(context): + context.run.font.east_asian = "MS Gothic" + context.run.font.name = "Calibri" + + +@then("latin is Calibri and east_asian is MS Gothic and they are independent") +def then_trio(context): + f2 = _roundtrip(context).paragraphs[0].runs[0].font + assert f2.name == "Calibri" and f2.east_asian == "MS Gothic" + assert f2.latin == "Calibri" + + +@when("I set the text frame to 2 columns spaced 36 points") +def when_columns(context): + context.tf.columns = 2 + context.tf.column_spacing = Pt(36) + + +@then("the text frame reports 2 columns after round-trip") +def then_columns(context): + assert _roundtrip(context).columns == 2 + + +@when("I set the text direction to east asian vertical") +def when_direction(context): + context.tf.text_direction = MSO_TEXT_DIRECTION.EAST_ASIAN_VERTICAL + + +@then("the text frame reports east asian vertical after round-trip") +def then_direction(context): + assert _roundtrip(context).text_direction == MSO_TEXT_DIRECTION.EAST_ASIAN_VERTICAL + + +@when("I set the paragraph to Arabic right-to-left") +def when_rtl(context): + p = context.tf.paragraphs[0] + p.text = "اللغة العربية" + p.rtl = True + + +@then("the paragraph reports rtl true after round-trip") +def then_rtl(context): + p2 = _roundtrip(context).paragraphs[0] + assert p2.rtl is True + + +@then("will_overflow reports true") +def then_will_overflow(context): + assert context.tf.will_overflow(font_file=TEST_FONT) is True + + +@when("I call fit_text on it") +def when_fit_text(context): + context.tf.fit_text(font_file=TEST_FONT) + + +@then("no error is raised and auto_size is set") +def then_fit_ok(context): + assert context.tf.auto_size is not None + + +@when("I call shrink_text_to_fit") +def when_shrink(context): + context.tf.shrink_text_to_fit(font_file=TEST_FONT) + + +@then("the normAutofit fontScale is below 100") +def then_shrink(context): + na = context.tf._txBody.bodyPr.normAutofit + assert na is not None and na.fontScale < 100 + assert context.tf.auto_size == MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE diff --git a/src/pptx/enum/text.py b/src/pptx/enum/text.py index 88d020dc2..0737a511f 100644 --- a/src/pptx/enum/text.py +++ b/src/pptx/enum/text.py @@ -299,3 +299,51 @@ class PP_AUTO_NUMBER_STYLE(BaseXmlEnum): ALPHA_LC_PAREN_BOTH = (12, "alphaLcParenBoth", "Lowercase letters in parentheses: (a) (b) (c)") """Lowercase letters in parentheses: (a) (b) (c)""" + + +class MSO_TEXT_STRIKE_TYPE(BaseXmlEnum): + """Specifies the strikethrough style of text. + + Used with :attr:`.Font.strike`. Maps to the OOXML `a:rPr/@strike` + attribute (`ST_TextStrikeType`). Issue #16 SF2. + + MS API Name: (no direct VBA equivalent — modeled on `MsoTextStrike`). + """ + + NONE = (0, "noStrike", "No strikethrough.") + """No strikethrough.""" + + SINGLE = (1, "sngStrike", "A single-line strikethrough.") + """A single-line strikethrough.""" + + DOUBLE = (2, "dblStrike", "A double-line strikethrough.") + """A double-line strikethrough.""" + + +class MSO_TEXT_DIRECTION(BaseXmlEnum): + """Specifies the flow direction of text in a text frame. + + Used with :attr:`.TextFrame.text_direction`. Maps to the OOXML + `a:bodyPr/@vert` attribute (`ST_TextVerticalType`). Issue #16 SF7. + """ + + HORIZONTAL = (0, "horz", "Horizontal text (the default).") + """Horizontal text (the default).""" + + VERTICAL = (1, "vert", "Vertical text, rotated 90° clockwise.") + """Vertical text, rotated 90° clockwise.""" + + VERTICAL_270 = (2, "vert270", "Vertical text, rotated 270° clockwise.") + """Vertical text, rotated 270° clockwise.""" + + WORD_ART_VERTICAL = (3, "wordArtVert", "WordArt-style stacked vertical text.") + """WordArt-style stacked vertical text.""" + + EAST_ASIAN_VERTICAL = (4, "eaVert", "East-Asian vertical text.") + """East-Asian vertical text.""" + + MONGOLIAN_VERTICAL = (5, "mongolianVert", "Mongolian vertical text.") + """Mongolian vertical text.""" + + WORD_ART_VERTICAL_RTL = (6, "wordArtVertRtl", "Right-to-left WordArt vertical text.") + """Right-to-left WordArt vertical text.""" diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py index 0de1e3997..6e4121bba 100644 --- a/src/pptx/oxml/__init__.py +++ b/src/pptx/oxml/__init__.py @@ -291,6 +291,7 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): register_element_cls("a:alphaOff", CT_PositiveFixedPercentage) register_element_cls("a:bgClr", CT_Color) register_element_cls("a:fgClr", CT_Color) +register_element_cls("a:highlight", CT_Color) register_element_cls("a:hslClr", CT_HslColor) register_element_cls("a:lumMod", CT_Percentage) register_element_cls("a:lumOff", CT_Percentage) @@ -583,6 +584,8 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): register_element_cls("a:endParaRPr", CT_TextCharacterProperties) register_element_cls("a:fld", CT_TextField) register_element_cls("a:latin", CT_TextFont) +register_element_cls("a:ea", CT_TextFont) +register_element_cls("a:cs", CT_TextFont) register_element_cls("a:lnSpc", CT_TextSpacing) register_element_cls("a:normAutofit", CT_TextNormalAutofit) register_element_cls("a:r", CT_RegularTextRun) diff --git a/src/pptx/oxml/simpletypes.py b/src/pptx/oxml/simpletypes.py index 2b23075eb..50f81d0a4 100644 --- a/src/pptx/oxml/simpletypes.py +++ b/src/pptx/oxml/simpletypes.py @@ -669,6 +669,37 @@ def validate(cls, value): cls.validate_int_in_range(value, 100, 400000) +class ST_TextPoint(BaseIntType): + """Signed text point measure in 1/100 pt, e.g. character spacing `spc`. + + OOXML ST_TextPointUnqualified is `xsd:int` restricted to + -400000..400000 (-4000..4000 pt). Negative values tighten spacing. + """ + + @classmethod + def validate(cls, value): + cls.validate_int_in_range(value, -400000, 400000) + + +class ST_TextNonNegativePoint(BaseIntType): + """Non-negative text point measure in 1/100 pt, e.g. kerning `kern`. + + OOXML ST_TextNonNegativePoint restricts to 0..400000. + """ + + @classmethod + def validate(cls, value): + cls.validate_int_in_range(value, 0, 400000) + + +class ST_TextColumnCount(BaseIntType): + """Text column count, 1..16 inclusive (OOXML ST_TextColumnCount).""" + + @classmethod + def validate(cls, value): + cls.validate_int_in_range(value, 1, 16) + + class ST_TextIndentLevelType(BaseIntType): @classmethod def validate(cls, value): diff --git a/src/pptx/oxml/text.py b/src/pptx/oxml/text.py index f60fabe36..1e03e31d0 100644 --- a/src/pptx/oxml/text.py +++ b/src/pptx/oxml/text.py @@ -8,6 +8,8 @@ from pptx.enum.lang import MSO_LANGUAGE_ID from pptx.enum.text import ( MSO_AUTO_SIZE, + MSO_TEXT_DIRECTION, + MSO_TEXT_STRIKE_TYPE, MSO_TEXT_UNDERLINE_TYPE, MSO_VERTICAL_ANCHOR, PP_PARAGRAPH_ALIGNMENT, @@ -19,9 +21,13 @@ from pptx.oxml.simpletypes import ( ST_Coordinate32, ST_FieldType, + ST_Percentage, + ST_TextColumnCount, ST_TextFontScalePercentOrPercentString, ST_TextFontSize, ST_TextIndentLevelType, + ST_TextNonNegativePoint, + ST_TextPoint, ST_TextSpacingPercentOrPercentString, ST_TextSpacingPoint, ST_TextTypeface, @@ -226,6 +232,18 @@ class CT_TextBodyProperties(BaseOxmlElement): wrap: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "wrap", ST_TextWrappingType ) + numCol: int | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "numCol", ST_TextColumnCount + ) + spcCol: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "spcCol", ST_Coordinate32 + ) + rtlCol: bool | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "rtlCol", XsdBoolean + ) + vert: MSO_TEXT_DIRECTION | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "vert", MSO_TEXT_DIRECTION + ) @property def autofit(self): @@ -262,7 +280,13 @@ class CT_TextCharacterProperties(BaseOxmlElement): get_or_add_hlinkClick: Callable[[], CT_Hyperlink] get_or_add_latin: Callable[[], CT_TextFont] + get_or_add_highlight: Callable[[], BaseOxmlElement] + get_or_add_ea: Callable[[], CT_TextFont] + get_or_add_cs: Callable[[], CT_TextFont] _remove_latin: Callable[[], None] + _remove_highlight: Callable[[], None] + _remove_ea: Callable[[], None] + _remove_cs: Callable[[], None] _remove_hlinkClick: Callable[[], None] eg_fillProperties = ZeroOrOneChoice( @@ -292,6 +316,27 @@ class CT_TextCharacterProperties(BaseOxmlElement): "a:extLst", ), ) + # ---OOXML a:rPr child order (dml-main.xsd CT_TextCharacterProperties): + # ---ln, fill, effectLst/effectDag, highlight, uLnTx/uLn/uFillTx/uFill, + # ---latin, ea, cs, sym, hlinkClick, hlinkMouseOver, rtl, extLst. + # ---Emitting a child out of this order is a silent PowerPoint repair. + highlight: BaseOxmlElement | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:highlight", + successors=( + "a:uLnTx", + "a:uLn", + "a:uFillTx", + "a:uFill", + "a:latin", + "a:ea", + "a:cs", + "a:sym", + "a:hlinkClick", + "a:hlinkMouseOver", + "a:rtl", + "a:extLst", + ), + ) latin: CT_TextFont | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "a:latin", successors=( @@ -304,6 +349,21 @@ class CT_TextCharacterProperties(BaseOxmlElement): "a:extLst", ), ) + ea: CT_TextFont | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:ea", + successors=( + "a:cs", + "a:sym", + "a:hlinkClick", + "a:hlinkMouseOver", + "a:rtl", + "a:extLst", + ), + ) + cs: CT_TextFont | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:cs", + successors=("a:sym", "a:hlinkClick", "a:hlinkMouseOver", "a:rtl", "a:extLst"), + ) hlinkClick: CT_Hyperlink | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "a:hlinkClick", successors=("a:hlinkMouseOver", "a:rtl", "a:extLst") ) @@ -319,6 +379,18 @@ class CT_TextCharacterProperties(BaseOxmlElement): u: MSO_TEXT_UNDERLINE_TYPE | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "u", MSO_TEXT_UNDERLINE_TYPE ) + strike: MSO_TEXT_STRIKE_TYPE | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "strike", MSO_TEXT_STRIKE_TYPE + ) + baseline: int | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "baseline", ST_Percentage + ) + spc: int | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "spc", ST_TextPoint + ) + kern: int | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "kern", ST_TextNonNegativePoint + ) def _new_gradFill(self): return CT_GradientFillProperties.new_gradFill() @@ -418,6 +490,9 @@ class CT_TextNormalAutofit(BaseOxmlElement): fontScale = OptionalAttribute( "fontScale", ST_TextFontScalePercentOrPercentString, default=100.0 ) + lnSpcReduction = OptionalAttribute( + "lnSpcReduction", ST_TextSpacingPercentOrPercentString, default=0.0 + ) class CT_TextParagraph(BaseOxmlElement): @@ -544,6 +619,9 @@ class CT_TextParagraphProperties(BaseOxmlElement): "lvl", ST_TextIndentLevelType, default=0 ) algn: PP_PARAGRAPH_ALIGNMENT | None = OptionalAttribute("algn", PP_PARAGRAPH_ALIGNMENT) # pyright: ignore[reportAssignmentType] + rtl: bool | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "rtl", XsdBoolean + ) del _tag_seq @property diff --git a/src/pptx/text/layout.py b/src/pptx/text/layout.py index d2b439939..ccd856071 100644 --- a/src/pptx/text/layout.py +++ b/src/pptx/text/layout.py @@ -40,6 +40,22 @@ def _best_fit_font_size(self, max_size): sizes = _BinarySearchTree.from_ordered_sequence(range(1, int(max_size) + 1)) return sizes.find_max(predicate) + @classmethod + def wrapped_line_count(cls, text: str, width: "Length", font_file: str, point_size: int) -> int: + """Number of lines `text` wraps to at `point_size` within `width`. + + Used by overflow detection (issue #16 SF9). Height is irrelevant + here so 0 is passed for it. + """ + line_source = _LineSource(text) + fitter = cls(line_source, (width, 0), font_file) + return len(fitter._wrap_lines(line_source, point_size)) + + @staticmethod + def line_height(point_size: int, font_file: str) -> int: + """Rendered single-line height in EMU at `point_size` (issue #16 SF9).""" + return _rendered_size("Ty", point_size, font_file)[1] + def _break_line(self, line_source, point_size): """ Return a (line, remainder) pair where *line* is the longest line in @@ -48,7 +64,19 @@ def _break_line(self, line_source, point_size): """ lines = _BinarySearchTree.from_ordered_sequence(line_source) predicate = self._fits_in_width_predicate(point_size) - return lines.find_max(predicate) + best = lines.find_max(predicate) + if best is None: + # ---issue #16 SF11 / scanny#168: a single word wider than the + # ---frame makes every width predicate False, so find_max + # ---returns None and the old code crashed unpacking it. Force- + # ---accept the shortest candidate (first word): it overflows + # ---the width — matching PowerPoint, which just clips a long + # ---word — but consumes >=1 word so _wrap_lines terminates. + try: + best = next(iter(line_source)) + except StopIteration: + best = _Line("", _LineSource("")) + return best def _fits_in_width_predicate(self, point_size): """ diff --git a/src/pptx/text/text.py b/src/pptx/text/text.py index a36605cfb..272de41f6 100644 --- a/src/pptx/text/text.py +++ b/src/pptx/text/text.py @@ -3,13 +3,15 @@ from __future__ import annotations import uuid -from typing import TYPE_CHECKING, Iterator, cast +from typing import TYPE_CHECKING, Iterator, NamedTuple, cast from pptx.dml.fill import FillFormat from pptx.enum.dml import MSO_FILL from pptx.enum.lang import MSO_LANGUAGE_ID from pptx.enum.text import ( MSO_AUTO_SIZE, + MSO_TEXT_DIRECTION, + MSO_TEXT_STRIKE_TYPE, MSO_UNDERLINE, MSO_VERTICAL_ANCHOR, PP_AUTO_NUMBER_STYLE, @@ -41,6 +43,20 @@ from pptx.types import ProvidesExtents, ProvidesPart +class _OverflowInfo(NamedTuple): + """Structured result of :meth:`TextFrame.overflow_info` (issue #16 SF9). + + `overflows` is the boolean answer; the other fields expose the rendered + vs. available extents so callers can report *by how much* and which + dimension (height) is limiting. + """ + + overflows: bool + required_height: Length + available_height: Length + available_width: Length + + class TextFrame(Subshape): """The part of a shape that contains its text. @@ -224,6 +240,141 @@ def word_wrap(self, value: bool | None): None: None, }[value] + @property + def columns(self) -> int: + """Number of text columns in this text frame (issue #16 SF6). + + Read/write int in range 1..16, backed by `a:bodyPr/@numCol`. + Returns 1 when no explicit value is set. Assigning a value outside + 1..16 raises |ValueError|. + """ + numCol = self._bodyPr.numCol + return 1 if numCol is None else numCol + + @columns.setter + def columns(self, value: int): + if not isinstance(value, int) or value < 1 or value > 16: + raise ValueError(f"columns must be an int in range 1..16, got {value!r}") + self._bodyPr.numCol = value + + @property + def column_spacing(self) -> Length | None: + """Spacing between text columns as a |Length| (issue #16 SF6). + + Backed by `a:bodyPr/@spcCol` (EMU). |None| when unset; assigning + |None| removes the attribute. + """ + spcCol = self._bodyPr.spcCol + if spcCol is None: + return None + return Emu(spcCol) + + @column_spacing.setter + def column_spacing(self, value: Length | None): + self._bodyPr.spcCol = None if value is None else Emu(value) + + @property + def text_direction(self) -> MSO_TEXT_DIRECTION | None: + """Flow direction of text in this frame (issue #16 SF7). + + A member of :ref:`MsoTextDirection` (e.g. `VERTICAL`, `EAST_ASIAN_ + VERTICAL`) or |None| when inherited. Backed by `a:bodyPr/@vert`. + Assigning |None| removes the attribute. + """ + return self._bodyPr.vert + + @text_direction.setter + def text_direction(self, value: MSO_TEXT_DIRECTION | None): + self._bodyPr.vert = value + + def overflow_info( + self, + font_family: str = "Calibri", + bold: bool = False, + italic: bool = False, + font_file: str | None = None, + ) -> "_OverflowInfo": + """Return a structured report on whether this text overflows its shape. + + Read-only: does NOT modify the text frame or set autofit (issue #16 + SF9, closes scanny/python-pptx#1114). The report names the rendered + vs. available height for the frame's text wrapped at the largest run + font size present (defaulting to 18pt), using the metrics of the + font described by `font_family`/`bold`/`italic` (or `font_file`). + """ + avail_w, avail_h = self._extents + text = self.text + if text == "": + return _OverflowInfo(False, Emu(0), Length(avail_h), Length(avail_w)) + if font_file is None: + font_file = FontFiles.find(font_family, bold, italic) + point_size = self._max_run_point_size() + n_lines = TextFitter.wrapped_line_count(text, avail_w, font_file, point_size) + line_cy = TextFitter.line_height(point_size, font_file) + required_h = Length(line_cy * n_lines) + return _OverflowInfo( + overflows=required_h > avail_h, + required_height=required_h, + available_height=Length(avail_h), + available_width=Length(avail_w), + ) + + def will_overflow( + self, + font_family: str = "Calibri", + bold: bool = False, + italic: bool = False, + font_file: str | None = None, + ) -> bool: + """`True` if this text frame's content would overflow its shape. + + Thin boolean over :meth:`overflow_info` (issue #16 SF9). Read-only. + """ + return self.overflow_info(font_family, bold, italic, font_file).overflows + + 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, + ): + """Eagerly shrink text via `normAutofit` so it fits without reopen. + + Sets `auto_size` to `TEXT_TO_FIT_SHAPE` and writes a computed + `a:normAutofit/@fontScale` so the text fits inside the shape + immediately — without depending on PowerPoint to recompute the + scale on open (issue #16 SF10, closes scanny/python-pptx#1107). Run + `sz` values are left unchanged (the scale is applied by the + renderer). No-op on an empty text frame. + """ + if self.text == "": + return + self.word_wrap = True + self.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE + max_pt = self._max_run_point_size(default=max_size) + best = self._best_fit_font_size(font_family, max_pt, bold, italic, font_file) + scale = max(1.0, min(100.0, (best / max_pt) * 100.0)) + normAutofit = self._bodyPr.normAutofit + if normAutofit is not None: + normAutofit.fontScale = scale + + def _max_run_point_size(self, default: int = 18) -> int: + """Largest explicit run font size in this frame, in whole points. + + Falls back to `default` when no run sets an explicit size. Reads the + `a:rPr/@sz` directly off each run element and never creates an + `a:rPr` — `overflow_info`/`will_overflow` must be read-only (ISC-68). + """ + sizes: list[int] = [] + for p in self._txBody.p_lst: + for r in p.r_lst: + rPr = r.rPr # ZeroOrOne — None when absent, no mutation + if rPr is not None and rPr.sz is not None: + sizes.append(int(round(Centipoints(rPr.sz).pt))) + return max(sizes) if sizes else default + def _apply_fit(self, font_family: str, font_size: int, is_bold: bool, is_italic: bool): """Arrange text in this text frame to fit inside its extents. @@ -436,6 +587,208 @@ def underline(self, value: bool | MSO_TEXT_UNDERLINE_TYPE | None): value = MSO_UNDERLINE.NONE self._element.u = value + @property + def strike(self) -> MSO_TEXT_STRIKE_TYPE | None: + """Strikethrough setting for this font (issue #16 SF2). + + A member of :ref:`MsoTextStrikeType` (`NONE`/`SINGLE`/`DOUBLE`) or + |None| when no explicit value is set (inherited from the style + hierarchy). Assigning |None| removes the attribute (restoring + inheritance); assigning `MSO_TEXT_STRIKE_TYPE.NONE` writes an + explicit `strike="noStrike"`. + """ + return self._rPr.strike + + @strike.setter + def strike(self, value: MSO_TEXT_STRIKE_TYPE | None): + self._rPr.strike = value + + @property + def superscript(self) -> bool | None: + """Whether this font is superscript (issue #16 SF1). + + Backed by `a:rPr/@baseline` (a signed percentage). |True| when the + baseline is positive, |False| when it is zero/negative, |None| when + no baseline is set. Assigning |True| sets a +30% baseline; |False| + or |None| removes the baseline (and thus any subscript too — + super/subscript are mutually exclusive). + """ + baseline = self._rPr.baseline + if baseline is None: + return None + return baseline > 0 + + @superscript.setter + def superscript(self, value: bool | None): + if value: + # ---ST_Percentage python value is a fraction: 0.30 -> "30000" + self._rPr.baseline = 0.30 + else: + self._rPr.baseline = None + + @property + def subscript(self) -> bool | None: + """Whether this font is subscript (issue #16 SF1). + + Backed by `a:rPr/@baseline`. |True| when the baseline is negative, + |False| when zero/positive, |None| when unset. Assigning |True| sets + a -25% baseline; |False|/|None| removes the baseline. + """ + baseline = self._rPr.baseline + if baseline is None: + return None + return baseline < 0 + + @subscript.setter + def subscript(self, value: bool | None): + if value: + # ---fraction: -0.25 -> "-25000" + self._rPr.baseline = -0.25 + else: + self._rPr.baseline = None + + @property + def character_spacing(self) -> Length | None: + """Inter-character spacing (`a:rPr/@spc`) as a |Length| (issue #16 SF4). + + Read/write. |None| when no explicit value is set. Negative values + tighten spacing, positive values loosen it. Assigning |None| removes + the attribute. + """ + spc = self._rPr.spc + if spc is None: + return None + return Centipoints(spc) + + @character_spacing.setter + def character_spacing(self, value: Length | None): + if value is None: + self._rPr.spc = None + else: + self._rPr.spc = Emu(value).centipoints + + @property + def kerning(self) -> Length | None: + """Minimum font size at which kerning is applied (`a:rPr/@kern`). + + Read/write |Length| or |None| (issue #16 SF4). Non-negative. + """ + kern = self._rPr.kern + if kern is None: + return None + return Centipoints(kern) + + @kerning.setter + def kerning(self, value: Length | None): + if value is None: + self._rPr.kern = None + else: + self._rPr.kern = Emu(value).centipoints + + @property + def latin(self) -> str | None: + """The Latin-script typeface name (`a:rPr/a:latin`) (issue #16 SF5). + + Equivalent to :attr:`name`; provided so the latin/east-asian/ + complex-script trio reads symmetrically. Assigning |None| removes + the `` child. + """ + return self.name + + @latin.setter + def latin(self, value: str | None): + self.name = value + + @property + def east_asian(self) -> str | None: + """The East-Asian typeface name (`a:rPr/a:ea`) (issue #16 SF5). + + Independent of :attr:`name`/:attr:`latin` — setting this never + touches ``. Assigning |None| removes the `` child. + """ + ea = self._rPr.ea + if ea is None: + return None + return ea.typeface + + @east_asian.setter + def east_asian(self, value: str | None): + if value is None: + self._rPr._remove_ea() # pyright: ignore[reportPrivateUsage] + else: + self._rPr.get_or_add_ea().typeface = value + + @property + def complex_script(self) -> str | None: + """The complex-script typeface name (`a:rPr/a:cs`) (issue #16 SF5). + + Independent of :attr:`name`/:attr:`latin`. Assigning |None| removes + the `` child. + """ + cs = self._rPr.cs + if cs is None: + return None + return cs.typeface + + @complex_script.setter + def complex_script(self, value: str | None): + if value is None: + self._rPr._remove_cs() # pyright: ignore[reportPrivateUsage] + else: + self._rPr.get_or_add_cs().typeface = value + + @lazyproperty + def highlight(self) -> "_HighlightColor": + """Text highlight color (`a:rPr/a:highlight`) (issue #16 SF3). + + Returns a |_HighlightColor| proxy. Reading `.rgb` when no highlight + is set returns |None| and does NOT mutate the XML; assigning `.rgb` + materializes `` (schema-ordered before the typeface + trio). + """ + return _HighlightColor(self._rPr) + + +class _HighlightColor: + """Lazy `` color proxy for :attr:`Font.highlight` (issue #16 SF3). + + Read of `.rgb`/`.type` when no `` is present returns |None| + without touching the XML; writing `.rgb` creates the element on demand. + """ + + def __init__(self, rPr: "CT_TextCharacterProperties"): + self._rPr = rPr + + @property + def type(self): + from pptx.dml.color import ColorFormat + + hl = self._rPr.highlight + if hl is None: + return None + return ColorFormat.from_colorchoice_parent(hl).type + + @property + def rgb(self): + from pptx.dml.color import ColorFormat + + hl = self._rPr.highlight + if hl is None: + return None + return ColorFormat.from_colorchoice_parent(hl).rgb + + @rgb.setter + def rgb(self, value): + from pptx.dml.color import ColorFormat + + hl = self._rPr.get_or_add_highlight() + ColorFormat.from_colorchoice_parent(hl).rgb = value + + @property + def visible(self) -> bool: + """|True| if an `` element is present.""" + return self._rPr.highlight is not None + class _LazyFontColorFormat: """ColorFormat-shaped proxy that defers `` creation until first SET. @@ -613,6 +966,24 @@ def alignment(self) -> PP_PARAGRAPH_ALIGNMENT | None: def alignment(self, value: PP_PARAGRAPH_ALIGNMENT | None): self._pPr.algn = value + @property + def rtl(self) -> bool | None: + """Right-to-left setting for this paragraph (issue #16 SF8). + + Backed by `a:pPr/@rtl`. |True| flows the paragraph right-to-left + (Arabic, Hebrew, Persian); |False| forces left-to-right; |None| + (default) removes the attribute so direction is inherited. + PowerPoint performs the actual bidi shaping. + """ + pPr = self._p.pPr + if pPr is None: + return None + return pPr.rtl + + @rtl.setter + def rtl(self, value: bool | None): + self._pPr.rtl = value + @property def bullet_char(self) -> str | None: """Character used for bullet, e.g. '•'. diff --git a/tests/test_issue16_advanced_text.py b/tests/test_issue16_advanced_text.py new file mode 100644 index 000000000..91dfa2ad5 --- /dev/null +++ b/tests/test_issue16_advanced_text.py @@ -0,0 +1,453 @@ +# pyright: reportPrivateUsage=false + +"""Unit + round-trip tests for issue #16. + +[Epic] Advanced Text, Auto-fit & Internationalization — +https://github.com/MHoroszowski/python-pptx/issues/16 + +Covers all 11 sub-features: sub/superscript, strike, highlight, +character-spacing+kerning, latin/east_asian/complex_script (incl. +Font.name backward-compat), columns+spacing, text_direction, paragraph +RTL, will_overflow/overflow_info, shrink_text_to_fit, and the #168 +fit_text long-word crash fix. + +Layered like tests/test_issue18_shape_effects.py: API-surface unit tests ++ save→reopen round-trip integration (the only layer that proves the +file is Office-compatible and nothing was silently dropped). +""" + +from __future__ import annotations + +import io +import os + +import pytest + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import MSO_TEXT_DIRECTION, MSO_TEXT_STRIKE_TYPE +from pptx.util import Inches, Pt + +A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" +TEST_FONT = os.path.join(os.path.dirname(__file__), "test_files", "calibriz.ttf") + + +def _frame(): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + tb = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(2)) + return prs, tb.text_frame + + +def _run(tf): + r = tf.paragraphs[0].add_run() + r.text = "Sample" + return r + + +def _roundtrip(prs): + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + return Presentation(buf) + + +def _first_tf(prs2): + return list(prs2.slides[0].shapes)[0].text_frame + + +# ───────── A. super/subscript (SF1) ───────── + + +class DescribeSuperSubscript: + def it_sets_superscript(self): + prs, tf = _frame() + f = _run(tf).font + f.superscript = True + assert f.superscript is True + assert f._rPr.get("baseline") == "30000" + + def it_sets_subscript_negative_baseline(self): + prs, tf = _frame() + f = _run(tf).font + f.subscript = True + assert f.subscript is True + assert int(f._rPr.get("baseline")) < 0 + + def it_makes_super_and_sub_mutually_exclusive(self): + prs, tf = _frame() + f = _run(tf).font + f.superscript = True + f.subscript = True + assert f.subscript is True + assert f.superscript is False + + def it_clears_baseline_on_false(self): + prs, tf = _frame() + f = _run(tf).font + f.superscript = True + f.superscript = False + assert f.superscript is None + assert f._rPr.get("baseline") is None + + def it_round_trips_superscript(self): + prs, tf = _frame() + _run(tf).font.superscript = True + f2 = _first_tf(_roundtrip(prs)).paragraphs[0].runs[0].font + assert f2.superscript is True + + +# ───────── B. strike (SF2) ───────── + + +class DescribeStrike: + def it_sets_single_strike(self): + prs, tf = _frame() + f = _run(tf).font + f.strike = MSO_TEXT_STRIKE_TYPE.SINGLE + assert f._rPr.get("strike") == "sngStrike" + + def it_sets_double_strike(self): + prs, tf = _frame() + f = _run(tf).font + f.strike = MSO_TEXT_STRIKE_TYPE.DOUBLE + assert f._rPr.get("strike") == "dblStrike" + assert f.strike == MSO_TEXT_STRIKE_TYPE.DOUBLE + + def it_removes_strike_on_none(self): + prs, tf = _frame() + f = _run(tf).font + f.strike = MSO_TEXT_STRIKE_TYPE.SINGLE + f.strike = None + assert f._rPr.get("strike") is None + assert f.strike is None + + def it_round_trips_strike(self): + prs, tf = _frame() + _run(tf).font.strike = MSO_TEXT_STRIKE_TYPE.DOUBLE + f2 = _first_tf(_roundtrip(prs)).paragraphs[0].runs[0].font + assert f2.strike == MSO_TEXT_STRIKE_TYPE.DOUBLE + + +# ───────── C. highlight (SF3) ───────── + + +class DescribeHighlight: + def it_sets_highlight_rgb(self): + prs, tf = _frame() + f = _run(tf).font + f.highlight.rgb = RGBColor(0xFF, 0xFF, 0x00) + hl = f._rPr.find(f"{A}highlight") + assert hl is not None + assert hl.find(f"{A}srgbClr").get("val") == "FFFF00" + + def it_reads_none_without_mutating_when_absent(self): + prs, tf = _frame() + f = _run(tf).font + before = len(list(f._rPr)) + assert f.highlight.rgb is None + assert len(list(f._rPr)) == before # no mutation on read + + def it_orders_highlight_before_typeface_trio(self): + prs, tf = _frame() + f = _run(tf).font + f.name = "Arial" + f.highlight.rgb = RGBColor(0x00, 0xFF, 0x00) + kids = [c.tag for c in f._rPr] + assert kids.index(f"{A}highlight") < kids.index(f"{A}latin") + + def it_round_trips_highlight(self): + prs, tf = _frame() + _run(tf).font.highlight.rgb = RGBColor(0xFF, 0x00, 0xFF) + f2 = _first_tf(_roundtrip(prs)).paragraphs[0].runs[0].font + assert f2.highlight.rgb == RGBColor(0xFF, 0x00, 0xFF) + + +# ───────── D. character spacing + kerning (SF4) ───────── + + +class DescribeSpacingKerning: + def it_sets_character_spacing(self): + prs, tf = _frame() + f = _run(tf).font + f.character_spacing = Pt(2) + assert f._rPr.get("spc") == "200" + assert f.character_spacing.pt == 2.0 + + def it_supports_negative_character_spacing(self): + prs, tf = _frame() + f = _run(tf).font + f.character_spacing = Pt(-1) + assert int(f._rPr.get("spc")) < 0 + + def it_sets_kerning(self): + prs, tf = _frame() + f = _run(tf).font + f.kerning = Pt(12) + assert f._rPr.get("kern") == "1200" + + def it_round_trips_spacing_and_kerning(self): + prs, tf = _frame() + f = _run(tf).font + f.character_spacing = Pt(3) + f.kerning = Pt(8) + f2 = _first_tf(_roundtrip(prs)).paragraphs[0].runs[0].font + assert f2.character_spacing.pt == 3.0 + assert f2.kerning.pt == 8.0 + + +# ───────── E. latin/east_asian/complex_script (SF5) ───────── + + +class DescribeTypefaceTrio: + def it_sets_east_asian_without_touching_latin(self): + prs, tf = _frame() + f = _run(tf).font + f.east_asian = "MS Gothic" + assert f._rPr.find(f"{A}ea").get("typeface") == "MS Gothic" + assert f._rPr.find(f"{A}latin") is None # issue acceptance + + def it_sets_complex_script(self): + prs, tf = _frame() + f = _run(tf).font + f.complex_script = "Arial" + assert f._rPr.find(f"{A}cs").get("typeface") == "Arial" + + def it_keeps_font_name_latin_only_backward_compat(self): + prs, tf = _frame() + f = _run(tf).font + f.name = "Calibri" + assert f._rPr.find(f"{A}latin").get("typeface") == "Calibri" + assert f._rPr.find(f"{A}ea") is None + assert f._rPr.find(f"{A}cs") is None + assert f.name == "Calibri" + assert f.latin == "Calibri" + + def it_orders_latin_ea_cs(self): + prs, tf = _frame() + f = _run(tf).font + f.complex_script = "C" + f.east_asian = "E" + f.name = "L" + kids = [c.tag for c in f._rPr] + assert kids.index(f"{A}latin") < kids.index(f"{A}ea") < kids.index(f"{A}cs") + + def it_round_trips_the_trio(self): + prs, tf = _frame() + f = _run(tf).font + f.name, f.east_asian, f.complex_script = "Calibri", "MS Gothic", "Arial" + f2 = _first_tf(_roundtrip(prs)).paragraphs[0].runs[0].font + assert (f2.name, f2.east_asian, f2.complex_script) == ( + "Calibri", + "MS Gothic", + "Arial", + ) + + +# ───────── F. columns + spacing (SF6) ───────── + + +class DescribeColumns: + def it_defaults_to_one_column(self): + prs, tf = _frame() + assert tf.columns == 1 + + def it_sets_columns_and_spacing(self): + prs, tf = _frame() + tf.columns = 2 + tf.column_spacing = Pt(36) + assert tf._bodyPr.get("numCol") == "2" + assert tf.column_spacing.pt == 36.0 + + def it_rejects_out_of_range_columns(self): + prs, tf = _frame() + with pytest.raises(ValueError): + tf.columns = 0 + with pytest.raises(ValueError): + tf.columns = 17 + + def it_round_trips_columns(self): + prs, tf = _frame() + tf.columns = 3 + tf.column_spacing = Pt(24) + tf2 = _first_tf(_roundtrip(prs)) + assert tf2.columns == 3 + assert tf2.column_spacing.pt == 24.0 + + +# ───────── G. text_direction (SF7) ───────── + + +class DescribeTextDirection: + def it_sets_vertical(self): + prs, tf = _frame() + tf.text_direction = MSO_TEXT_DIRECTION.VERTICAL + assert tf._bodyPr.get("vert") == "vert" + + def it_sets_east_asian_vertical(self): + prs, tf = _frame() + tf.text_direction = MSO_TEXT_DIRECTION.EAST_ASIAN_VERTICAL + assert tf._bodyPr.get("vert") == "eaVert" + assert tf.text_direction == MSO_TEXT_DIRECTION.EAST_ASIAN_VERTICAL + + def it_removes_direction_on_none(self): + prs, tf = _frame() + tf.text_direction = MSO_TEXT_DIRECTION.VERTICAL + tf.text_direction = None + assert tf._bodyPr.get("vert") is None + + def it_round_trips_direction(self): + prs, tf = _frame() + tf.text_direction = MSO_TEXT_DIRECTION.VERTICAL_270 + assert _first_tf(_roundtrip(prs)).text_direction == MSO_TEXT_DIRECTION.VERTICAL_270 + + +# ───────── H. paragraph RTL (SF8) ───────── + + +class DescribeParagraphRtl: + def it_sets_rtl_true(self): + prs, tf = _frame() + p = tf.paragraphs[0] + p.text = "مرحبا" + p.rtl = True + assert p._p.find(f"{A}pPr").get("rtl") == "1" + assert p.rtl is True + + def it_sets_rtl_false(self): + prs, tf = _frame() + p = tf.paragraphs[0] + p.rtl = False + assert p._p.find(f"{A}pPr").get("rtl") == "0" + + def it_removes_rtl_on_none(self): + prs, tf = _frame() + p = tf.paragraphs[0] + p.rtl = True + p.rtl = None + assert p._p.find(f"{A}pPr").get("rtl") is None + + def it_round_trips_arabic_rtl_paragraph(self): + prs, tf = _frame() + p = tf.paragraphs[0] + p.text = "اللغة العربية" + p.rtl = True + p2 = _first_tf(_roundtrip(prs)).paragraphs[0] + assert p2.rtl is True + assert "العربية" in p2.text + + +# ───────── I. overflow detection (SF9) ───────── + + +class DescribeOverflowDetection: + def it_reports_no_overflow_for_empty_frame(self): + prs, tf = _frame() + assert tf.will_overflow(font_file=TEST_FONT) is False + + def it_reports_no_overflow_when_text_fits(self): + prs = Presentation() + s = prs.slides.add_slide(prs.slide_layouts[6]) + tf = s.shapes.add_textbox(Inches(1), Inches(1), Inches(8), Inches(5)).text_frame + tf.text = "short" + assert tf.will_overflow(font_file=TEST_FONT) is False + + def it_reports_overflow_for_oversized_content(self): + prs = Presentation() + s = prs.slides.add_slide(prs.slide_layouts[6]) + tf = s.shapes.add_textbox(Inches(0), Inches(0), Inches(1), Inches(0.3)).text_frame + tf.text = "Supercalifragilistic " * 12 + info = tf.overflow_info(font_file=TEST_FONT) + assert info.overflows is True + assert info.required_height > info.available_height + + @pytest.mark.parametrize("fr_h", [0.3, 0.4, 0.5]) + def it_detects_overflow_at_110pct_across_sizes(self, fr_h): + prs = Presentation() + s = prs.slides.add_slide(prs.slide_layouts[6]) + tf = s.shapes.add_textbox(Inches(0), Inches(0), Inches(2), Inches(fr_h)).text_frame + tf.text = "The quick brown fox jumps over the lazy dog. " * 6 + assert tf.will_overflow(font_file=TEST_FONT) is True + + def it_does_not_mutate_on_overflow_check(self): + prs = Presentation() + s = prs.slides.add_slide(prs.slide_layouts[6]) + tf = s.shapes.add_textbox(Inches(0), Inches(0), Inches(1), Inches(0.3)).text_frame + tf.text = "overflowing " * 20 + before = tf._txBody.xml + before_autosize = tf.auto_size + tf.will_overflow(font_file=TEST_FONT) + assert tf._txBody.xml == before # no XML mutation at all + assert tf.auto_size == before_autosize # autofit unchanged + + +# ───────── J. shrink_text_to_fit (SF10) ───────── + + +class DescribeShrinkTextToFit: + def it_sets_normautofit_with_reduced_scale(self): + prs = Presentation() + s = prs.slides.add_slide(prs.slide_layouts[6]) + tf = s.shapes.add_textbox(Inches(0), Inches(0), Inches(1), Inches(0.3)).text_frame + tf.text = "Supercalifragilistic " * 14 + for r in tf.paragraphs[0].runs: + r.font.size = Pt(18) + tf.shrink_text_to_fit(font_file=TEST_FONT) + from pptx.enum.text import MSO_AUTO_SIZE + + assert tf.auto_size == MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE + na = tf._txBody.bodyPr.normAutofit + assert na is not None + assert na.fontScale < 100 + + def it_does_not_rewrite_run_sizes(self): + prs = Presentation() + s = prs.slides.add_slide(prs.slide_layouts[6]) + tf = s.shapes.add_textbox(Inches(0), Inches(0), Inches(1), Inches(0.3)).text_frame + tf.text = "overflow " * 20 + r = tf.paragraphs[0].runs[0] + r.font.size = Pt(18) + tf.shrink_text_to_fit(font_file=TEST_FONT) + assert r.font.size == Pt(18) # eager scale, not size rewrite + + def it_is_a_noop_on_empty_frame(self): + prs, tf = _frame() + tf.shrink_text_to_fit(font_file=TEST_FONT) # must not raise + assert tf._txBody.bodyPr.normAutofit is None + + +# ───────── K. fit_text #168 crash fix (SF11) ───────── + + +class DescribeFitTextLongWordFix: + def it_does_not_crash_on_a_single_long_word(self): + # scanny/python-pptx#168 — previously raised TypeError + prs = Presentation() + s = prs.slides.add_slide(prs.slide_layouts[6]) + tf = s.shapes.add_textbox(Inches(0), Inches(0), Inches(0.5), Inches(0.5)).text_frame + tf.text = "Supercalifragilisticexpialidocious" + tf.fit_text(font_file=TEST_FONT) # no TypeError + assert tf.auto_size is not None + + def it_still_fits_normal_multiword_text(self): + prs = Presentation() + s = prs.slides.add_slide(prs.slide_layouts[6]) + tf = s.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(2)).text_frame + tf.text = "one two three four five" + tf.fit_text(font_file=TEST_FONT) + sz = tf.paragraphs[0].runs[0].font.size + assert sz is not None + assert sz.pt >= 1 + + def it_break_line_returns_usable_line_when_nothing_fits(self): + from pptx.text.layout import TextFitter, _LineSource + + ls = _LineSource("Supercalifragilisticexpialidocious") + fitter = TextFitter(ls, (Inches(0.2), Inches(0.2)), TEST_FONT) + result = fitter._break_line(ls, 18) + assert result is not None + assert result.text != "" + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__, "-q"]))