Files
cowork-local/core/pptx_edit.py
T
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

385 lines
14 KiB
Python

"""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 ``<a:fld>`` 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