Skip to content

Commit c17466f

Browse files
committed
Render inline math as real subscripts/superscripts in pptx (slide-deck-rules §12)
The exporter flattened math to ASCII ("za", not z-subscript-a). Add inline math rendering: authoring marks math with $...$; inside it `_x` / `_{xy}` become a real subscript and `^x` / `^{xy}` a superscript (via the run's OOXML `baseline` attribute, since python-pptx has no Font.subscript), single-letter tokens (variables z / λ / I) are italicised while multi-letter operators (min / log) stay upright. Plain `_` outside $...$ (file names, prose) is left untouched. - _render_math_paragraph + helpers; every run sets an explicit colour (dark-mode contract — a None-coloured run renders black on the dark slide). - Wired into _add_bullet_box, where math most often appears. A plain bullet still renders as one run exactly as before, so existing decks are unchanged. - 6 unit tests (subscript / superscript / braced / italic-variable-vs-upright- operator / plain-text / bullet integration). 598 tests pass; ruff + bandit clean.
1 parent c40b876 commit c17466f

2 files changed

Lines changed: 220 additions & 11 deletions

File tree

tests/test_exporters.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,3 +1389,111 @@ def test_export_unknown_format_raises(sample_papers, tmp_path):
13891389
)
13901390
with pytest.raises(ExportError):
13911391
export_collection(collection, options)
1392+
1393+
1394+
# ---------------------------------------------------------------------------
1395+
# Inline math rendering ($...$ -> real subscripts / superscripts + italic vars)
1396+
# ---------------------------------------------------------------------------
1397+
1398+
1399+
def _new_paragraph():
1400+
from pptx import Presentation
1401+
from pptx.util import Inches
1402+
1403+
prs = Presentation()
1404+
slide = prs.slides.add_slide(prs.slide_layouts[6])
1405+
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(8), Inches(1))
1406+
return box.text_frame.paragraphs[0]
1407+
1408+
1409+
def _baseline(run):
1410+
rpr = run._r.rPr # noqa: SLF001
1411+
return rpr.get("baseline") if rpr is not None else None
1412+
1413+
1414+
def _near_white():
1415+
from pptx.dml.color import RGBColor
1416+
1417+
return RGBColor(0xE5, 0xE7, 0xEB)
1418+
1419+
1420+
def test_render_math_subscript_and_italic_variable():
1421+
from thesisagents.exporters import pptx as pptx_mod
1422+
1423+
para = _new_paragraph()
1424+
pptx_mod._render_math_paragraph( # noqa: SLF001
1425+
para, "$z_a$", size_pt=18, colour=_near_white()
1426+
)
1427+
runs = para.runs
1428+
assert [r.text for r in runs] == ["z", "a"]
1429+
assert runs[0].font.italic is True # single-letter variable z
1430+
assert _baseline(runs[0]) is None # base char, normal baseline
1431+
assert _baseline(runs[1]) == "-25000" # a rendered as subscript
1432+
# Dark-mode contract: every run carries an explicit colour.
1433+
assert all(r.font.color.rgb == _near_white() for r in runs)
1434+
1435+
1436+
def test_render_math_superscript_and_multiletter_operator_upright():
1437+
from thesisagents.exporters import pptx as pptx_mod
1438+
1439+
para = _new_paragraph()
1440+
pptx_mod._render_math_paragraph( # noqa: SLF001
1441+
para, "$min x^2$", size_pt=18, colour=_near_white()
1442+
)
1443+
by_text = {r.text: r for r in para.runs}
1444+
assert by_text["min"].font.italic is False # multi-letter operator upright
1445+
assert by_text["x"].font.italic is True # single-letter variable italic
1446+
assert _baseline(by_text["2"]) == "30000" # superscript
1447+
1448+
1449+
def test_render_math_braced_subscript():
1450+
from thesisagents.exporters import pptx as pptx_mod
1451+
1452+
para = _new_paragraph()
1453+
pptx_mod._render_math_paragraph( # noqa: SLF001
1454+
para, "$z_{adv}$", size_pt=18, colour=_near_white()
1455+
)
1456+
sub = next(r for r in para.runs if r.text == "adv")
1457+
assert _baseline(sub) == "-25000" # multi-char braced subscript
1458+
1459+
1460+
def test_render_math_plain_text_is_one_normal_run():
1461+
from thesisagents.exporters import pptx as pptx_mod
1462+
1463+
para = _new_paragraph()
1464+
pptx_mod._render_math_paragraph( # noqa: SLF001
1465+
para, "plain text, no math here", size_pt=18, colour=_near_white()
1466+
)
1467+
assert len(para.runs) == 1
1468+
assert para.runs[0].text == "plain text, no math here"
1469+
assert _baseline(para.runs[0]) is None
1470+
1471+
1472+
def test_render_math_mixed_prose_and_span():
1473+
from thesisagents.exporters import pptx as pptx_mod
1474+
1475+
para = _new_paragraph()
1476+
pptx_mod._render_math_paragraph( # noqa: SLF001
1477+
para, "loss $z_a$ over batch", size_pt=18, colour=_near_white()
1478+
)
1479+
assert para.runs[0].text == "loss " # prose before the span
1480+
assert para.runs[-1].text == " over batch" # prose after the span
1481+
assert any(_baseline(r) == "-25000" for r in para.runs) # subscript inside
1482+
1483+
1484+
def test_bullet_box_renders_math_subscript():
1485+
from pptx import Presentation
1486+
from pptx.util import Inches
1487+
1488+
from thesisagents.exporters import pptx as pptx_mod
1489+
1490+
prs = Presentation()
1491+
slide = prs.slides.add_slide(prs.slide_layouts[6])
1492+
pptx_mod._add_bullet_box( # noqa: SLF001
1493+
slide, name="body", bullets=["uses $z_a$ latent"],
1494+
left=Inches(1), top=Inches(1), width=Inches(8), height=Inches(2),
1495+
font_pt=16,
1496+
)
1497+
para = slide.shapes[0].text_frame.paragraphs[0]
1498+
assert any(_baseline(r) == "-25000" for r in para.runs) # subscript rendered
1499+
assert all(r.font.color.rgb is not None for r in para.runs) # dark-mode contract

thesisagents/exporters/pptx.py

Lines changed: 112 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,6 +1308,112 @@ def _add_horizontal_rule(slide, *, top) -> None:
13081308
line.line.width = Pt(0.75)
13091309

13101310

1311+
# ---------------------------------------------------------------------------
1312+
# Inline math rendering. Authoring marks math with $...$; inside it, `_x` /
1313+
# `_{xy}` render as a real subscript and `^x` / `^{xy}` as a superscript (via
1314+
# the run's OOXML `baseline` attribute, since python-pptx has no Font.subscript),
1315+
# and a single-letter token (a variable like z / λ / I) is italicised while a
1316+
# multi-letter word (an operator like min / log / softmax) stays upright. See
1317+
# slide-deck-rules §12. Plain `_` outside `$...$` (file names, prose) is left
1318+
# alone — only $-delimited spans are parsed.
1319+
# ---------------------------------------------------------------------------
1320+
_SUBSCRIPT_BASELINE = -25000
1321+
_SUPERSCRIPT_BASELINE = 30000
1322+
_MATH_DELIM = re.compile(r"\$([^$]+)\$")
1323+
1324+
1325+
def _set_run_baseline(run, baseline: int) -> None:
1326+
"""Shift a run's baseline (1/1000 of a percent): negative = subscript,
1327+
positive = superscript. python-pptx exposes no Font.subscript, so set the
1328+
OOXML attribute on the run's character properties directly."""
1329+
run._r.get_or_add_rPr().set("baseline", str(baseline)) # noqa: SLF001
1330+
1331+
1332+
def _add_math_run(
1333+
paragraph, text: str, *, size_pt: int, colour: RGBColor,
1334+
bold: bool, italic: bool = False, baseline: int = 0,
1335+
):
1336+
"""Append one styled run. Always sets an explicit colour (dark-mode
1337+
contract — a run with ``color.rgb = None`` renders black on the dark slide
1338+
and can't be swapped by the post-pass)."""
1339+
run = paragraph.add_run()
1340+
run.text = text
1341+
run.font.size = Pt(size_pt)
1342+
run.font.bold = bold
1343+
run.font.italic = italic
1344+
run.font.color.rgb = colour
1345+
if baseline:
1346+
_set_run_baseline(run, baseline)
1347+
return run
1348+
1349+
1350+
def _math_tokens(span: str):
1351+
"""Tokenise an inline-math string into (kind, text): ``sub`` / ``sup`` for
1352+
``_x`` / ``_{xy}`` / ``^x`` / ``^{xy}``, ``word`` for a letter run, ``char``
1353+
for anything else."""
1354+
i, n = 0, len(span)
1355+
while i < n:
1356+
c = span[i]
1357+
if c in "_^" and i + 1 < n:
1358+
j = i + 1
1359+
if span[j] == "{":
1360+
end = span.find("}", j)
1361+
if end == -1:
1362+
content, i = span[j + 1:], n
1363+
else:
1364+
content, i = span[j + 1:end], end + 1
1365+
else:
1366+
content, i = span[j], j + 1
1367+
yield ("sub" if c == "_" else "sup"), content
1368+
elif c.isalpha():
1369+
j = i
1370+
while j < n and span[j].isalpha():
1371+
j += 1
1372+
yield "word", span[i:j]
1373+
i = j
1374+
else:
1375+
yield "char", c
1376+
i += 1
1377+
1378+
1379+
def _render_math_span(paragraph, span: str, *, size_pt: int, colour: RGBColor, bold: bool) -> None:
1380+
"""Render one ``$...$`` inner string with real sub/superscripts and italic
1381+
single-letter variables (multi-letter operators like ``min`` stay upright)."""
1382+
common = {"size_pt": size_pt, "colour": colour, "bold": bold}
1383+
for kind, text in _math_tokens(span):
1384+
if kind == "sub":
1385+
_add_math_run(paragraph, text, baseline=_SUBSCRIPT_BASELINE, **common)
1386+
elif kind == "sup":
1387+
_add_math_run(paragraph, text, baseline=_SUPERSCRIPT_BASELINE, **common)
1388+
elif kind == "word":
1389+
_add_math_run(paragraph, text, italic=(len(text) == 1), **common)
1390+
else:
1391+
_add_math_run(paragraph, text, **common)
1392+
1393+
1394+
def _render_math_paragraph(
1395+
paragraph, text: str, *, size_pt: int, colour: RGBColor, bold: bool = False,
1396+
) -> None:
1397+
"""Fill ``paragraph`` with runs, rendering ``$...$`` spans as math. Plain
1398+
text outside ``$...$`` becomes one run. Use instead of ``paragraph.text =
1399+
...`` wherever a string may contain math notation.
1400+
1401+
Example: ``_render_math_paragraph(p, "loss $I(z_a;z_b)$", ...)`` yields
1402+
"loss " + I(italic) + "(" + z(italic) + a(subscript) + ";" + … .
1403+
"""
1404+
paragraph.clear()
1405+
pos = 0
1406+
for m in _MATH_DELIM.finditer(text):
1407+
if m.start() > pos:
1408+
_add_math_run(paragraph, text[pos:m.start()], size_pt=size_pt, colour=colour, bold=bold)
1409+
_render_math_span(paragraph, m.group(1), size_pt=size_pt, colour=colour, bold=bold)
1410+
pos = m.end()
1411+
if pos < len(text):
1412+
_add_math_run(paragraph, text[pos:], size_pt=size_pt, colour=colour, bold=bold)
1413+
if not paragraph.runs:
1414+
_add_math_run(paragraph, "", size_pt=size_pt, colour=colour, bold=bold)
1415+
1416+
13111417
def _add_textbox(
13121418
slide, *, name: str, text: str, left, top, width, height,
13131419
font_pt: int, bold: bool = False, colour: RGBColor | None = None,
@@ -1352,18 +1458,13 @@ def _add_bullet_box(
13521458
return
13531459
for index, bullet in enumerate(bullets):
13541460
paragraph = text_frame.paragraphs[0] if index == 0 else text_frame.add_paragraph()
1355-
paragraph.text = f"• {bullet}"
13561461
paragraph.alignment = PP_ALIGN.LEFT
1357-
for run in paragraph.runs:
1358-
run.font.size = Pt(font_pt)
1359-
# ALWAYS set the run colour explicitly. A run with
1360-
# ``font.color.rgb = None`` inherits the theme's body-text
1361-
# colour (which renders as black) and the dark-mode
1362-
# post-pass cannot swap it because there's no source RGB
1363-
# to look up in the mapping. See deck-design.md
1364-
# "Dark-mode contract" — every text-adding helper sets a
1365-
# palette colour, no exceptions.
1366-
run.font.color.rgb = _BRAND_DARK
1462+
# Render via _render_math_paragraph so $...$ spans become real
1463+
# subscripts / superscripts + italic variables; a plain bullet is one
1464+
# run. It sets an explicit _BRAND_DARK colour on every run (dark-mode
1465+
# contract — a None-coloured run renders black on the dark slide and the
1466+
# post-pass can't swap it). See deck-design.md "Dark-mode contract".
1467+
_render_math_paragraph(paragraph, f"• {bullet}", size_pt=font_pt, colour=_BRAND_DARK)
13671468

13681469

13691470
def _add_footer(slide, text: str) -> None:

0 commit comments

Comments
 (0)