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/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