| name | libreoffice-writer |
| description | How to programmatically create, modify, and verify LibreOffice Writer (.docx) files using Python python-docx. For setup-gen and reward-gen agents. |
| user-invocable | false |
LibreOffice Writer — Python Manipulation Guide
This skill teaches setup-gen (create/modify docx) and reward-gen (read/verify docx) how to work with document files using pure Python code.
- Library:
python-docx
- Install:
pip3 install python-docx Pillow
0. GUI Startup on VM (for setup-gen)
After generating /home/user/<task_id>_initial.docx, setup-gen should open it in LibreOffice Writer 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 --writer "/home/user/<task_id>_initial.docx"', 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 Document
from docx import Document
from docx.shared import Pt, Inches, RGBColor, Emu
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT, WD_TAB_ALIGNMENT, WD_TAB_LEADER, WD_COLOR_INDEX
from docx.enum.section import WD_ORIENT, WD_SECTION_START
from docx.oxml.ns import qn
import shutil
doc = Document()
doc.add_heading("Title", level=0)
para = doc.add_paragraph("First paragraph.")
doc.add_paragraph("Bullet item", style="List Bullet")
doc.add_paragraph("Numbered item", style="List Number")
doc.save("/home/user/output.docx")
Font Formatting (Runs)
para = doc.add_paragraph()
run = para.add_run("Styled text")
run.bold = True
run.italic = True
run.underline = True
run.font.strike = True
run.font.name = "Times New Roman"
run.font.size = Pt(14)
run.font.color.rgb = RGBColor(0xFF, 0x00, 0x00)
run_sub = para.add_run("2")
run_sub.font.subscript = True
Paragraph Formatting
para = doc.add_paragraph("Centered")
para.paragraph_format.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
para.paragraph_format.line_spacing = 2.0
para.paragraph_format.space_before = Pt(12)
para.paragraph_format.space_after = Pt(6)
para.paragraph_format.left_indent = Inches(0.5)
para.paragraph_format.first_line_indent = Inches(0.25)
para.paragraph_format.page_break_before = True
Tab Stops
tab_stops = para.paragraph_format.tab_stops
tab_stops.add_tab_stop(Inches(2), WD_TAB_ALIGNMENT.LEFT)
tab_stops.add_tab_stop(Inches(3.5), WD_TAB_ALIGNMENT.CENTER)
tab_stops.add_tab_stop(Inches(6), WD_TAB_ALIGNMENT.RIGHT, WD_TAB_LEADER.DOTS)
para.add_run("Col1\tCol2\tCol3")
Tables
table = doc.add_table(rows=3, cols=4)
table.style = "Table Grid"
table.cell(0, 0).text = "Header"
cell = table.cell(1, 0)
run = cell.paragraphs[0].add_run("Bold red")
run.bold = True
run.font.color.rgb = RGBColor(0xFF, 0x00, 0x00)
table.add_row().cells[0].text = "New row"
table.cell(0, 0).merge(table.cell(0, 1))
Images
doc.add_picture("/home/user/image.png", width=Inches(4))
run = table.cell(1, 2).paragraphs[0].add_run()
run.add_picture("/home/user/image.png", width=Inches(1.5))
Page Breaks
doc.add_page_break()
run = para.add_run()
br = run._element.makeelement(qn('w:br'), {qn('w:type'): 'page'})
run._element.append(br)
Headers, Footers & Page Numbers
section = doc.sections[0]
header = section.header
header.is_linked_to_previous = False
header.paragraphs[0].text = "Document Header"
footer = section.footer
footer.is_linked_to_previous = False
fp = footer.paragraphs[0]
fp.text = "Page "
r1 = fp.add_run()
r1._element.append(r1._element.makeelement(qn('w:fldChar'), {qn('w:fldCharType'): 'begin'}))
r2 = fp.add_run()
instr = r2._element.makeelement(qn('w:instrText'), {})
instr.text = ' PAGE '
r2._element.append(instr)
r3 = fp.add_run()
r3._element.append(r3._element.makeelement(qn('w:fldChar'), {qn('w:fldCharType'): 'end'}))
Page Setup
section = doc.sections[0]
section.page_width = Inches(8.5)
section.page_height = Inches(11)
section.left_margin = Inches(1)
section.right_margin = Inches(1)
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.orientation = WD_ORIENT.LANDSCAPE
section.page_width, section.page_height = section.page_height, section.page_width
Highlighting
run = para.add_run("Highlighted")
run.font.highlight_color = WD_COLOR_INDEX.YELLOW
run.font.highlight_color = None
Modifying Existing Files
shutil.copy("/home/user/original.docx", "/home/user/modified.docx")
doc = Document("/home/user/modified.docx")
for para in doc.paragraphs:
for run in para.runs:
run.font.name = "Calibri"
doc.save("/home/user/modified.docx")
2. Reading & Verifying Files (reward-gen)
Loading & Structure
from docx import Document
from docx.shared import Pt, RGBColor
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT, WD_TAB_ALIGNMENT
doc = Document("/path/to/file.docx")
num_paras = len(doc.paragraphs)
num_tables = len(doc.tables)
num_sections = len(doc.sections)
Reading Text & Font Properties
for para in doc.paragraphs:
for run in para.runs:
run.text
run.font.name
run.font.size
run.font.bold
run.font.italic
run.font.underline
run.font.strike
run.font.subscript
run.font.superscript
run.font.color.rgb
run.font.highlight_color
Verifying Paragraph Format
pf = para.paragraph_format
pf.alignment
pf.line_spacing
pf.space_before
pf.space_after
pf.left_indent
pf.first_line_indent
pf.page_break_before
Verifying Tab Stops
for ts in para.paragraph_format.tab_stops:
if ts.alignment == WD_TAB_ALIGNMENT.CLEAR: continue
if ts.alignment == WD_TAB_ALIGNMENT.LEFT and ts.position == 0: continue
print(f"Alignment={ts.alignment}, Position={ts.position}")
Verifying Tables
for table in doc.tables:
for i, row in enumerate(table.rows):
for j, cell in enumerate(row.cells):
text = cell.text.strip()
for para in cell.paragraphs:
for run in para.runs:
pass
Verifying Images
from io import BytesIO
from PIL import Image
def extract_images(doc):
images = []
for rel in doc.part.rels.values():
if "image" in rel.reltype:
images.append(BytesIO(rel.target_part.blob))
return images
has_image = 'graphicData' in run._element.xml
Verifying Page Breaks
def count_page_breaks(doc):
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
count = 0
for para in doc.paragraphs:
for run in para.runs:
for br in run.element.findall('.//w:br', ns):
if br.attrib.get(f'{{{ns["w"]}}}type') == 'page':
count += 1
return count
Verifying Headers, Footers & Page Numbers
section = doc.sections[0]
header_text = section.header.paragraphs[0].text if section.header.paragraphs else ""
footer_text = section.footer.paragraphs[0].text if section.footer.paragraphs else ""
has_page_num = any(c.isdigit() for c in footer_text)
Verifying Page Setup
s = doc.sections[0]
from docx.enum.section import WD_ORIENT
is_landscape = s.orientation == WD_ORIENT.LANDSCAPE
Verifying Highlighting & Strikethrough
for run in para.runs:
if run.font.highlight_color is not None:
print(f"Highlighted: '{run.text}' color={run.font.highlight_color}")
last_para = doc.paragraphs[-1]
all_strike = all(run.font.strike for run in last_para.runs if run.text.strip())
Verifying Case Conversion
def has_uppercase(doc):
for para in doc.paragraphs:
for run in para.runs:
if run.text.strip() and run.text.isupper(): return True
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
for run in para.runs:
if run.text.strip() and run.text.isupper(): return True
return False
Verifying Colored Words in Tables
from math import sqrt
def color_distance(c1, c2):
return sqrt(sum((a - b) ** 2 for a, b in zip(c1, c2)))
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
for run in para.runs:
if run.text and run.font.color.rgb:
first = run.text[0].lower()
if first in 'aeiou':
assert color_distance(run.font.color.rgb, RGBColor(255,0,0)) < 50
else:
assert color_distance(run.font.color.rgb, RGBColor(0,0,255)) < 50
ODF (.odt) File Verification
from odf.opendocument import load
from odf.text import P, Span
odt_doc = load("/path/to/file.odt")
for para in odt_doc.getElementsByType(P):
text_parts = []
for node in para.childNodes:
if node.nodeType == node.TEXT_NODE:
text_parts.append(node.data)
elif node.nodeType == node.ELEMENT_NODE and node.tagName == 'text:span':
for child in node.childNodes:
if child.nodeType == child.TEXT_NODE:
text_parts.append(child.data)
for span in odt_doc.getElementsByType(Span):
style_name = span.getAttribute('stylename')
if style_name:
for auto_style in odt_doc.automaticstyles.childNodes:
if auto_style.getAttribute('name') == style_name:
for prop in auto_style.childNodes:
if prop.getAttribute('backgroundcolor') == '#ffff00':
print("Yellow highlight found!")
Common Helpers for reward.py
import re
def compare_text(doc1, doc2, ignore_blanks=True):
p1 = [p.text for p in doc1.paragraphs]
p2 = [p.text for p in doc2.paragraphs]
if ignore_blanks:
return re.sub(r'\s+', ' ', '\n'.join(p1)).strip() == re.sub(r'\s+', ' ', '\n'.join(p2)).strip()
return p1 == p2
def check_all_font_name(doc, expected):
return all(run.font.name == expected for para in doc.paragraphs for run in para.runs if run.font.name)
def check_italic_size(doc, expected_pt):
return all(run.font.size and run.font.size.pt == expected_pt
for para in doc.paragraphs for run in para.runs if run.italic)
3. Bitter Lessons
-
run.font.size is EMU, not points. run.font.size == 14 fails. Use run.font.size.pt == 14 or run.font.size == Pt(14).
-
para.text concatenates runs, losing formatting. To check per-run formatting (bold word + normal), iterate para.runs. Never rely on para.text for style checks.
-
None means "inherited" for font properties. run.font.bold is None means "inherit from style". If the style is bold, None means bold. Usually treat None as False, but be aware.
-
Page breaks are inside runs, not paragraphs. Manual breaks are <w:br w:type="page"/> in run elements. Use XML parsing: run.element.findall('.//w:br', ns). Cannot detect via para.text.
-
Highlighting differs between .docx and .odt. run.font.highlight_color works for .docx. ODT stores highlights in automatic styles as backgroundcolor. Need different code paths.
-
Copy-then-modify for golden files. shutil.copy(initial, golden) then modify. Scratch files lack styles, numbering, and metadata causing comparison failures.
-
Tab stop comparison must filter defaults. LibreOffice adds default LEFT@0 and CLEAR stops. Filter them out to avoid false mismatches.
-
ignore_blanks=True collapses all whitespace. When paragraph structure (empty lines) matters, use ignore_blanks=False and compare paragraph-by-paragraph.
-
Footer page numbers are field codes, not text. <w:fldChar> + <w:instrText> PAGE </w:instrText>. The .text property shows cached values. Check for digit presence, not exact number.
-
doc.part.rels images include ALL images. Headers, footers, textboxes too. Not just body. Filter if needed.
-
para.runs may miss hyperlink/field text. para.text includes all text, but ''.join(r.text for r in para.runs) may be shorter. Use para.text for full text comparison.
-
Color comparison: use perceptual distance. The evaluator uses CIEDE2000 (Delta E) with threshold ~3.5 from skimage.color.deltaE_ciede2000. Simple RGB distance is imprecise.
-
Equations are OLE objects. Detect with run.element.xpath('.//w:object'). No readable text via python-docx.
-
Multiple gold files for tolerance. LibreOffice formatting varies by version/fonts. Provide 2-4 gold files with OR logic in reward scripts.