"""Edit a .pptx in place — silently, without opening PowerPoint. Uses ``python-pptx`` (pure Python) so it works in the background on any OS with no Office/PowerPoint window. The deck is exposed as a marker-delimited document — one block per shape (text box, picture, table, …) — carrying its type, position/size and (for text shapes) its text, so a human OR the AI editor can change any of them and we map each block straight back onto its shape: ### Slide 1 / Box 1 type: text pos: 1.00, 0.50 size: 8.00, 1.20 text: Quarterly Review ### Slide 1 / Box 2 type: picture pos: 1.00, 2.00 size: 4.00, 3.00 image: (keep — set a file path to replace this image) Editable per block: ``text`` (text shapes), ``pos``/``size`` (inches, any shape → move/resize) and, for pictures, ``image:`` set to a file path to REPLACE the picture in place. Layout, other images and formatting are preserved. Pure logic (no Qt) so it's directly unit-testable. """ from __future__ import annotations import os import re from typing import Dict, List, Tuple _MARK = re.compile(r"^###\s*Slide\s*(\d+)\s*/\s*Box\s*(\d+)\s*$") _FIELD = re.compile(r"^(type|pos|size|image|font|text)\s*:\s*(.*)$") _EMU_PER_IN = 914400 _KEEP_PREFIX = "(" # image values like "(keep …)" mean "don't change" def is_available() -> bool: """Máy đã cài ``python-pptx`` chưa — không có thì mọi tính năng PowerPoint tắt.""" try: import pptx # noqa: F401 return True except Exception: # noqa: BLE001 return False def _in(emu) -> float: """Đổi đơn vị EMU của Office sang inch, làm tròn 2 chữ số.""" return round((emu or 0) / _EMU_PER_IN, 2) def _kind(shape) -> str: """Loại hình khối trong slide: ảnh, bảng, biểu đồ hay hộp văn bản.""" from pptx.enum.shapes import MSO_SHAPE_TYPE try: if shape.shape_type == MSO_SHAPE_TYPE.PICTURE: return "picture" if shape.shape_type == MSO_SHAPE_TYPE.TABLE: return "table" except Exception: # noqa: BLE001 pass # Recognise placeholders (title / body / SLIDE NUMBER / footer / date …) so # the slide-number box is identifiable even though its value is a dynamic # field (not literal text). try: if shape.is_placeholder: pt = shape.placeholder_format.type role = pt.name.lower() if pt is not None else "placeholder" return f"placeholder:{role}" except Exception: # noqa: BLE001 pass return "text" if shape.has_text_frame else "shape" def _slide_number_text(shape, slide_index: int) -> str: """The number a slide-number placeholder shows. Its run text is usually empty (the value is a ```` field resolved at display time), so fall back to the slide's 1-based index so the number is still recognised.""" txt = shape.text_frame.text.strip() if txt: return txt # Pull cached field text if present, else use the slide index. try: from pptx.oxml.ns import qn for fld in shape.text_frame._txBody.iter(qn("a:fld")): t = fld.find(qn("a:t")) if t is not None and t.text: return t.text except Exception: # noqa: BLE001 pass return str(slide_index) def pptx_to_text(path: str) -> str: """Marker-delimited editable document for every shape in the deck.""" from pptx import Presentation prs = Presentation(path) out: List[str] = [] for si, slide in enumerate(prs.slides, 1): for bi, shape in enumerate(slide.shapes, 1): kind = _kind(shape) out.append(f"### Slide {si} / Box {bi}") out.append(f"type: {kind}") out.append(f"pos: {_in(shape.left):.2f}, {_in(shape.top):.2f}") out.append(f"size: {_in(shape.width):.2f}, {_in(shape.height):.2f}") if kind == "picture": out.append("image: (keep — set a file path to replace this image)") if kind == "placeholder:slide_number": # Surface the slide number explicitly (its text is a dynamic field). out.append(f"slide_number: {_slide_number_text(shape, si)}") if shape.has_text_frame: out.append("font: " + _read_font(shape)) out.append("text:") out.append(shape.text_frame.text) out.append("") return ("\n".join(out).rstrip() + "\n") if out else "" def _read_font(shape) -> str: """Summarise the first run's font as ``name=.. size=.. bold=.. color=RRGGBB`` (blank fields when a property isn't set). ``color`` is the solid RGB hex, or empty when inherited/theme-based.""" name = size = color = "" bold = 0 try: paras = shape.text_frame.paragraphs run = None for p in paras: if p.runs: run = p.runs[0] break font = run.font if run is not None else paras[0].font name = font.name or "" if font.size is not None: size = str(int(font.size.pt)) bold = 1 if font.bold else 0 try: if font.color is not None and font.color.type is not None and font.color.rgb is not None: color = str(font.color.rgb) except Exception: # noqa: BLE001 - theme/inherited colour has no rgb color = "" except Exception: # noqa: BLE001 pass return f"name={name} size={size} bold={bold} color={color}" def _apply_font(shape, spec: str) -> bool: """Apply a ``name=.. size=.. bold=.. color=RRGGBB`` spec to every run in the text box (only the fields actually provided). Returns True if applied.""" from pptx.dml.color import RGBColor from pptx.util import Pt attrs = {} for kv in (spec or "").split(): if "=" in kv: k, v = kv.split("=", 1) attrs[k.strip()] = v.strip() if not attrs: return False applied = False for para in shape.text_frame.paragraphs: runs = list(para.runs) if not runs and para.text: runs = [para.add_run()] for run in runs: f = run.font if attrs.get("name"): f.name = attrs["name"]; applied = True if attrs.get("size"): try: f.size = Pt(float(attrs["size"])); applied = True except ValueError: pass if "bold" in attrs and attrs["bold"] != "": f.bold = attrs["bold"] in ("1", "true", "True", "yes"); applied = True if attrs.get("color"): try: f.color.rgb = RGBColor.from_string(attrs["color"].lstrip("#").upper()) applied = True except Exception: # noqa: BLE001 - bad hex → ignore pass return applied def _parse(text: str) -> Dict[Tuple[int, int], dict]: """Đọc dạng văn bản đánh dấu của slide trở lại thành cấu trúc. Đây là khuôn trung gian giữa PowerPoint và ô soạn thảo: người dùng (và AI) sửa văn bản, hàm này dựng lại thành thao tác trên deck. """ blocks: Dict[Tuple[int, int], dict] = {} cur: Tuple[int, int] | None = None fields: dict = {} in_text = False textbuf: List[str] = [] def _flush(): """Chốt khối đang đọc dở và đưa vào kết quả.""" if cur is not None: if in_text: fields["text"] = "\n".join(textbuf).strip("\n") blocks[cur] = dict(fields) for line in (text or "").splitlines(): m = _MARK.match(line) if m: _flush() cur = (int(m.group(1)), int(m.group(2))) fields = {} in_text = False textbuf = [] continue if cur is None: continue if in_text: textbuf.append(line) continue fm = _FIELD.match(line) if fm: key, val = fm.group(1), fm.group(2) if key == "text": in_text = True textbuf = [val] if val else [] else: fields[key] = val.strip() _flush() return blocks def _pair(val: str): """Đọc chuỗi 'a, b' thành cặp số (dùng cho toạ độ và kích thước).""" try: a, b = (x.strip() for x in val.split(",", 1)) return float(a), float(b) except Exception: # noqa: BLE001 return None def image_change_requested(text: str) -> bool: """True if the edited document asks to REPLACE any picture (an ``image:`` field pointing at a real file) — used to confirm before touching images.""" for f in _parse(text).values(): img = (f.get("image") or "").strip() if img and not img.startswith(_KEEP_PREFIX) and os.path.isfile(img): return True return False def apply_text_to_pptx(path: str, text: str) -> Tuple[int, bool]: """Write the edited document back onto the deck and save in place. Returns ``(shapes_changed, image_changed)``.""" from pptx import Presentation from pptx.util import Emu blocks = _parse(text) prs = Presentation(path) changed = 0 image_changed = False for si, slide in enumerate(prs.slides, 1): for bi, shape in enumerate(slide.shapes, 1): f = blocks.get((si, bi)) if not f: continue touched = False # geometry (move / resize) pos = _pair(f.get("pos", "")) if "pos" in f else None if pos is not None: new_left, new_top = Emu(int(pos[0] * _EMU_PER_IN)), Emu(int(pos[1] * _EMU_PER_IN)) if shape.left != new_left or shape.top != new_top: shape.left, shape.top = new_left, new_top touched = True size = _pair(f.get("size", "")) if "size" in f else None if size is not None: new_w, new_h = Emu(int(size[0] * _EMU_PER_IN)), Emu(int(size[1] * _EMU_PER_IN)) if shape.width != new_w or shape.height != new_h: shape.width, shape.height = new_w, new_h touched = True # text if "text" in f and shape.has_text_frame and f["text"] != shape.text_frame.text: shape.text_frame.text = f["text"] touched = True # font (name / size / bold / colour) — applied AFTER text so it lands # on the new runs. This is how AI edit recognises & changes colour/font. if "font" in f and shape.has_text_frame: if _apply_font(shape, f["font"]): touched = True # image replace (in place — keeps position/size) img = (f.get("image") or "").strip() if img and not img.startswith(_KEEP_PREFIX) and os.path.isfile(img): if _replace_picture(slide, shape, img): image_changed = True touched = True if touched: changed += 1 prs.save(path) return changed, image_changed def _blank_layout(prs): """A slide layout with no placeholders ('Blank'), so added boxes aren't fighting template placeholders. Falls back to a sensible index/last layout.""" layouts = list(prs.slide_layouts) for lay in layouts: try: if len(lay.placeholders) == 0: return lay except Exception: # noqa: BLE001 pass if len(layouts) > 6: return layouts[6] return layouts[-1] if layouts else prs.slide_layouts[0] def _add_box(slide, f: dict) -> None: """Add one shape (text box or picture) to ``slide`` from a parsed block.""" from pptx.util import Emu pos = _pair(f.get("pos", "")) or (0.5, 0.5) size = _pair(f.get("size", "")) or (9.0, 1.2) left, top = Emu(int(pos[0] * _EMU_PER_IN)), Emu(int(pos[1] * _EMU_PER_IN)) width, height = Emu(int(size[0] * _EMU_PER_IN)), Emu(int(size[1] * _EMU_PER_IN)) kind = (f.get("type") or "text").lower() img = (f.get("image") or "").strip() has_real_img = bool(img) and not img.startswith(_KEEP_PREFIX) and os.path.isfile(img) if kind.startswith("picture") and has_real_img: try: slide.shapes.add_picture(img, left, top, width, height) return except Exception: # noqa: BLE001 - bad image → fall through to a text box pass tb = slide.shapes.add_textbox(left, top, width, height) tf = tb.text_frame tf.word_wrap = True tf.text = f.get("text", "") if "font" in f: _apply_font(tb, f["font"]) def create_pptx_from_text(path: str, text: str) -> Tuple[int, int]: """Create a NEW .pptx at ``path`` from a marker-delimited document — the same '### Slide N / Box M' format ``pptx_to_text`` produces. When ``text`` has no markers, fall back to one text-box slide per blank-line-separated block so a plain outline still yields a valid deck. Returns ``(slides, boxes)``.""" from pptx import Presentation prs = Presentation() blank = _blank_layout(prs) blocks = _parse(text) boxes = 0 if blocks: slide_nums = sorted({si for si, _bi in blocks}) slide_map = {si: prs.slides.add_slide(blank) for si in slide_nums} for (si, _bi), f in sorted(blocks.items()): _add_box(slide_map[si], f) boxes += 1 prs.save(path) return len(slide_nums), boxes # Fallback: no markers → split on blank lines, one full-width text box per slide. chunks = [c.strip() for c in re.split(r"\n\s*\n", (text or "").strip()) if c.strip()] if not chunks: chunks = [""] for chunk in chunks: slide = prs.slides.add_slide(blank) _add_box(slide, {"type": "text", "pos": "0.5, 0.5", "size": "9.0, 6.0", "text": chunk}) boxes += 1 prs.save(path) return len(chunks), boxes def _replace_picture(slide, shape, image_path: str) -> bool: """Swap a picture's image blob in place (geometry preserved). Best-effort; returns False for non-picture shapes or on failure.""" try: blip = shape._element.blipFill.blip except Exception: # noqa: BLE001 - not a picture / no blip return False try: image_part, rId = slide.part.get_or_add_image_part(image_path) blip.rEmbed = rId return True except Exception: # noqa: BLE001 return False