Spaces:
Running
Running
| # pptx writing: incremental load-modify-save; the anchor is a slide number (1-based) + a placeholder role, or anchor text (cross-deck find/replace). | |
| # Implemented with python-pptx; feature set informed by archipelago's (Apache-2.0) slides_server, addressing and parameters are this project's own design. | |
| # | |
| # Operations (op): | |
| # create(slides=[{layout?, title?, subtitle?, bullets?[], table?[[]], notes?}]) | |
| # add_slide(layout="title_and_content", title?, bullets?[], index?) index omitted = at the end | |
| # set_text(slide, placeholder, text) slide = 1-based page number; placeholder = title|body|subtitle | |
| # add_textbox(slide, text, x=1, y=1, w=8, h=1) inches | |
| # add_table(slide, rows=[[...]], x=0.5, y=1.5, w=9, h=3, header=True) | |
| # add_image(slide, image_path, x=1, y=1, w?, h?) | |
| # replace_text(find, replace, slide?) slide omitted = the whole deck | |
| # set_notes(slide, text) | |
| # add_shape(slide, shape=rectangle|oval|..., x,y,w,h, text?, fill_color?, line_color?) | |
| # add_chart(slide, chart_type=bar|column|line|pie|..., categories[], series={name:[vals]}, | |
| # x?,y?,w?,h?, title?) | |
| # format_text(slide, find, bold?, italic?, underline?, font_size?, font_color?) | |
| # duplicate_slide(slide) copy a slide and append it at the end | |
| # delete_slide(slide) | |
| import os as _os | |
| _LAYOUTS = {"title": 0, "title_and_content": 1, "section_header": 2, | |
| "two_content": 3, "title_only": 5, "blank": 6} | |
| _PH = {"title": 0, "body": 1, "subtitle": 1, "content": 1} | |
| # Shape name → MSO_SHAPE (the enum is fetched lazily, see _shape_enum) | |
| _SHAPES = {"rectangle": "RECTANGLE", "rounded_rectangle": "ROUNDED_RECTANGLE", | |
| "oval": "OVAL", "ellipse": "OVAL", "diamond": "DIAMOND", | |
| "triangle": "ISOCELES_TRIANGLE", "right_arrow": "RIGHT_ARROW", | |
| "left_arrow": "LEFT_ARROW", "up_arrow": "UP_ARROW", "down_arrow": "DOWN_ARROW", | |
| "pentagon": "PENTAGON", "chevron": "CHEVRON", "star": "STAR_5_POINT", | |
| "cloud": "CLOUD", "heart": "HEART"} | |
| # Chart name → XL_CHART_TYPE | |
| _CHARTS = {"bar": "BAR_CLUSTERED", "column": "COLUMN_CLUSTERED", "line": "LINE", | |
| "line_markers": "LINE_MARKERS", "pie": "PIE", "doughnut": "DOUGHNUT", | |
| "area": "AREA", "radar": "RADAR"} | |
| def _rgb(c): | |
| """6-digit hex → RGBColor.""" | |
| from pptx.dml.color import RGBColor | |
| return RGBColor.from_string(str(c).lstrip("#").upper()) | |
| def _pp(): | |
| _ensure("pptx", "python-pptx") | |
| import pptx | |
| return pptx | |
| def _slide(prs, n): | |
| """1-based page number → slide object.""" | |
| idx = int(n) - 1 | |
| if idx < 0 or idx >= len(prs.slides.__iter__.__self__._sldIdLst): | |
| raise ValueError(f"slide {n} out of range (deck has {len(prs.slides._sldIdLst)} slides)") | |
| return list(prs.slides)[idx] | |
| def _align_pptx(p, align): | |
| if not align: | |
| return | |
| from pptx.enum.text import PP_ALIGN | |
| m = {"left": "LEFT", "center": "CENTER", "right": "RIGHT", | |
| "justify": "JUSTIFY"}.get(str(align).lower()) | |
| if m: | |
| p.alignment = getattr(PP_ALIGN, m) | |
| def _set_pptx_ea(run, font): | |
| """Add a:ea (the East Asian font) to a pptx run — font.name only writes a:latin, and CJK characters read a:ea.""" | |
| try: | |
| from pptx.oxml.ns import qn as _q | |
| rPr = run._r.get_or_add_rPr() | |
| for tag in ("a:ea", "a:cs"): | |
| el = rPr.find(_q(tag)) | |
| if el is None: | |
| el = rPr.makeelement(_q(tag), {}) | |
| rPr.append(el) | |
| el.set("typeface", font) | |
| except Exception: | |
| pass | |
| def _rt_para(p, text): | |
| """Write RichText (normalised by _norm_runs) run by run into one pptx paragraph p. A link becomes a real hyperlink.""" | |
| from pptx.util import Pt | |
| for rd in _norm_runs(text): | |
| r = p.add_run() | |
| r.text = rd.get("text", "") | |
| f = r.font | |
| if rd.get("bold") is not None: | |
| f.bold = bool(rd["bold"]) | |
| if rd.get("italic") is not None: | |
| f.italic = bool(rd["italic"]) | |
| if rd.get("underline") is not None: | |
| f.underline = bool(rd["underline"]) | |
| if rd.get("size"): | |
| f.size = Pt(float(rd["size"])) | |
| if rd.get("color"): | |
| f.color.rgb = _rgb(rd["color"]) | |
| if rd.get("font"): | |
| f.name = rd["font"] | |
| # python-pptx's font.name only writes a:latin — CJK characters use a:ea, so | |
| # without setting it the requested | |
| # font has no effect on Chinese/Japanese/Korean text (the same problem as docx's w:eastAsia). | |
| _set_pptx_ea(r, rd["font"]) | |
| if rd.get("link"): | |
| r.hyperlink.address = rd["link"] | |
| def _no_bullet(p): | |
| """Turn off a paragraph's bullet (already-numbered content, or content that needs no dot): insert a:buNone into pPr.""" | |
| from pptx.oxml.ns import qn | |
| pPr = p._p.get_or_add_pPr() | |
| for tag in ("a:buChar", "a:buAutoNum", "a:buNone"): | |
| for e in pPr.findall(qn(tag)): | |
| pPr.remove(e) | |
| pPr.append(pPr.makeelement(qn("a:buNone"), {})) | |
| def _set_autofit(tf, mode): | |
| """Text-box autofit: none / shrink_text (shrink the font) / resize_shape (grow the shape).""" | |
| if not mode: | |
| return | |
| from pptx.enum.text import MSO_AUTO_SIZE | |
| tf.word_wrap = True | |
| m = str(mode).lower() | |
| if m in ("shrink", "shrink_text"): | |
| tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE | |
| elif m in ("resize", "resize_shape"): | |
| tf.auto_size = MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT | |
| elif m == "none": | |
| tf.auto_size = MSO_AUTO_SIZE.NONE | |
| def _find_ph(slide, role): | |
| """Find a placeholder by its semantic type (fixes: body and subtitle both map to idx 1 in _PH, | |
| so writing both on one slide clobbered each other and silently lost content). When the exact | |
| type is missing it falls back to idx, but skips placeholders belonging to another | |
| role's dedicated type (no stealing); still nothing → None (the caller falls back to a text box).""" | |
| try: | |
| from pptx.enum.shapes import PP_PLACEHOLDER as _P | |
| WANT = {"title": {_P.TITLE, _P.CENTER_TITLE}, | |
| "subtitle": {_P.SUBTITLE}, | |
| "body": {_P.BODY, _P.OBJECT}, | |
| "content": {_P.BODY, _P.OBJECT}} | |
| AVOID = {"body": {_P.SUBTITLE, _P.TITLE, _P.CENTER_TITLE}, | |
| "content": {_P.SUBTITLE, _P.TITLE, _P.CENTER_TITLE}, | |
| "subtitle": {_P.BODY, _P.OBJECT, _P.TITLE, _P.CENTER_TITLE}} | |
| want, avoid = WANT.get(role), AVOID.get(role, set()) | |
| except Exception: # enum unavailable → fall back to matching on idx alone | |
| want, avoid = None, set() | |
| phs = list(slide.placeholders) | |
| if want: | |
| for ph in phs: | |
| try: | |
| if ph.placeholder_format.type in want: | |
| return ph | |
| except Exception: | |
| continue | |
| idx = _PH.get(role, 1) | |
| for ph in phs: | |
| try: | |
| if ph.placeholder_format.idx == idx and ph.placeholder_format.type not in avoid: | |
| return ph | |
| except Exception: | |
| continue | |
| return None | |
| def _set_ph(slide, role, text): | |
| """Write RichText into a placeholder. role = title/body/subtitle. A missing placeholder falls back to a text box.""" | |
| from pptx.util import Inches | |
| tf = None | |
| if role == "title" and slide.shapes.title is not None: | |
| tf = slide.shapes.title.text_frame | |
| else: | |
| ph = _find_ph(slide, role) | |
| if ph is not None: | |
| tf = ph.text_frame | |
| if tf is None: | |
| tb = slide.shapes.add_textbox(Inches(0.8), Inches(1.6), Inches(8), Inches(1)) | |
| tf = tb.text_frame | |
| tf.clear() | |
| _rt_para(tf.paragraphs[0], text) | |
| return True | |
| def _add_body(slide, body): | |
| """Structured list for the body placeholder (cures flattening and forced bullets). body may be: | |
| - a list ['a','b'] (the old bullets form, still supported, defaults to level 0 with a dot); | |
| - dict {items:[{text:RichText, level:0-4, bullet:true|false}], autofit?}。""" | |
| from pptx.util import Inches | |
| if isinstance(body, dict): | |
| items = body.get("items", []) | |
| autofit = body.get("autofit") | |
| else: | |
| items = list(body or []) | |
| autofit = None | |
| ph = _find_ph(slide, "body") | |
| if ph is None: | |
| ph = slide.shapes.add_textbox(Inches(0.8), Inches(1.8), Inches(8.5), Inches(4)) | |
| tf = ph.text_frame | |
| tf.clear() | |
| for i, it in enumerate(items): | |
| if isinstance(it, str): | |
| it = {"text": it} | |
| p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() | |
| p.level = int(it.get("level", 0)) | |
| _rt_para(p, it.get("text", "")) | |
| if it.get("bullet") is False: | |
| _no_bullet(p) | |
| _set_autofit(tf, autofit) | |
| def _add_table(slide, rows, x, y, w, h, header): | |
| from pptx.util import Inches | |
| nr, nc = len(rows), len(rows[0]) | |
| gf = slide.shapes.add_table(nr, nc, Inches(x), Inches(y), Inches(w), Inches(h)) | |
| tbl = gf.table | |
| for i, row in enumerate(rows): | |
| for j, val in enumerate(row): | |
| tbl.cell(i, j).text = str(val) | |
| return gf | |
| def _build_slide(prs, sd): | |
| layout = prs.slide_layouts[_LAYOUTS.get(sd.get("layout", "title_and_content"), 1)] | |
| slide = prs.slides.add_slide(layout) | |
| if sd.get("title"): | |
| _set_ph(slide, "title", sd["title"]) | |
| if sd.get("subtitle"): | |
| _set_ph(slide, "subtitle", sd["subtitle"]) | |
| if sd.get("body") is not None: | |
| _add_body(slide, sd["body"]) | |
| elif sd.get("bullets"): | |
| _add_body(slide, sd["bullets"]) # the old bullets form | |
| if sd.get("table"): | |
| _add_table(slide, sd["table"], 0.5, 2.0, 9, 3, True) | |
| if sd.get("notes"): | |
| ntf = slide.notes_slide.notes_text_frame | |
| ntf.clear() | |
| _rt_para(ntf.paragraphs[0], sd["notes"]) | |
| return slide | |
| def _pptx_write(path, op, args): | |
| pptx = _pp() | |
| if op == "create": | |
| if _os.path.exists(path) and not args.get("overwrite"): | |
| return _res(f"create refused: {path} already exists — use add_slide/set_text to " | |
| "edit, or pass overwrite:true to rebuild", ok=False) | |
| slides = args.get("slides", []) | |
| if not slides: | |
| return _res(f"create wrote nothing to {path}: no slides given", ok=False) | |
| prs = pptx.Presentation() | |
| for sd in slides: | |
| _build_slide(prs, sd) | |
| _os.makedirs(_os.path.dirname(path) or ".", exist_ok=True) | |
| prs.save(path) | |
| return _res(f"created pptx: {path}", counts={"slide": len(slides)}) | |
| if not _os.path.exists(path): | |
| return f"[error] file not found (edit needs existing file): {path}" | |
| prs = pptx.Presentation(path) | |
| if op == "add_slide": | |
| sd = {k: args[k] for k in ("layout", "title", "subtitle", "body", "bullets", "notes", "table") | |
| if k in args} | |
| idx = args.get("index") | |
| slide = _build_slide(prs, sd) | |
| if idx is not None: # move to the requested position (1-based) | |
| lst = prs.slides._sldIdLst | |
| el = lst[-1] | |
| lst.remove(el) | |
| lst.insert(min(int(idx) - 1, len(lst)), el) | |
| prs.save(path) | |
| return f"added slide ({sd.get('layout', 'title_and_content')})" | |
| if op == "set_text": | |
| slide = _slide(prs, args["slide"]) | |
| _set_ph(slide, args.get("placeholder", "body"), args["text"]) | |
| prs.save(path) | |
| return f"set {args.get('placeholder', 'body')} on slide {args['slide']}" | |
| if op == "add_textbox": | |
| from pptx.util import Inches | |
| slide = _slide(prs, args["slide"]) | |
| tb = slide.shapes.add_textbox(Inches(args.get("x", 1)), Inches(args.get("y", 1)), | |
| Inches(args.get("w", 8)), Inches(args.get("h", 1))) | |
| tf = tb.text_frame | |
| tf.clear() | |
| _rt_para(tf.paragraphs[0], args.get("text", "")) | |
| _align_pptx(tf.paragraphs[0], args.get("align_h")) | |
| _set_autofit(tf, args.get("autofit")) | |
| prs.save(path) | |
| return f"added textbox on slide {args['slide']}" | |
| if op == "add_table": | |
| slide = _slide(prs, args["slide"]) | |
| _add_table(slide, args["rows"], args.get("x", 0.5), args.get("y", 1.5), | |
| args.get("w", 9), args.get("h", 3), args.get("header", True)) | |
| prs.save(path) | |
| return f"added table on slide {args['slide']}" | |
| if op == "add_image": | |
| from pptx.util import Inches | |
| slide = _slide(prs, args["slide"]) | |
| kw = {} | |
| if args.get("w"): | |
| kw["width"] = Inches(args["w"]) | |
| if args.get("h"): | |
| kw["height"] = Inches(args["h"]) | |
| slide.shapes.add_picture(args["image_path"], Inches(args.get("x", 1)), | |
| Inches(args.get("y", 1)), **kw) | |
| prs.save(path) | |
| return f"added image on slide {args['slide']}" | |
| if op == "set_notes": | |
| slide = _slide(prs, args["slide"]) | |
| ntf = slide.notes_slide.notes_text_frame | |
| ntf.clear() | |
| _rt_para(ntf.paragraphs[0], args["text"]) | |
| prs.save(path) | |
| return f"set notes on slide {args['slide']}" | |
| if op == "replace_text": | |
| find, repl = args["find"], args.get("replace", "") | |
| target = [_slide(prs, args["slide"])] if args.get("slide") else list(prs.slides) | |
| cnt = 0 | |
| for slide in target: | |
| for shape in slide.shapes: | |
| if not shape.has_text_frame: | |
| continue | |
| for p in shape.text_frame.paragraphs: | |
| for r in p.runs: | |
| if r.text and find in r.text: | |
| r.text = r.text.replace(find, repl) | |
| cnt += 1 | |
| prs.save(path) | |
| return _res(f"replaced {cnt} run(s) containing {find!r}", | |
| warn=(f"0 matches for {find!r}" if cnt == 0 else None)) | |
| if op == "add_shape": | |
| from pptx.enum.shapes import MSO_SHAPE | |
| from pptx.util import Inches, Pt | |
| slide = _slide(prs, args["slide"]) | |
| name = str(args.get("shape", "rectangle")).lower() | |
| mso = getattr(MSO_SHAPE, _SHAPES.get(name, "RECTANGLE")) | |
| sp = slide.shapes.add_shape(mso, Inches(args.get("x", 1)), Inches(args.get("y", 1)), | |
| Inches(args.get("w", 2)), Inches(args.get("h", 1))) | |
| if args.get("fill_color"): | |
| sp.fill.solid() | |
| sp.fill.fore_color.rgb = _rgb(args["fill_color"]) | |
| if args.get("line_color"): | |
| sp.line.color.rgb = _rgb(args["line_color"]) | |
| if args.get("text"): | |
| sp.text_frame.text = args["text"] | |
| if args.get("font_color"): | |
| sp.text_frame.paragraphs[0].runs[0].font.color.rgb = _rgb(args["font_color"]) | |
| if args.get("font_size"): | |
| sp.text_frame.paragraphs[0].runs[0].font.size = Pt(float(args["font_size"])) | |
| prs.save(path) | |
| return f"added {name} on slide {args['slide']}" | |
| if op == "add_chart": | |
| from pptx.chart.data import CategoryChartData | |
| from pptx.enum.chart import XL_CHART_TYPE | |
| from pptx.util import Inches | |
| slide = _slide(prs, args["slide"]) | |
| ctype = getattr(XL_CHART_TYPE, _CHARTS.get( | |
| str(args.get("chart_type", "column")).lower(), "COLUMN_CLUSTERED")) | |
| data = CategoryChartData() | |
| data.categories = args.get("categories") or [] | |
| series = args.get("series") or {} | |
| for sname, vals in series.items(): | |
| data.add_series(sname, [float(v) for v in vals]) | |
| gf = slide.shapes.add_chart( | |
| ctype, Inches(args.get("x", 1)), Inches(args.get("y", 1.5)), | |
| Inches(args.get("w", 8)), Inches(args.get("h", 4.5)), data) | |
| if args.get("title"): | |
| gf.chart.has_title = True | |
| gf.chart.chart_title.text_frame.text = args["title"] | |
| prs.save(path) | |
| return f"added {args.get('chart_type', 'column')} chart on slide {args['slide']}" | |
| if op == "format_text": | |
| from pptx.util import Pt | |
| slide = _slide(prs, args["slide"]) | |
| find = args["find"] | |
| hits = 0 | |
| for shape in slide.shapes: | |
| if not shape.has_text_frame: | |
| continue | |
| for p in shape.text_frame.paragraphs: | |
| for r in p.runs: | |
| if not r.text or find not in r.text: | |
| continue | |
| f = r.font | |
| if args.get("bold") is not None: | |
| f.bold = bool(args["bold"]) | |
| if args.get("italic") is not None: | |
| f.italic = bool(args["italic"]) | |
| if args.get("underline") is not None: | |
| f.underline = bool(args["underline"]) | |
| if args.get("strike") is not None: # python-pptx has no font.strike, so go through rPr | |
| rPr = r._r.get_or_add_rPr() | |
| rPr.set("strike", "sngStrike" if args["strike"] else "noStrike") | |
| sz = args.get("font_size") or args.get("size") # size = the parameter name the documented contract uses | |
| if sz: | |
| f.size = Pt(float(sz)) | |
| col = args.get("font_color") or args.get("color") | |
| if col: | |
| f.color.rgb = _rgb(col) | |
| hits += 1 | |
| prs.save(path) | |
| return _res(f"formatted {hits} run(s) matching {find!r} on slide {args['slide']}", | |
| warn=(f"0 runs matched {find!r}" if hits == 0 else None)) | |
| if op == "duplicate_slide": | |
| import copy as _copy | |
| src = _slide(prs, args["slide"]) | |
| layout = src.slide_layout | |
| new = prs.slides.add_slide(layout) | |
| # Drop the placeholders the layout brings and deep-copy the source slide's shapes instead | |
| for sh in list(new.shapes): | |
| sh._element.getparent().remove(sh._element) | |
| for sh in src.shapes: | |
| new.shapes._spTree.append(_copy.deepcopy(sh._element)) | |
| # Also copy the source slide's relationships (rels for images / charts / hyperlinks) | |
| # and rewrite the rIds inside the copied XML — | |
| # copying only the shape XML leaves r:embed pointing at a relationship the new slide does not have, losing assets and making PowerPoint report corruption. | |
| _RNS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" | |
| id_map = {} | |
| for rid, rel in list(src.part.rels.items()): | |
| if rel.reltype.endswith("/slideLayout") or rel.reltype.endswith("/notesSlide"): | |
| continue # the new slide already has its own layout; notes do not travel with shapes | |
| if rel.is_external: | |
| new_rid = new.part.rels.get_or_add_ext_rel(rel.reltype, rel.target_ref) | |
| else: | |
| new_rid = new.part.relate_to(rel.target_part, rel.reltype) | |
| if new_rid != rid: | |
| id_map[rid] = new_rid | |
| if id_map: | |
| for el in new.shapes._spTree.iter(): | |
| for attr, val in list(el.attrib.items()): | |
| if attr.startswith("{" + _RNS + "}") and val in id_map: | |
| el.set(attr, id_map[val]) | |
| prs.save(path) | |
| return f"duplicated slide {args['slide']} -> slide {len(prs.slides._sldIdLst)}" | |
| if op == "delete_slide": | |
| idx = int(args["slide"]) - 1 | |
| lst = prs.slides._sldIdLst | |
| ids = list(lst) | |
| if idx < 0 or idx >= len(ids): | |
| return f"[error] slide {args['slide']} out of range" | |
| lst.remove(ids[idx]) | |
| prs.save(path) | |
| return f"deleted slide {args['slide']}" | |
| if op == "set_slide_size": | |
| # Slide canvas size / aspect ratio (the most common pptx patch in evaluation: switching to widescreen when the template is not 16:9). | |
| from pptx.util import Inches | |
| preset = str(args.get("preset") or args.get("aspect") or "").lower().replace("x", ":") | |
| presets = {"16:9": (13.333, 7.5), "widescreen": (13.333, 7.5), | |
| "4:3": (10, 7.5), "standard": (10, 7.5), "16:10": (10, 6.25)} | |
| if preset in presets: | |
| w, h = presets[preset] | |
| else: | |
| w = float(args.get("width_in", 13.333)) | |
| h = float(args.get("height_in", 7.5)) | |
| prs.slide_width = Inches(w) | |
| prs.slide_height = Inches(h) | |
| prs.save(path) | |
| return f"set slide size {w}in x {h}in ({preset or 'custom'})" | |
| return f"[error] unknown pptx op: {op}" | |