pptd-utils 2026.10.2.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pptd_utils/README.md +95 -0
- pptd_utils/__init__.py +10 -0
- pptd_utils/__main__.py +5 -0
- pptd_utils/_shape_data.py +134 -0
- pptd_utils/anim.py +367 -0
- pptd_utils/charts_native.py +847 -0
- pptd_utils/charts_raster.py +1019 -0
- pptd_utils/fonts.py +130 -0
- pptd_utils/icons.py +135 -0
- pptd_utils/images.py +105 -0
- pptd_utils/lines.py +180 -0
- pptd_utils/main.py +285 -0
- pptd_utils/render_text.py +241 -0
- pptd_utils/rich.py +213 -0
- pptd_utils/shapes.py +334 -0
- pptd_utils/slides_image.py +128 -0
- pptd_utils/style.py +253 -0
- pptd_utils/tables.py +241 -0
- pptd_utils/xmlutil.py +109 -0
- pptd_utils-2026.10.2.dev3.dist-info/METADATA +120 -0
- pptd_utils-2026.10.2.dev3.dist-info/RECORD +23 -0
- pptd_utils-2026.10.2.dev3.dist-info/WHEEL +4 -0
- pptd_utils-2026.10.2.dev3.dist-info/entry_points.txt +3 -0
pptd_utils/main.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Orchestration: load .pptd project, render pages, verify, CLI."""
|
|
2
|
+
import argparse
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from lxml import etree
|
|
10
|
+
from pptx import Presentation
|
|
11
|
+
from pptx.oxml.ns import qn
|
|
12
|
+
from pptx.util import Pt
|
|
13
|
+
|
|
14
|
+
from . import charts_native, charts_raster
|
|
15
|
+
from .anim import build_timing
|
|
16
|
+
from .fonts import embed_fonts, fetch_custom_fonts
|
|
17
|
+
from .images import render_image
|
|
18
|
+
from .icons import render_icon
|
|
19
|
+
from .lines import render_line
|
|
20
|
+
from .render_text import render_text
|
|
21
|
+
from .shapes import render_shape
|
|
22
|
+
from .style import fill_xml
|
|
23
|
+
from .tables import render_table
|
|
24
|
+
from .xmlutil import sub
|
|
25
|
+
|
|
26
|
+
RENDERERS = ("text", "shape", "line", "image", "icon", "table", "chart")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_yaml(path):
|
|
30
|
+
return yaml.safe_load(Path(path).read_text())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _set_background(slide, bg, theme, root, prs):
|
|
34
|
+
if not bg:
|
|
35
|
+
bg = {"type": "solid", "color": "#FFFFFF"}
|
|
36
|
+
cSld = slide._element.find(qn("p:cSld"))
|
|
37
|
+
for e in cSld.findall(qn("p:bg")):
|
|
38
|
+
cSld.remove(e)
|
|
39
|
+
bgel = etree.Element(qn("p:bg"))
|
|
40
|
+
cSld.insert(0, bgel)
|
|
41
|
+
bgPr = sub(bgel, "p:bgPr")
|
|
42
|
+
|
|
43
|
+
def ipf(path):
|
|
44
|
+
path = Path(path)
|
|
45
|
+
if not path.is_absolute() and not path.exists():
|
|
46
|
+
cand = root / path
|
|
47
|
+
if cand.exists():
|
|
48
|
+
path = cand
|
|
49
|
+
part, rid = slide.part.get_or_add_image_part(str(path))
|
|
50
|
+
return rid
|
|
51
|
+
fill_xml(bgPr, bg, theme, image_part_fn=ipf)
|
|
52
|
+
if bg.get("type") == "image":
|
|
53
|
+
src = bg.get("src")
|
|
54
|
+
from .style import fetch_url, cover_src_rect
|
|
55
|
+
path = fetch_url(src) if str(src).startswith("http") else (
|
|
56
|
+
root / src if not Path(src).exists() else Path(src))
|
|
57
|
+
bf = bgPr.find(qn("a:blipFill"))
|
|
58
|
+
if bf is not None and bf.find(qn("a:srcRect")) is None:
|
|
59
|
+
rect = cover_src_rect(path, prs.slide_width / 12700,
|
|
60
|
+
prs.slide_height / 12700)
|
|
61
|
+
if rect:
|
|
62
|
+
st = bf.find(qn("a:stretch"))
|
|
63
|
+
sr = sub(bf, "a:srcRect", rect)
|
|
64
|
+
if st is not None:
|
|
65
|
+
st.addprevious(sr)
|
|
66
|
+
sub(bgPr, "a:effectLst")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _add_fade(slide, speed="fast"):
|
|
70
|
+
sld = slide._element
|
|
71
|
+
for e in sld.findall(qn("p:transition")):
|
|
72
|
+
sld.remove(e)
|
|
73
|
+
tr = etree.Element(qn("p:transition"), {"spd": speed, "advClick": "1"})
|
|
74
|
+
tr.append(etree.Element(qn("p:fade")))
|
|
75
|
+
clr = sld.find(qn("p:clrMapOvr"))
|
|
76
|
+
if clr is not None:
|
|
77
|
+
clr.addnext(tr)
|
|
78
|
+
else:
|
|
79
|
+
sld.find(qn("p:cSld")).addnext(tr)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _render_element(slide, el, theme, root):
|
|
83
|
+
typ = el.get("elementType")
|
|
84
|
+
if typ == "text":
|
|
85
|
+
return render_text(slide, el, theme)
|
|
86
|
+
if typ == "shape":
|
|
87
|
+
return render_shape(slide, el, theme)
|
|
88
|
+
if typ == "line":
|
|
89
|
+
return render_line(slide, el, theme)
|
|
90
|
+
if typ == "image":
|
|
91
|
+
return render_image(slide, el, theme, root)
|
|
92
|
+
if typ == "icon":
|
|
93
|
+
return render_icon(slide, el, theme)
|
|
94
|
+
if typ == "table":
|
|
95
|
+
return render_table(slide, el, theme, root)
|
|
96
|
+
if typ == "chart":
|
|
97
|
+
if charts_native.can_render_native(el):
|
|
98
|
+
return charts_native.render_chart(slide, el, theme)
|
|
99
|
+
return charts_raster.render_chart(slide, el, theme, root)
|
|
100
|
+
raise ValueError(f"unsupported elementType: {typ}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build(pptd_path, out_path):
|
|
104
|
+
pptd_path = Path(pptd_path)
|
|
105
|
+
out_path = Path(out_path)
|
|
106
|
+
root = pptd_path.parent
|
|
107
|
+
deck = _load_yaml(pptd_path)
|
|
108
|
+
if str(deck.get("version")) != "v2":
|
|
109
|
+
raise ValueError("only PPTD v2 supported")
|
|
110
|
+
theme = deck.get("theme") or {}
|
|
111
|
+
prs = Presentation()
|
|
112
|
+
prs.slide_width = Pt(deck["size"][0])
|
|
113
|
+
prs.slide_height = Pt(deck["size"][1])
|
|
114
|
+
if deck.get("title"):
|
|
115
|
+
prs.core_properties.title = deck["title"]
|
|
116
|
+
|
|
117
|
+
for page_rel in deck["pages"]:
|
|
118
|
+
page = _load_yaml(root / page_rel)
|
|
119
|
+
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
120
|
+
_set_background(slide, page.get("background"), theme, root, prs)
|
|
121
|
+
shape_ids = {}
|
|
122
|
+
for el in page.get("elements", []):
|
|
123
|
+
shape = _render_element(slide, el, theme, root)
|
|
124
|
+
if shape is not None:
|
|
125
|
+
shape_ids[el.get("elementId")] = (
|
|
126
|
+
shape.shape_id if hasattr(shape, "shape_id") else None)
|
|
127
|
+
if page.get("notes"):
|
|
128
|
+
slide.notes_slide.notes_text_frame.text = str(page["notes"])
|
|
129
|
+
_add_fade(slide)
|
|
130
|
+
if page.get("animations"):
|
|
131
|
+
build_timing(slide, page["animations"], shape_ids,
|
|
132
|
+
prs.slide_width / 12700, prs.slide_height / 12700)
|
|
133
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
prs.save(out_path)
|
|
135
|
+
|
|
136
|
+
n_fonts = 0
|
|
137
|
+
if deck.get("customFonts"):
|
|
138
|
+
fonts = fetch_custom_fonts(deck["customFonts"])
|
|
139
|
+
n_fonts = embed_fonts(out_path, fonts)
|
|
140
|
+
return len(deck["pages"]), n_fonts
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def verify(pptd_path, out_path):
|
|
144
|
+
"""Runnable self-check: 1:1 structural round-trip."""
|
|
145
|
+
pptd_path = Path(pptd_path)
|
|
146
|
+
root = pptd_path.parent
|
|
147
|
+
deck = _load_yaml(pptd_path)
|
|
148
|
+
pages = [_load_yaml(root / p) for p in deck["pages"]]
|
|
149
|
+
prs = Presentation(out_path)
|
|
150
|
+
assert len(prs.slides) == len(pages), "slide count mismatch"
|
|
151
|
+
n_charts = n_tables = n_media = 0
|
|
152
|
+
for i, (page, slide) in enumerate(zip(pages, prs.slides), 1):
|
|
153
|
+
els = page.get("elements", [])
|
|
154
|
+
shapes = slide.shapes
|
|
155
|
+
assert len(shapes) == len(els), (
|
|
156
|
+
f"slide {i}: {len(shapes)} shapes != {len(els)} elements")
|
|
157
|
+
tr = slide._element.findall(qn("p:transition"))
|
|
158
|
+
assert tr and tr[0].find(qn("p:fade")) is not None, \
|
|
159
|
+
f"slide {i}: no fade transition"
|
|
160
|
+
if page.get("notes"):
|
|
161
|
+
assert slide.has_notes_slide and \
|
|
162
|
+
slide.notes_slide.notes_text_frame.text.strip() == \
|
|
163
|
+
str(page["notes"]).strip(), f"slide {i}: notes mismatch"
|
|
164
|
+
anims = page.get("animations")
|
|
165
|
+
timing = slide._element.findall(qn("p:timing"))
|
|
166
|
+
if anims:
|
|
167
|
+
assert timing, f"slide {i}: animations missing timing XML"
|
|
168
|
+
for el in els:
|
|
169
|
+
if el.get("elementType") == "chart":
|
|
170
|
+
n_charts += 1
|
|
171
|
+
if el.get("elementType") == "table":
|
|
172
|
+
n_tables += 1
|
|
173
|
+
n_media += sum(1 for s in shapes if s.shape_type == 13)
|
|
174
|
+
with zipfile.ZipFile(out_path) as z:
|
|
175
|
+
assert z.testzip() is None, "zip integrity failure"
|
|
176
|
+
charts = [n for n in z.namelist() if "charts/chart" in n]
|
|
177
|
+
fonts = [n for n in z.namelist() if n.endswith(".fntdata")]
|
|
178
|
+
native_charts = len(charts)
|
|
179
|
+
raster_charts = sum(
|
|
180
|
+
1 for page in pages for el in page.get("elements", [])
|
|
181
|
+
if el.get("elementType") == "chart" and
|
|
182
|
+
not charts_native.can_render_native(el))
|
|
183
|
+
assert native_charts + raster_charts == n_charts, (
|
|
184
|
+
f"chart mismatch: native {native_charts} + raster {raster_charts} "
|
|
185
|
+
f"!= declared {n_charts}")
|
|
186
|
+
if deck.get("customFonts"):
|
|
187
|
+
assert fonts, "customFonts declared but no fonts embedded"
|
|
188
|
+
print(f"verify OK: {len(pages)} slides, {n_charts} charts "
|
|
189
|
+
f"({native_charts} native, {raster_charts} raster), "
|
|
190
|
+
f"{n_tables} tables, {n_media} pictures, {len(fonts)} fonts")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def convert(pptd_path, out_path=None):
|
|
194
|
+
src = Path(pptd_path)
|
|
195
|
+
dst = Path(out_path) if out_path else src.with_suffix(".pptx")
|
|
196
|
+
n, nf = build(src, dst)
|
|
197
|
+
verify(src, dst)
|
|
198
|
+
print(f"written: {dst} ({n} slides, {nf} fonts embedded)")
|
|
199
|
+
return dst
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _slides(value, count):
|
|
203
|
+
if value is None or value == "all":
|
|
204
|
+
return list(range(1, count + 1))
|
|
205
|
+
try:
|
|
206
|
+
result = [int(n) for n in value.split(",")]
|
|
207
|
+
except ValueError as exc:
|
|
208
|
+
raise ValueError("slides must be 'all' or comma-separated numbers") from exc
|
|
209
|
+
if not result or any(n < 1 or n > count for n in result):
|
|
210
|
+
raise ValueError(f"slide numbers must be between 1 and {count}")
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _cli_export(kind, selection, src):
|
|
215
|
+
from . import slides_image
|
|
216
|
+
import tempfile
|
|
217
|
+
src = Path(src)
|
|
218
|
+
with tempfile.TemporaryDirectory(prefix="pptd-cli-") as tmp:
|
|
219
|
+
pptx = Path(tmp) / "deck.pptx"
|
|
220
|
+
convert(src, pptx)
|
|
221
|
+
prs = Presentation(pptx)
|
|
222
|
+
slides = _slides(selection, len(prs.slides))
|
|
223
|
+
if kind == "png":
|
|
224
|
+
if selection == "collage":
|
|
225
|
+
out = src.with_name(src.stem + "-collage.png")
|
|
226
|
+
slides_image.render_collage(src, out, slides=slides, tmp_pptx=pptx)
|
|
227
|
+
print(f"collage: {out}")
|
|
228
|
+
else:
|
|
229
|
+
out_dir = src.with_name(src.stem + "-png")
|
|
230
|
+
images = slides_image.render_slide_images(src, out_dir, slides=slides,
|
|
231
|
+
tmp_pptx=pptx)
|
|
232
|
+
print(f"{len(images)} PNG files -> {out_dir}/")
|
|
233
|
+
else:
|
|
234
|
+
out = src.with_name(src.stem + ".pdf")
|
|
235
|
+
selected = Path(tmp) / "selected.pptx"
|
|
236
|
+
if slides != list(range(1, len(prs.slides) + 1)):
|
|
237
|
+
keep = {id(prs.slides[n - 1]._element) for n in slides}
|
|
238
|
+
for slide in list(prs.slides):
|
|
239
|
+
if id(slide._element) not in keep:
|
|
240
|
+
prs.slides._sldIdLst.remove(slide._element.getparent())
|
|
241
|
+
prs.save(selected)
|
|
242
|
+
pptx = selected
|
|
243
|
+
subprocess.run(["soffice", "--headless", "--convert-to", "pdf",
|
|
244
|
+
"--outdir", str(tmp), str(pptx)],
|
|
245
|
+
check=True, capture_output=True, timeout=120)
|
|
246
|
+
Path(tmp, f"{pptx.stem}.pdf").replace(out)
|
|
247
|
+
print(f"PDF: {out}")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def main(argv=None):
|
|
251
|
+
parser = argparse.ArgumentParser(prog="pptd", description="PPTD v2 CLI")
|
|
252
|
+
parser.add_argument("command", choices=("check", "png", "pptx", "pdf"))
|
|
253
|
+
parser.add_argument("args", nargs="+", metavar="PATH|SLIDES",
|
|
254
|
+
help="path; png/pdf optionally accept all, collage, or 1,2,3")
|
|
255
|
+
parsed = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
|
256
|
+
values = parsed.args
|
|
257
|
+
if parsed.command in ("check", "pptx"):
|
|
258
|
+
if len(values) != 1:
|
|
259
|
+
parser.error(f"{parsed.command} syntax: pptd {parsed.command} path")
|
|
260
|
+
selection, src = "all", Path(values[0])
|
|
261
|
+
else:
|
|
262
|
+
if len(values) == 1:
|
|
263
|
+
selection, src = "all", Path(values[0])
|
|
264
|
+
elif len(values) == 2:
|
|
265
|
+
selection, src = values
|
|
266
|
+
src = Path(src)
|
|
267
|
+
else:
|
|
268
|
+
parser.error(f"{parsed.command} syntax: pptd {parsed.command} [slides] path")
|
|
269
|
+
if parsed.command == "check":
|
|
270
|
+
import tempfile
|
|
271
|
+
with tempfile.TemporaryDirectory(prefix="pptd-check-") as tmp:
|
|
272
|
+
out = Path(tmp) / "checked.pptx"
|
|
273
|
+
build(src, out)
|
|
274
|
+
verify(src, out)
|
|
275
|
+
elif parsed.command == "pptx":
|
|
276
|
+
convert(src)
|
|
277
|
+
else:
|
|
278
|
+
if parsed.command == "pdf" and selection == "collage":
|
|
279
|
+
parser.error("pdf accepts only all or comma-separated slide numbers")
|
|
280
|
+
_cli_export(parsed.command, selection, src)
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
if __name__ == "__main__":
|
|
285
|
+
sys.exit(main())
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Text element + shared run emission (used by text boxes and table cells)."""
|
|
2
|
+
from lxml import etree
|
|
3
|
+
from pptx.dml.color import RGBColor
|
|
4
|
+
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
|
5
|
+
from pptx.oxml.ns import qn
|
|
6
|
+
from pptx.util import Pt
|
|
7
|
+
|
|
8
|
+
from .rich import parse_rich, LIST_TYPES
|
|
9
|
+
from .style import resolve_color, color_el, css_px
|
|
10
|
+
from .xmlutil import sub, insert_ordered, RPR_ORDER, set_xfrm_flip_rot
|
|
11
|
+
|
|
12
|
+
H_ALIGN = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER,
|
|
13
|
+
"right": PP_ALIGN.RIGHT, "justify": PP_ALIGN.JUSTIFY,
|
|
14
|
+
"distributed": PP_ALIGN.DISTRIBUTE}
|
|
15
|
+
V_ALIGN = {"top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE,
|
|
16
|
+
"bottom": MSO_ANCHOR.BOTTOM}
|
|
17
|
+
|
|
18
|
+
TEXT_DEFAULTS = {"color": "#000000", "fontSize": 18, "fontFamily": "MiSans",
|
|
19
|
+
"bold": False, "italic": False, "lineHeight": 1,
|
|
20
|
+
"letterSpacing": 0, "marginTop": 0}
|
|
21
|
+
|
|
22
|
+
TEXTSTYLE_KEYS = ("color", "fontSize", "fontFamily", "bold", "italic",
|
|
23
|
+
"backgroundColor", "lineHeight", "lineHeightPx",
|
|
24
|
+
"letterSpacing", "marginTop")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_base_style(c, theme, extra=None):
|
|
28
|
+
"""Style priority chain for TextContent-like dicts -> flat style dict."""
|
|
29
|
+
base = dict(TEXT_DEFAULTS)
|
|
30
|
+
if c.get("style"):
|
|
31
|
+
ref = theme.get("textStyles", {}).get(str(c["style"]).lstrip("$"), {})
|
|
32
|
+
for k in TEXTSTYLE_KEYS:
|
|
33
|
+
if ref.get(k) is not None:
|
|
34
|
+
base[k] = ref[k]
|
|
35
|
+
for k in TEXTSTYLE_KEYS:
|
|
36
|
+
if c.get(k) is not None:
|
|
37
|
+
base[k] = c[k]
|
|
38
|
+
for k, v in (extra or {}).items():
|
|
39
|
+
if v is not None:
|
|
40
|
+
base[k] = v
|
|
41
|
+
return base
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _font_family_names(ff):
|
|
45
|
+
"""FontFamily -> (latin, ea)."""
|
|
46
|
+
if isinstance(ff, dict):
|
|
47
|
+
return ff.get("latin"), ff.get("ea")
|
|
48
|
+
return ff, ff
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def emit_runs(p, para, base, theme, opacity=1.0):
|
|
52
|
+
"""Emit parsed runs of one paragraph into a pptx paragraph p."""
|
|
53
|
+
for run in para.runs:
|
|
54
|
+
if run.text == "\n":
|
|
55
|
+
p.add_line_break()
|
|
56
|
+
continue
|
|
57
|
+
if run.math is not None:
|
|
58
|
+
omml = latex_to_omml(run.math)
|
|
59
|
+
if omml is not None:
|
|
60
|
+
p._p.append(omml)
|
|
61
|
+
continue
|
|
62
|
+
if not run.text:
|
|
63
|
+
continue
|
|
64
|
+
r = p.add_run()
|
|
65
|
+
r.text = run.text
|
|
66
|
+
span = run.span
|
|
67
|
+
f = r.font
|
|
68
|
+
size = css_px(span.get("font-size")) or base.get("fontSize", 18)
|
|
69
|
+
f.size = Pt(size)
|
|
70
|
+
latin, ea = _font_family_names(
|
|
71
|
+
span.get("font-family") or base.get("fontFamily", "MiSans"))
|
|
72
|
+
f.name = str(latin or "MiSans")
|
|
73
|
+
rPr = r._r.get_or_add_rPr()
|
|
74
|
+
if ea:
|
|
75
|
+
ea_el = rPr.find(qn("a:ea"))
|
|
76
|
+
if ea_el is None:
|
|
77
|
+
ea_el = insert_ordered(rPr, etree.Element(qn("a:ea")), RPR_ORDER)
|
|
78
|
+
ea_el.set("typeface", str(ea))
|
|
79
|
+
f.bold = bool(base.get("bold")) or run.bold
|
|
80
|
+
f.italic = bool(base.get("italic")) or run.italic
|
|
81
|
+
if run.underline:
|
|
82
|
+
f.underline = True
|
|
83
|
+
if run.strike:
|
|
84
|
+
rPr.set("strike", "sngStrike")
|
|
85
|
+
if run.sup:
|
|
86
|
+
rPr.set("baseline", "30000")
|
|
87
|
+
if run.sub:
|
|
88
|
+
rPr.set("baseline", "-25000")
|
|
89
|
+
ls = base.get("letterSpacing")
|
|
90
|
+
if ls:
|
|
91
|
+
rPr.set("spc", str(int(float(ls) * 100)))
|
|
92
|
+
# color (+alpha)
|
|
93
|
+
color = span.get("color") or base.get("color", "#000000")
|
|
94
|
+
if run.link and not span.get("color"):
|
|
95
|
+
color = "#0563C1"
|
|
96
|
+
f.underline = True
|
|
97
|
+
rgb, alpha = resolve_color(color, theme, opacity)
|
|
98
|
+
f.color.rgb = rgb
|
|
99
|
+
if alpha < 1.0:
|
|
100
|
+
srgb = rPr.find(qn("a:solidFill") + "/" + qn("a:srgbClr"))
|
|
101
|
+
if srgb is None:
|
|
102
|
+
srgb = rPr.find(qn("a:solidFill")).find(qn("a:srgbClr"))
|
|
103
|
+
if srgb is not None:
|
|
104
|
+
sub(srgb, "a:alpha", {"val": str(int(alpha * 100000))})
|
|
105
|
+
if run.link:
|
|
106
|
+
r.hyperlink.address = run.link
|
|
107
|
+
bg = span.get("background-color")
|
|
108
|
+
if bg:
|
|
109
|
+
hl = etree.Element(qn("a:highlight"))
|
|
110
|
+
color_el(hl, bg, theme)
|
|
111
|
+
insert_ordered(rPr, hl, RPR_ORDER)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _apply_para_style(p, para, base, theme, def_align):
|
|
115
|
+
pPr = p._p.get_or_add_pPr()
|
|
116
|
+
p.alignment = H_ALIGN.get(para.align or def_align, PP_ALIGN.LEFT)
|
|
117
|
+
lh_px = para.lh_px if para.lh_px is not None else (
|
|
118
|
+
css_px(base.get("lineHeightPx")) if base.get("lineHeightPx") else None)
|
|
119
|
+
if lh_px:
|
|
120
|
+
p.line_spacing = Pt(lh_px)
|
|
121
|
+
else:
|
|
122
|
+
lh = para.lh if para.lh is not None else base.get("lineHeight", 1)
|
|
123
|
+
if lh and float(lh) != 1.0:
|
|
124
|
+
p.line_spacing = float(lh)
|
|
125
|
+
mt = para.mt if para.mt is not None else base.get("marginTop", 0)
|
|
126
|
+
if mt:
|
|
127
|
+
p.space_before = Pt(float(mt))
|
|
128
|
+
ml = para.ml
|
|
129
|
+
if ml:
|
|
130
|
+
pPr.set("marL", str(int(Pt(float(ml)))))
|
|
131
|
+
if para.mr:
|
|
132
|
+
pPr.set("marR", str(int(Pt(float(para.mr)))))
|
|
133
|
+
if para.ls:
|
|
134
|
+
# li-level letter-spacing: applied via runs below (base override)
|
|
135
|
+
base["letterSpacing"] = para.ls
|
|
136
|
+
# bullets
|
|
137
|
+
if para.bullet or para.ordered:
|
|
138
|
+
lsty = para.list_style or "disc"
|
|
139
|
+
if para.ordered and lsty == "disc":
|
|
140
|
+
lsty = "decimal"
|
|
141
|
+
if lsty in (None, "none"):
|
|
142
|
+
insert_ordered(pPr, etree.SubElement(pPr, qn("a:buNone")), _PPR_BUL)
|
|
143
|
+
elif lsty in LIST_TYPES and isinstance(LIST_TYPES[lsty], str) and LIST_TYPES[lsty][0].isalpha():
|
|
144
|
+
bul = etree.SubElement(pPr, qn("a:buAutoNum"), {"type": LIST_TYPES[lsty]})
|
|
145
|
+
insert_ordered(pPr, bul, _PPR_BUL)
|
|
146
|
+
_add_bu_font(pPr)
|
|
147
|
+
else:
|
|
148
|
+
char = LIST_TYPES.get(lsty, "\u2022") if lsty in LIST_TYPES else lsty
|
|
149
|
+
bul = etree.SubElement(pPr, qn("a:buChar"), {"char": str(char)})
|
|
150
|
+
insert_ordered(pPr, bul, _PPR_BUL)
|
|
151
|
+
_add_bu_font(pPr)
|
|
152
|
+
if pPr.get("marL") is None:
|
|
153
|
+
pPr.set("marL", "228600")
|
|
154
|
+
pPr.set("indent", "-228600")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_PPR_BUL = [qn(t) for t in ("a:buClrTx", "a:buClr", "a:buSzTx", "a:buSzPct",
|
|
158
|
+
"a:buSzPts", "a:buFontTx", "a:buFont", "a:buNone",
|
|
159
|
+
"a:buAutoNum", "a:buChar", "a:buBlip", "a:tabLst",
|
|
160
|
+
"a:defRPr", "a:extLst")]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _add_bu_font(pPr):
|
|
164
|
+
bf = etree.SubElement(pPr, qn("a:buFont"), {"typeface": "Arial"})
|
|
165
|
+
insert_ordered(pPr, bf, _PPR_BUL)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def emit_text(tf, content, theme, default_align=("left", "top"), opacity=1.0,
|
|
169
|
+
extra_style=None, vertical=None, wrap=None):
|
|
170
|
+
"""Emit TextContent into a pptx text_frame tf."""
|
|
171
|
+
base = resolve_base_style(content, theme, extra_style)
|
|
172
|
+
align = content.get("align") or list(default_align)
|
|
173
|
+
tf.word_wrap = content.get("wrap", True) if wrap is None else wrap
|
|
174
|
+
for m in ("margin_left", "margin_right", "margin_top", "margin_bottom"):
|
|
175
|
+
setattr(tf, m, 0)
|
|
176
|
+
tf.vertical_anchor = V_ALIGN.get(align[1], MSO_ANCHOR.TOP)
|
|
177
|
+
if vertical or content.get("textDirection") == "vertical":
|
|
178
|
+
tf._txBody.find(qn("a:bodyPr")).set("vert", "eaVert")
|
|
179
|
+
paras = parse_rich(content.get("text", ""))
|
|
180
|
+
for i, para in enumerate(paras):
|
|
181
|
+
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
|
182
|
+
_apply_para_style(p, para, base, theme, align[0])
|
|
183
|
+
emit_runs(p, para, base, theme, opacity)
|
|
184
|
+
return base
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def render_text(slide, el, theme):
|
|
188
|
+
c = el["content"]
|
|
189
|
+
x, y, w, h = el["bounds"]
|
|
190
|
+
tb = slide.shapes.add_textbox(Pt(x), Pt(y), Pt(w), Pt(h))
|
|
191
|
+
tf = tb.text_frame
|
|
192
|
+
tf.auto_size = None
|
|
193
|
+
base = emit_text(tf, c, theme, opacity=el.get("opacity", 1.0))
|
|
194
|
+
rotation = el.get("rotation") or 0
|
|
195
|
+
flip = el.get("flip")
|
|
196
|
+
if rotation or flip:
|
|
197
|
+
set_xfrm_flip_rot(tb, rotation, flip)
|
|
198
|
+
# text gradient
|
|
199
|
+
grad = c.get("gradient")
|
|
200
|
+
if grad:
|
|
201
|
+
_text_gradient(tf, grad, theme)
|
|
202
|
+
shadow = c.get("shadow")
|
|
203
|
+
if shadow:
|
|
204
|
+
_text_shadow(tf, shadow, theme)
|
|
205
|
+
return tb
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _text_gradient(tf, grad, theme):
|
|
209
|
+
from .xmlutil import RPR_ORDER as ORDER
|
|
210
|
+
from .style import fill_xml
|
|
211
|
+
for para in tf.paragraphs:
|
|
212
|
+
for run in para.runs:
|
|
213
|
+
rPr = run._r.get_or_add_rPr()
|
|
214
|
+
fill_xml(rPr, grad, theme, order=ORDER)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _text_shadow(tf, shadow, theme):
|
|
218
|
+
from .style import shadow_xml
|
|
219
|
+
for para in tf.paragraphs:
|
|
220
|
+
for run in para.runs:
|
|
221
|
+
rPr = run._r.get_or_add_rPr()
|
|
222
|
+
shadow_xml(rPr, shadow, theme, order=RPR_ORDER)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def latex_to_omml(latex):
|
|
226
|
+
"""Optional: LaTeX -> m:oMath element via latex2mathml + mathml2omml."""
|
|
227
|
+
try:
|
|
228
|
+
import latex2mathml.converter as l2m
|
|
229
|
+
import mathml2omml
|
|
230
|
+
except ImportError:
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
mathml = l2m.convert(latex)
|
|
234
|
+
omml = mathml2omml.convert(mathml)
|
|
235
|
+
if isinstance(omml, bytes):
|
|
236
|
+
omml = omml.decode()
|
|
237
|
+
ns = {"m": "http://schemas.openxmlformats.org/officeDocument/2006/math"}
|
|
238
|
+
root = etree.fromstring("<root xmlns:m='%s'>%s</root>" % (ns["m"], omml))
|
|
239
|
+
return root.find("m:oMath", ns)
|
|
240
|
+
except Exception:
|
|
241
|
+
return None
|