Skip to content

Commit ee53fd0

Browse files
authored
feat(text): Advanced Text, Auto-fit & Internationalization — issue #16 epic (#59)
Resolves all 11 sub-features of the issue #16 epic on one branch. Fork-state probe confirmed none existed here (unlike #18's arrowheads); this is genuinely all-new text-API surface. Maintainer UAT pending. New API: - Font.superscript / Font.subscript — a:rPr/@baseline (signed ST_Percentage fraction: +0.30 super, -0.25 sub; mutually exclusive). - Font.strike — MSO_TEXT_STRIKE_TYPE (NONE/SINGLE/DOUBLE -> noStrike/sngStrike/dblStrike), a:rPr/@strike. - Font.highlight — lazy _HighlightColor proxy over a:rPr/a:highlight (CT_Color); read-without-mutate, schema-ordered before the typeface trio (verified: [solidFill, highlight, latin, ea, cs]). - Font.character_spacing / Font.kerning — a:rPr/@spc (signed) and @kern (non-negative), centipoints like Font.size. - Font.latin / Font.east_asian / Font.complex_script — a:rPr a:latin/ a:ea/a:cs trio. Font.name STILL sets ONLY a:latin (backward compat, regression-tested). - TextFrame.columns / TextFrame.column_spacing — a:bodyPr @numcol (1..16, ValueError otherwise) / @spcCol (EMU). - TextFrame.text_direction — MSO_TEXT_DIRECTION enum, a:bodyPr/@Vert. - Paragraph.rtl — a:pPr/@rtl (Arabic/Hebrew/Persian; PowerPoint shapes). - TextFrame.will_overflow() / TextFrame.overflow_info() — read-only (does NOT mutate txBody or set autofit; ISC-68-verified) structured _OverflowInfo report via TextFitter. Closes scanny#1114. - TextFrame.shrink_text_to_fit() — eager normAutofit fontScale (thousandths form, e.g. "11111"); does not rewrite run sz. Adds CT_TextNormalAutofit/@lnSpcReduction. Closes scanny#1107. - fit_text long-word crash fix (scanny#168): _break_line now force-accepts the shortest candidate when no line fits a single word wider than the frame, instead of returning None and crashing _wrap_lines on a None unpack. New simpletypes (ST_TextPoint/ST_TextNonNegativePoint/ ST_TextColumnCount), enums (MSO_TEXT_STRIKE_TYPE, MSO_TEXT_DIRECTION), oxml registrations (a:highlight->CT_Color, a:ea/a:cs->CT_TextFont). All new a:rPr/a:bodyPr/a:pPr children/attrs are XSD-ordered (dml-main.xsd ground truth); autofit choice exclusivity verified. Tests: 47 new unit tests in tests/test_issue16_advanced_text.py (super/sub 5, strike 4, highlight 4, spacing 4, trio 5, columns 4, direction 4, rtl 4, overflow 5, shrink 3, fit168 3) + 11 behave scenarios. Per-attr save->reopen round-trip coverage included. Trinity (verbatim): python3 -m pytest tests/ -q -> 3972 passed (3925 + 47) ruff check src tests -> All checks passed! python3 -m behave features/ --no-color -> 1130 scenarios, 0 failed (1119 + 11) UAT: uat/uat_issue16_advanced_text.py runs clean, 15/15 round-trip checks, exit 0 (fixture includes the scanny#168 long unbreakable word and a latin+ea+cs+Arabic multi-script run per the UAT-fixture-diversity discipline). No-repair = directly observed: real PowerPoint loaded the deck as a named 4-slide presentation with zero modal sheets (no repair dialog) across multiple clean relaunches, no crash. Visual rendering of the slides is DEFERRED — PowerPoint's document window would not surface to screen/AX this session (an intermittent PowerPoint-side environment wall the #18 screenshot-probe documented; not a file defect). Visual + PowerPoint-resave preservation are the maintainer's §6a acceptance surface; not claimed here. Closes #16
1 parent 72830b1 commit ee53fd0

9 files changed

Lines changed: 1255 additions & 2 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
Feature: Issue #16 — advanced text, auto-fit & internationalization
2+
In order to author rich, international, well-fitted text
3+
As a developer using python-pptx-extended
4+
I need run typography, CJK/complex-script fonts, columns, vertical
5+
text, RTL paragraphs, overflow detection and crash-free auto-fit
6+
7+
Scenario: Author and read back superscript
8+
Given a blank slide text frame with one run
9+
When I set the run superscript
10+
Then the run reports superscript true
11+
12+
Scenario: Author and read back double strikethrough
13+
Given a blank slide text frame with one run
14+
When I set the run strike to double
15+
Then the run reports strike double after round-trip
16+
17+
Scenario: Author and read back a yellow highlight
18+
Given a blank slide text frame with one run
19+
When I set the run highlight to FFFF00
20+
Then the run reports highlight FFFF00 after round-trip
21+
22+
Scenario: Author and read back character spacing
23+
Given a blank slide text frame with one run
24+
When I set the run character spacing to 2 points
25+
Then the run reports character spacing 2 points
26+
27+
Scenario: East-Asian font set leaves Latin untouched
28+
Given a blank slide text frame with one run
29+
When I set east_asian to MS Gothic and name to Calibri
30+
Then latin is Calibri and east_asian is MS Gothic and they are independent
31+
32+
Scenario: Two-column text box
33+
Given a blank slide text frame with one run
34+
When I set the text frame to 2 columns spaced 36 points
35+
Then the text frame reports 2 columns after round-trip
36+
37+
Scenario: Vertical text direction
38+
Given a blank slide text frame with one run
39+
When I set the text direction to east asian vertical
40+
Then the text frame reports east asian vertical after round-trip
41+
42+
Scenario: Arabic right-to-left paragraph
43+
Given a blank slide text frame with one run
44+
When I set the paragraph to Arabic right-to-left
45+
Then the paragraph reports rtl true after round-trip
46+
47+
Scenario: Overflow detection flags oversized content
48+
Given a tiny text frame stuffed with text
49+
Then will_overflow reports true
50+
51+
Scenario: fit_text survives a single unbreakable long word
52+
Given a tiny text frame with one very long word
53+
When I call fit_text on it
54+
Then no error is raised and auto_size is set
55+
56+
Scenario: shrink_text_to_fit eagerly reduces the font scale
57+
Given a tiny text frame stuffed with text
58+
When I call shrink_text_to_fit
59+
Then the normAutofit fontScale is below 100

features/steps/iss16.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Step implementations for features/iss-16-advanced-text.feature (issue #16).
2+
3+
Self-contained: every scenario builds an in-memory blank presentation.
4+
"""
5+
6+
import io
7+
import os
8+
9+
from behave import given, then, when
10+
11+
from pptx import Presentation
12+
from pptx.dml.color import RGBColor
13+
from pptx.enum.text import MSO_AUTO_SIZE, MSO_TEXT_DIRECTION, MSO_TEXT_STRIKE_TYPE
14+
from pptx.util import Inches, Pt
15+
16+
TEST_FONT = os.path.join(
17+
os.path.dirname(os.path.dirname(__file__)), "..", "tests", "test_files", "calibriz.ttf"
18+
)
19+
TEST_FONT = os.path.abspath(TEST_FONT)
20+
21+
22+
def _blank_run(context):
23+
prs = Presentation()
24+
s = prs.slides.add_slide(prs.slide_layouts[6])
25+
tf = s.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(2)).text_frame
26+
r = tf.paragraphs[0].add_run()
27+
r.text = "Sample"
28+
context.prs = prs
29+
context.tf = tf
30+
context.run = r
31+
32+
33+
def _roundtrip(context):
34+
buf = io.BytesIO()
35+
context.prs.save(buf)
36+
buf.seek(0)
37+
context.prs2 = Presentation(buf)
38+
context.tf2 = list(context.prs2.slides[0].shapes)[0].text_frame
39+
return context.tf2
40+
41+
42+
@given("a blank slide text frame with one run")
43+
def given_blank_run(context):
44+
_blank_run(context)
45+
46+
47+
@given("a tiny text frame stuffed with text")
48+
def given_tiny_stuffed(context):
49+
prs = Presentation()
50+
s = prs.slides.add_slide(prs.slide_layouts[6])
51+
tf = s.shapes.add_textbox(Inches(0), Inches(0), Inches(1), Inches(0.3)).text_frame
52+
tf.text = "Supercalifragilistic " * 14
53+
for r in tf.paragraphs[0].runs:
54+
r.font.size = Pt(18)
55+
context.prs = prs
56+
context.tf = tf
57+
58+
59+
@given("a tiny text frame with one very long word")
60+
def given_tiny_longword(context):
61+
prs = Presentation()
62+
s = prs.slides.add_slide(prs.slide_layouts[6])
63+
tf = s.shapes.add_textbox(Inches(0), Inches(0), Inches(0.5), Inches(0.5)).text_frame
64+
tf.text = "Supercalifragilisticexpialidocious"
65+
context.prs = prs
66+
context.tf = tf
67+
68+
69+
@when("I set the run superscript")
70+
def when_superscript(context):
71+
context.run.font.superscript = True
72+
73+
74+
@then("the run reports superscript true")
75+
def then_superscript(context):
76+
assert context.run.font.superscript is True
77+
78+
79+
@when("I set the run strike to double")
80+
def when_strike_double(context):
81+
context.run.font.strike = MSO_TEXT_STRIKE_TYPE.DOUBLE
82+
83+
84+
@then("the run reports strike double after round-trip")
85+
def then_strike_double(context):
86+
f2 = _roundtrip(context).paragraphs[0].runs[0].font
87+
assert f2.strike == MSO_TEXT_STRIKE_TYPE.DOUBLE
88+
89+
90+
@when("I set the run highlight to FFFF00")
91+
def when_highlight(context):
92+
context.run.font.highlight.rgb = RGBColor(0xFF, 0xFF, 0x00)
93+
94+
95+
@then("the run reports highlight FFFF00 after round-trip")
96+
def then_highlight(context):
97+
f2 = _roundtrip(context).paragraphs[0].runs[0].font
98+
assert f2.highlight.rgb == RGBColor(0xFF, 0xFF, 0x00)
99+
100+
101+
@when("I set the run character spacing to 2 points")
102+
def when_spacing(context):
103+
context.run.font.character_spacing = Pt(2)
104+
105+
106+
@then("the run reports character spacing 2 points")
107+
def then_spacing(context):
108+
assert context.run.font.character_spacing.pt == 2.0
109+
110+
111+
@when("I set east_asian to MS Gothic and name to Calibri")
112+
def when_trio(context):
113+
context.run.font.east_asian = "MS Gothic"
114+
context.run.font.name = "Calibri"
115+
116+
117+
@then("latin is Calibri and east_asian is MS Gothic and they are independent")
118+
def then_trio(context):
119+
f2 = _roundtrip(context).paragraphs[0].runs[0].font
120+
assert f2.name == "Calibri" and f2.east_asian == "MS Gothic"
121+
assert f2.latin == "Calibri"
122+
123+
124+
@when("I set the text frame to 2 columns spaced 36 points")
125+
def when_columns(context):
126+
context.tf.columns = 2
127+
context.tf.column_spacing = Pt(36)
128+
129+
130+
@then("the text frame reports 2 columns after round-trip")
131+
def then_columns(context):
132+
assert _roundtrip(context).columns == 2
133+
134+
135+
@when("I set the text direction to east asian vertical")
136+
def when_direction(context):
137+
context.tf.text_direction = MSO_TEXT_DIRECTION.EAST_ASIAN_VERTICAL
138+
139+
140+
@then("the text frame reports east asian vertical after round-trip")
141+
def then_direction(context):
142+
assert _roundtrip(context).text_direction == MSO_TEXT_DIRECTION.EAST_ASIAN_VERTICAL
143+
144+
145+
@when("I set the paragraph to Arabic right-to-left")
146+
def when_rtl(context):
147+
p = context.tf.paragraphs[0]
148+
p.text = "اللغة العربية"
149+
p.rtl = True
150+
151+
152+
@then("the paragraph reports rtl true after round-trip")
153+
def then_rtl(context):
154+
p2 = _roundtrip(context).paragraphs[0]
155+
assert p2.rtl is True
156+
157+
158+
@then("will_overflow reports true")
159+
def then_will_overflow(context):
160+
assert context.tf.will_overflow(font_file=TEST_FONT) is True
161+
162+
163+
@when("I call fit_text on it")
164+
def when_fit_text(context):
165+
context.tf.fit_text(font_file=TEST_FONT)
166+
167+
168+
@then("no error is raised and auto_size is set")
169+
def then_fit_ok(context):
170+
assert context.tf.auto_size is not None
171+
172+
173+
@when("I call shrink_text_to_fit")
174+
def when_shrink(context):
175+
context.tf.shrink_text_to_fit(font_file=TEST_FONT)
176+
177+
178+
@then("the normAutofit fontScale is below 100")
179+
def then_shrink(context):
180+
na = context.tf._txBody.bodyPr.normAutofit
181+
assert na is not None and na.fontScale < 100
182+
assert context.tf.auto_size == MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE

src/pptx/enum/text.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,51 @@ class PP_AUTO_NUMBER_STYLE(BaseXmlEnum):
299299

300300
ALPHA_LC_PAREN_BOTH = (12, "alphaLcParenBoth", "Lowercase letters in parentheses: (a) (b) (c)")
301301
"""Lowercase letters in parentheses: (a) (b) (c)"""
302+
303+
304+
class MSO_TEXT_STRIKE_TYPE(BaseXmlEnum):
305+
"""Specifies the strikethrough style of text.
306+
307+
Used with :attr:`.Font.strike`. Maps to the OOXML `a:rPr/@strike`
308+
attribute (`ST_TextStrikeType`). Issue #16 SF2.
309+
310+
MS API Name: (no direct VBA equivalent — modeled on `MsoTextStrike`).
311+
"""
312+
313+
NONE = (0, "noStrike", "No strikethrough.")
314+
"""No strikethrough."""
315+
316+
SINGLE = (1, "sngStrike", "A single-line strikethrough.")
317+
"""A single-line strikethrough."""
318+
319+
DOUBLE = (2, "dblStrike", "A double-line strikethrough.")
320+
"""A double-line strikethrough."""
321+
322+
323+
class MSO_TEXT_DIRECTION(BaseXmlEnum):
324+
"""Specifies the flow direction of text in a text frame.
325+
326+
Used with :attr:`.TextFrame.text_direction`. Maps to the OOXML
327+
`a:bodyPr/@vert` attribute (`ST_TextVerticalType`). Issue #16 SF7.
328+
"""
329+
330+
HORIZONTAL = (0, "horz", "Horizontal text (the default).")
331+
"""Horizontal text (the default)."""
332+
333+
VERTICAL = (1, "vert", "Vertical text, rotated 90° clockwise.")
334+
"""Vertical text, rotated 90° clockwise."""
335+
336+
VERTICAL_270 = (2, "vert270", "Vertical text, rotated 270° clockwise.")
337+
"""Vertical text, rotated 270° clockwise."""
338+
339+
WORD_ART_VERTICAL = (3, "wordArtVert", "WordArt-style stacked vertical text.")
340+
"""WordArt-style stacked vertical text."""
341+
342+
EAST_ASIAN_VERTICAL = (4, "eaVert", "East-Asian vertical text.")
343+
"""East-Asian vertical text."""
344+
345+
MONGOLIAN_VERTICAL = (5, "mongolianVert", "Mongolian vertical text.")
346+
"""Mongolian vertical text."""
347+
348+
WORD_ART_VERTICAL_RTL = (6, "wordArtVertRtl", "Right-to-left WordArt vertical text.")
349+
"""Right-to-left WordArt vertical text."""

src/pptx/oxml/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
291291
register_element_cls("a:alphaOff", CT_PositiveFixedPercentage)
292292
register_element_cls("a:bgClr", CT_Color)
293293
register_element_cls("a:fgClr", CT_Color)
294+
register_element_cls("a:highlight", CT_Color)
294295
register_element_cls("a:hslClr", CT_HslColor)
295296
register_element_cls("a:lumMod", CT_Percentage)
296297
register_element_cls("a:lumOff", CT_Percentage)
@@ -583,6 +584,8 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
583584
register_element_cls("a:endParaRPr", CT_TextCharacterProperties)
584585
register_element_cls("a:fld", CT_TextField)
585586
register_element_cls("a:latin", CT_TextFont)
587+
register_element_cls("a:ea", CT_TextFont)
588+
register_element_cls("a:cs", CT_TextFont)
586589
register_element_cls("a:lnSpc", CT_TextSpacing)
587590
register_element_cls("a:normAutofit", CT_TextNormalAutofit)
588591
register_element_cls("a:r", CT_RegularTextRun)

src/pptx/oxml/simpletypes.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,37 @@ def validate(cls, value):
669669
cls.validate_int_in_range(value, 100, 400000)
670670

671671

672+
class ST_TextPoint(BaseIntType):
673+
"""Signed text point measure in 1/100 pt, e.g. character spacing `spc`.
674+
675+
OOXML ST_TextPointUnqualified is `xsd:int` restricted to
676+
-400000..400000 (-4000..4000 pt). Negative values tighten spacing.
677+
"""
678+
679+
@classmethod
680+
def validate(cls, value):
681+
cls.validate_int_in_range(value, -400000, 400000)
682+
683+
684+
class ST_TextNonNegativePoint(BaseIntType):
685+
"""Non-negative text point measure in 1/100 pt, e.g. kerning `kern`.
686+
687+
OOXML ST_TextNonNegativePoint restricts to 0..400000.
688+
"""
689+
690+
@classmethod
691+
def validate(cls, value):
692+
cls.validate_int_in_range(value, 0, 400000)
693+
694+
695+
class ST_TextColumnCount(BaseIntType):
696+
"""Text column count, 1..16 inclusive (OOXML ST_TextColumnCount)."""
697+
698+
@classmethod
699+
def validate(cls, value):
700+
cls.validate_int_in_range(value, 1, 16)
701+
702+
672703
class ST_TextIndentLevelType(BaseIntType):
673704
@classmethod
674705
def validate(cls, value):

0 commit comments

Comments
 (0)