LibreOffice Impress — Python Manipulation Guide
SkillFiles & storageHow to programmatically create, modify, and verify LibreOffice Impress (.pptx) files using Python python-pptx. For setup-gen and reward-gen agents.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the LibreOffice Impress — Python Manipulation Guide skill
What this skill tells your AI
The instructions your AI receives, as published by xlang-ai/cua-gym in .claude/skills/libreoffice-impress/SKILL.md and read by ahel’s review.
This skill teaches setup-gen (create/modify pptx) and reward-gen (read/verify pptx) how to work with presentation files using pure Python code.
- Library:
python-pptx - Install:
pip3 install python-pptx Pillow
0. GUI Startup on VM (for setup-gen)
After generating /home/user/<task_id>_initial.pptx, setup-gen should open it in LibreOffice Impress for the GUI agent.
CRITICAL VM LIMIT: GUI launches must set DISPLAY=:0.
import os
import shlex
import subprocess
import time
def launch_gui(command: str, delay_sec: float = 1.0):
env = os.environ.copy()
env["DISPLAY"] = ":0"
subprocess.Popen(
shlex.split(command),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
time.sleep(delay_sec)
launch_gui('libreoffice --impress "/home/user/<task_id>_initial.pptx"', delay_sec=2.0)
Guidelines:
- Open initial artifact, never golden artifact.
- Use non-blocking launch (
Popen) and short delays.
1. Creating & Writing Files (setup-gen)
Basic Presentation & Slides
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.enum.text import PP_ALIGN
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE_TYPE
import shutil, copy, zipfile, xml.etree.ElementTree as ET
prs = Presentation()
# Layouts: 0=Title Slide, 1=Title+Content, 5=Blank, 6=Title Only (default template)
slide = prs.slides.add_slide(prs.slide_layouts[0])
slide.shapes.title.text = "My Presentation"
slide.placeholders[1].text = "Subtitle"
# Portrait orientation: width < height
prs.slide_width = Inches(7.5)
prs.slide_height = Inches(10)
prs.save("/home/user/output.pptx")
Text Boxes & Font Formatting
slide = prs.slides.add_slide(prs.slide_layouts[5]) # blank
txBox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(6), Inches(2))
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = "Centered text"
p.alignment = PP_ALIGN.CENTER # LEFT, CENTER, RIGHT, JUSTIFY
# Font on runs
run = p.runs[0]
run.font.name = "Arial"
run.font.size = Pt(24)
run.font.bold = True
run.font.italic = True
run.font.underline = True
run.font.color.rgb = RGBColor(0xFF, 0x00, 0x00)
# Add run, add paragraph
run2 = p.add_run()
run2.text = " blue text"
run2.font.color.rgb = RGBColor(0x00, 0x00, 0xFF)
p2 = tf.add_paragraph()
p2.text = "Second paragraph"
p2.level = 1 # indent level (0-8), controls bullet depth
Strikethrough & Bullets via XML
from pptx.oxml.ns import qn
# Strikethrough (no direct API)
run.font._element.attrib['strike'] = 'sngStrike' # 'noStrike', 'sngStrike', 'dblStrike'
# Custom bullet character + color
pPr = p._p.get_or_add_pPr()
pPr.append(pPr.makeelement(qn('a:buChar'), {'char': '●'}))
buClr = pPr.makeelement(qn('a:buClr'), {})
buClr.append(buClr.makeelement(qn('a:srgbClr'), {'val': 'FF0000'}))
pPr.append(buClr)
Tables
table_shape = slide.shapes.add_table(4, 3, Inches(1), Inches(2), Inches(6), Inches(2))
table = table_shape.table
table.cell(0, 0).text = "Header"
table.columns[0].width = Inches(2)
# Style cell text
for run in table.cell(0, 0).text_frame.paragraphs[0].runs:
run.font.bold = True
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
Images
# Add with position/size; stretch to fill; center on slide
pic = slide.shapes.add_picture("/home/user/img.png", Inches(1), Inches(1), Inches(4), Inches(3))
pic = slide.shapes.add_picture("/home/user/img.png", 0, 0, prs.slide_width, prs.slide_height)
w, h = Inches(4), Inches(3)
pic = slide.shapes.add_picture("/home/user/img.png", (prs.slide_width-w)//2, (prs.slide_height-h)//2, w, h)
Background Color & Notes
# Background
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = RGBColor(0x1A, 0x1A, 0x2E)
# Notes
slide.notes_slide.notes_text_frame.text = "Speaker notes here"
Duplicate & Reorder Slides
def duplicate_slide(prs, slide):
new_slide = prs.slides.add_slide(slide.slide_layout)
for shape in slide.shapes:
new_slide.shapes._spTree.append(copy.deepcopy(shape.element))
if slide.background.fill.type is not None:
new_slide.background._element.getparent().replace(
new_slide.background._element, copy.deepcopy(slide.background._element))
return new_slide
def move_slide(prs, old_idx, new_idx):
xml_slides = prs.slides._sldIdLst
slides = list(xml_slides)
el = slides[old_idx]
xml_slides.remove(el)
xml_slides.insert(new_idx, el) if new_idx < len(slides) else xml_slides.append(el)
Modifying Existing Files
shutil.copy("/home/user/original.pptx", "/home/user/modified.pptx") # ALWAYS copy first
prs = Presentation("/home/user/modified.pptx")
for shape in prs.slides[0].shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
for run in para.runs:
run.font.name = "Times New Roman"
prs.save("/home/user/modified.pptx")
2. Reading & Verifying Files (reward-gen)
Loading & Basic Properties
prs = Presentation("/path/to/file.pptx")
num_slides = len(prs.slides)
is_portrait = prs.slide_width < prs.slide_height
Reading Text & Font Properties
for shape in prs.slides[0].shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
# para.text, para.level, para.alignment (PP_ALIGN or None)
for run in para.runs:
# run.text, run.font.name, run.font.size (EMU), run.font.bold/italic/underline (True/False/None)
if run.font.color.type is not None:
rgb = run.font.color.rgb # RGBColor; str(rgb) → "FF0000"
strike = run.font._element.attrib.get('strike', 'noStrike')
Verifying Shape Position & Size
def is_approximately_equal(val1, val2, tolerance=0.005):
if val1 == val2: return True
if val1 == 0 or val2 == 0: return val1 == val2
return abs(val1 - val2) / max(abs(val1), abs(val2)) <= tolerance
shape = prs.slides[0].shapes[0]
# shape.left, shape.top, shape.width, shape.height — all in EMU (914400 EMU = 1 inch)
Verifying Tables
for shape in slide.shapes:
if shape.shape_type == MSO_SHAPE_TYPE.TABLE:
table = shape.table
# len(table.rows), len(table.columns), table.cell(r, c).text
for para in table.cell(0, 0).text_frame.paragraphs:
for run in para.runs:
# run.font.bold, run.font.color.rgb, etc.
pass
Verifying Images
for shape in slide.shapes:
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE: # type 13
# shape.left, shape.top, shape.width, shape.height
img_blob = shape.image.blob # bytes — use for identity comparison
Verifying Background & Notes
def get_slide_background_rgb(slide):
fill = slide.background.fill
if fill.type == 1: return fill.fore_color.rgb
elif fill.type == 5: # inherited from master
master_fill = slide.slide_layout.slide_master.background.fill
return master_fill.fore_color.rgb if master_fill.type == 1 else None
return None
def get_slide_notes(slide):
try: return slide.notes_slide.notes_text_frame.text.strip()
except: return ""
Getting All Text Shapes (Including Groups)
def get_all_text_shapes(slide):
def extract(shape):
results = []
if hasattr(shape, "text") and hasattr(shape, "text_frame"):
results.append(shape)
if hasattr(shape, 'shapes'):
for sub in shape.shapes:
results.extend(extract(sub))
return results
out = []
for shape in slide.shapes:
out.extend(extract(shape))
return out
Verifying Transitions (via ZIP/XML)
def check_transition(pptx_path, slide_idx, expected_type):
"""slide_idx is 0-based. expected_type: 'dissolve', 'fade', 'push', etc."""
ns = {'p': 'http://schemas.openxmlformats.org/presentationml/2006/main'}
with zipfile.ZipFile(pptx_path, 'r') as zf:
try:
with zf.open(f'ppt/slides/slide{slide_idx + 1}.xml') as f:
root = ET.parse(f).getroot()
tr = root.find('.//p:transition', ns)
return tr is not None and tr.find(f'.//p:{expected_type}', ns) is not None
except KeyError:
return False
Verifying Bullets & Page Number Colors (via ZIP/XML)
def extract_bullets(pptx_path, slide_idx):
ns = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}
bullets = []
with zipfile.ZipFile(pptx_path, 'r') as zf:
with zf.open(f'ppt/slides/slide{slide_idx + 1}.xml') as f:
root = ET.parse(f).getroot()
for para in root.findall('.//a:p', ns):
pPr = para.find('a:pPr', ns)
lvl = pPr.get('lvl') if pPr is not None else None
buChar = pPr.find('a:buChar', ns) if pPr is not None else None
char = buChar.get('char') if buChar is not None else None
text = "".join(t.text or "" for t in para.findall('.//a:t', ns))
if text.strip():
bullets.append((lvl, char, text))
return bullets
def get_page_number_color(pptx_path):
ns = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main'}
with zipfile.ZipFile(pptx_path, 'r') as zf:
with zf.open('ppt/slideMasters/slideMaster1.xml') as f:
root = ET.parse(f).getroot()
for ph in root.findall('.//p:ph[@type="sldNum"]', ns):
clr = ph.find('.//a:solidFill//a:srgbClr', ns)
if clr is not None:
return clr.get('val') # e.g. "FF0000"
return None
Comparison Helpers for reward.py
def check_font_prop(run, prop, expected):
actual = getattr(run.font, prop)
if prop in ('bold', 'italic'): # normalize None→False
actual = False if actual is None else actual
expected = False if expected is None else expected
return actual == expected
def nonempty_runs(para):
return [r for r in para.runs if (r.text or "").strip()]
3. Bitter Lessons
-
NonevsFalsefor bold/italic.run.font.boldreturnsNone(inherit),True, orFalse. TreatNoneandFalseas equivalent ("not bold"). Same for italic. -
Alignment
Nonemeans LEFT.para.alignmentisNonewhen no explicit alignment is set, defaulting to left. NormalizeNonetoPP_ALIGN.LEFTwhen comparing. -
Strikethrough requires XML access. No
run.font.strikethrough. Userun.font._element.attrib.get('strike', 'noStrike'). Values:'noStrike','sngStrike','dblStrike'. -
Empty paragraph run normalization. Empty paragraphs may have 0 runs or 1 empty run. Filter with
[r for r in para.runs if (r.text or "").strip()]to avoid false mismatches. -
Font color can be None or theme-based.
run.font.color.rgbraisesAttributeErrorif type is not RGB. Always checkrun.font.color.type is not Nonefirst, or wrap in try/except. -
Shape positions use EMU, not inches. All values are EMU (914400 = 1 inch). Use
Inches()/Emu(). Never compare to float inches. -
Transitions & page number colors need ZIP/XML. Not accessible via python-pptx API. Parse
ppt/slides/slideN.xmlfor transitions,ppt/slideMasters/slideMaster1.xmlfor page number colors. -
Background fill type 5 = inherited from master. Fall back to
slide.slide_layout.slide_master.background.fillfor actual color. -
Layout indices vary by template. When modifying existing files, use the slide's own layout instead of assuming index mappings.
-
Copy-then-modify for golden files.
shutil.copy(initial, golden)then modify. From-scratch files lack theme/master data, causing comparison failures. -
GROUP shapes hide nested text. Recursively traverse
shape.shapesto find text in GROUP shapes. The evaluator checks these. -
Image identity = binary blob.
shape.image.blobmatches byte-for-byte. Resize/reposition doesn't change the blob. -
Position tolerance: 0.5%. Use relative tolerance
abs(a-b)/max(a,b) <= 0.005for shape comparisons. -
Accessing
slide.notes_slidecreates one. If notes shouldn't exist, don't access this property during setup.
Signals
- GitHub stars
- 197
- Forks
- 18
- Last commit
- Aug 2026
ahel review
K1binfo
installs-packages
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Catalog kind
- skill
- Gateway key
libreoffice-impress- Source
- github.com/xlang-ai/cua-gym