Skip to content

Instantly share code, notes, and snippets.

@artlbv
Created June 16, 2026 08:12
Show Gist options
  • Select an option

  • Save artlbv/482e2c8924cbcd0cdb8b1816a6f4677e to your computer and use it in GitHub Desktop.

Select an option

Save artlbv/482e2c8924cbcd0cdb8b1816a6f4677e to your computer and use it in GitHub Desktop.
CMS DP note PPTX to TWIKI converter
#!/usr/bin/env python3
"""
pptx_to_twiki.py
================
Convert a CMS DP-note PowerPoint file into a TWiki source file and a folder
of individual plot PNG images, following the style of CMS public DP note pages
(e.g. https://twiki.cern.ch/twiki/bin/view/CMSPublic/DP2026001).
Usage
-----
python pptx_to_twiki.py input.pptx [options]
Options
-------
--out-dir DIR Directory to write outputs (default: <pptx_stem>_twiki/)
--dp-number STR DP note number, e.g. 2026/042 (default: 2026/XXX)
--cds-record STR CDS record number or full URL (default: XXXXXXX)
--title STR Override document title (default: taken from slide 1)
--figures-section N Place the Figures section as section N (default: auto,
= last text section + 1)
--no-latex Skip automatic physics-notation LaTeX substitutions
Requirements
------------
pip install python-pptx Pillow
"""
import argparse
import os
import re
import shutil
import sys
import textwrap
from pathlib import Path
try:
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
except ImportError:
sys.exit("ERROR: python-pptx is required. Run: pip install python-pptx")
# ---------------------------------------------------------------------------
# Physics-notation LaTeX substitutions
# Applied to all text before writing to the TWiki source.
# Order matters: longer patterns before shorter ones.
# ---------------------------------------------------------------------------
LATEX_SUBS = [
# Arrows and Greek in decay chains
(r'→', r'\\rightarrow '),
(r'->', r'\\rightarrow '),
(r'τh', r'\\tau_h'),
(r'τµ', r'\\tau_\\mu'),
(r'τ', r'\\tau'),
(r'µ', r'\\mu'),
(r'η', r'\\eta'),
# Superscripts / subscripts written as plain text
(r'fb-1', r'fb%$^{-1}$%'),
(r'fb−1', r'fb%$^{-1}$%'),
# Kinematic variables
(r'\bpT\b', r'%$p_T$%'),
(r'\bp_T\b', r'%$p_T$%'),
(r'\bHT\b', r'%$H_T$%'),
(r'\bH_T\b', r'%$H_T$%'),
(r'\bmHH\b', r'%$m_{HH}$%'),
(r'\bm_HH\b', r'%$m_{HH}$%'),
(r'\bm_{HH}\b', r'%$m_{HH}$%'),
(r'\bsqrt\(s\)', r'%$\\sqrt{s}$%'),
(r'√s', r'%$\\sqrt{s}$%'),
# tt-bar
(r'tt̄', r'%$t\\bar{t}$%'),
(r'tt-bar', r'%$t\\bar{t}$%'),
# Delta
(r'\bΔR\b', r'%$\\Delta R$%'),
(r'\bΔη\b', r'%$\\Delta\\eta$%'),
# Wrap bare numbers with units that follow GeV
(r'(\d+)\s*GeV', r'%$\1$% GeV'),
(r'(\d+)\s*TeV', r'%$\1$% TeV'),
# Subscript formatting for L1HT
(r'\bL1HT\b', r'L1%$H_T$%'),
]
# Slide titles that trigger special handling instead of going into the
# generic Figures section or text sections.
REFERENCES_TITLES = {'references', 'bibliography'}
GLOSSARY_TITLES = {'glossary', 'abbreviations'}
SUMMARY_TITLES = {'summary', 'conclusions', 'conclusion'}
SKIP_TITLES = {'title', ''} # slide 1 (title slide) is skipped
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def slugify(text: str, max_len: int = 50) -> str:
"""Turn arbitrary text into a filesystem-safe slug."""
text = text.strip()
text = re.sub(r'[^\w\s\-]', '', text)
text = re.sub(r'\s+', '_', text)
text = re.sub(r'_+', '_', text)
return text[:max_len].strip('_')
def apply_latex(text: str, do_latex: bool) -> str:
"""Apply physics-notation LaTeX substitutions."""
if not do_latex:
return text
for pattern, replacement in LATEX_SUBS:
try:
text = re.sub(pattern, replacement, text)
except re.error:
text = text.replace(pattern, replacement)
return text
def wrap_math(text: str) -> str:
"""Wrap bare \\frac{} etc that aren't already in %$...$% markers."""
# Ensure any remaining bare \command{} blocks are wrapped
text = re.sub(r'(?<!%\$)(\\[a-zA-Z]+\{[^}]*\})(?!\$%)', r'%$\1$%', text)
return text
def get_slide_title(slide) -> str:
"""Return the title text of a slide (first placeholder of type TITLE or
the first text box whose text is short enough to be a title)."""
for shape in slide.placeholders:
if shape.placeholder_format.idx == 0: # idx 0 = title
return shape.text.strip()
# Fallback: first short text shape
for shape in slide.shapes:
if hasattr(shape, 'text') and shape.text.strip():
t = shape.text.strip().split('\n')[0]
if len(t) < 120:
return t
return ''
def get_slide_body_texts(slide) -> list[str]:
"""Return all non-title, non-page-number text blocks from a slide."""
texts = []
title = get_slide_title(slide)
for shape in slide.shapes:
if not hasattr(shape, 'text'):
continue
t = shape.text.strip()
if not t or t == '‹#›' or t == title:
continue
# Skip tiny label-only shapes (likely legend labels etc.)
if len(t) < 4:
continue
texts.append(t)
return texts
def get_visible_images(slide, slide_w: float, slide_h: float) -> list[dict]:
"""Return list of dicts for each image fully or partly on the slide canvas."""
images = []
for shape in slide.shapes:
if shape.shape_type != 13: # MSO_SHAPE_TYPE.PICTURE = 13
continue
try:
rId = shape._element.blipFill.blip.rEmbed
rel = slide.part.rels[rId]
fname = os.path.basename(rel.target_ref)
img = shape.image
except Exception:
continue
w = shape.width.inches
h = shape.height.inches
l = shape.left.inches
t = shape.top.inches
# Skip if entirely outside the slide canvas
if l >= slide_w or t >= slide_h or l + w <= 0 or t + h <= 0:
continue
# Skip tiny images (logos, watermarks, labels) — heuristic: < 0.5" in
# either dimension
if w < 0.5 or h < 0.5:
continue
images.append({
'fname': fname,
'left': l,
'top': t,
'width': w,
'height': h,
'blob': img.blob,
'ext': img.ext, # 'png', 'jpeg', …
})
# Sort left-to-right
images.sort(key=lambda x: x['left'])
return images
def figure_filename(slide_num: int, sub_idx: int, title_slug: str,
n_images: int) -> str:
"""Build a figure PNG filename."""
prefix = f'Figure_{slide_num:03d}'
if n_images > 1:
suffix = chr(ord('a') + sub_idx) # a, b, c, …
return f'{prefix}-{suffix}_{title_slug}.png'
return f'{prefix}_{title_slug}.png'
def twiki_figure_block(png_name: str, caption: str) -> str:
"""Render the standard CMS TWiki figure table block."""
return (
'| Figure | Caption |||\n'
f'| [[%ATTACHURLPATH%/{png_name}]'
f'[<img alt="" src="%ATTACHURL%/{png_name}" height="300/" />]] '
f'| <p> {caption} </br> '
f'[[[%ATTACHURL%/{png_name}][Get png version]]] <br> </p> |||\n'
)
def format_references(text: str) -> str:
"""Convert a slide reference list into TWiki numbered list with links."""
lines = []
for line in text.split('\n'):
line = line.strip()
if not line:
continue
# Try to extract a URL and linkify it
url_match = re.search(r'(https?://\S+)', line)
if url_match:
url = url_match.group(1).rstrip('.,;)')
line = line.replace(url_match.group(1), f'[[{url}][{url}]]')
lines.append(f' 1. {line}')
return '\n'.join(lines)
def format_glossary(text: str) -> str:
"""Convert a bullet-list glossary slide into TWiki bullet list."""
lines = []
for line in text.split('\n'):
line = line.strip().lstrip('•*–-').strip()
if not line:
continue
lines.append(f' * {line}')
return '\n'.join(lines)
# ---------------------------------------------------------------------------
# Main converter
# ---------------------------------------------------------------------------
def convert(pptx_path: str, out_dir: str, dp_number: str, cds_record: str,
title_override: str | None, figures_section: int | None,
do_latex: bool) -> None:
prs = Presentation(pptx_path)
slide_w = prs.slide_width.inches
slide_h = prs.slide_height.inches
out_path = Path(out_dir)
figs_path = out_path / 'figures'
out_path.mkdir(parents=True, exist_ok=True)
figs_path.mkdir(parents=True, exist_ok=True)
# ---- Pass 1: classify every slide ----
classified = [] # list of dicts
for i, slide in enumerate(prs.slides):
slide_num = i + 1
title = get_slide_title(slide)
tl = title.lower().strip()
bodies = get_slide_body_texts(slide)
images = get_visible_images(slide, slide_w, slide_h)
if slide_num == 1:
kind = 'title'
elif tl in REFERENCES_TITLES:
kind = 'references'
elif tl in GLOSSARY_TITLES:
kind = 'glossary'
elif tl in SUMMARY_TITLES:
kind = 'summary'
else:
kind = 'figures' if images else 'text'
classified.append({
'num': slide_num,
'title': title,
'bodies': bodies,
'images': images,
'kind': kind,
})
# ---- Determine document title ----
doc_title = title_override or classified[0]['title'] if classified else 'CMS DP Note'
# ---- Determine auto section numbering ----
text_slides = [s for s in classified if s['kind'] in ('text', 'summary')]
figure_slides = [s for s in classified if s['kind'] == 'figures']
n_text_sections = len(text_slides)
if figures_section is None:
fig_sec_num = n_text_sections + 1
else:
fig_sec_num = figures_section
ref_sec_num = fig_sec_num + 1
glos_sec_num = fig_sec_num + 2
# ---- Pass 2: extract PNG files ----
# Map (slide_num, sub_idx) -> png filename
fig_map = {}
for s in figure_slides:
n_imgs = len(s['images'])
title_slug = slugify(s['title'])
for j, img in enumerate(s['images']):
png_name = figure_filename(s['num'], j, title_slug, n_imgs)
dest = figs_path / png_name
with open(dest, 'wb') as fh:
fh.write(img['blob'])
fig_map[(s['num'], j)] = png_name
# ---- Pass 3: build TWiki source ----
lines = []
# Header
lines += [
'<noautolink />',
'<div style="text-align: justify;">',
f'---+ {doc_title} (CMS-DP-{dp_number})',
f'*Status:* Results are published in the DPS note '
f'[[https://cds.cern.ch/record/{cds_record}][DP-{dp_number}]]',
]
# Text sections
sec_num = 0
for s in classified:
if s['kind'] not in ('text', 'summary'):
continue
sec_num += 1
title_tw = apply_latex(s['title'], do_latex)
lines += ['', f'---++ {sec_num}. {title_tw}']
for body in s['bodies']:
body_tw = apply_latex(body, do_latex)
# Split long body into paragraphs at double newlines
paras = re.split(r'\n{2,}', body_tw)
for para in paras:
para = para.strip()
if para:
lines += [para, '']
# Figures section
lines += [
'',
f'---++ {fig_sec_num}. Figures',
'<span style="font-size: small;">'
'<span style="-webkit-border-horizontal-spacing: 2px; '
'-webkit-border-vertical-spacing: 2px;"><br /></span></span>',
]
for s in figure_slides:
n_imgs = len(s['images'])
# Build caption from body texts
raw_caption = ' '.join(s['bodies'])
caption = apply_latex(raw_caption, do_latex)
caption = caption.replace('\n', ' ').strip()
for j in range(n_imgs):
png_name = fig_map.get((s['num'], j), '')
if not png_name:
continue
lines.append('')
lines.append(twiki_figure_block(png_name, caption))
# References section
ref_slides = [s for s in classified if s['kind'] == 'references']
if ref_slides:
lines += ['', f'---++ {ref_sec_num}. References']
for s in ref_slides:
raw = '\n'.join(s['bodies'])
lines.append(format_references(raw))
# Glossary section
glos_slides = [s for s in classified if s['kind'] == 'glossary']
if glos_slides:
lines += ['', f'---++ {glos_sec_num}. Glossary', '']
for s in glos_slides:
raw = '\n'.join(s['bodies'])
lines.append(format_glossary(raw))
lines.append('') # trailing newline
# ---- Write TWiki file ----
pptx_stem = Path(pptx_path).stem
twiki_file = out_path / f'{slugify(pptx_stem)}_twiki.txt'
with open(twiki_file, 'w', encoding='utf-8') as fh:
fh.write('\n'.join(lines))
# ---- Summary ----
n_figs = len(fig_map)
print(f'Done.')
print(f' TWiki source : {twiki_file}')
print(f' Figures : {figs_path} ({n_figs} PNG files)')
print()
print('To publish:')
print(f' 1. Create CMSPublic.DP{dp_number.replace("/", "")} and paste the TWiki source.')
print(f' 2. Upload all {n_figs} PNG files from figures/ as attachments.')
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description='Convert a CMS DP-note PPTX file to TWiki source + PNG figures.',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Examples
--------
# Basic conversion
python pptx_to_twiki.py "HLT TOPO DP Note 2026 published.pptx"
# Specify DP number and CDS record
python pptx_to_twiki.py note.pptx --dp-number 2026/042 --cds-record 2951848
# Custom output directory
python pptx_to_twiki.py note.pptx --out-dir ./my_twiki_output/
"""),
)
parser.add_argument('pptx', help='Path to the input .pptx file')
parser.add_argument('--out-dir', default=None,
help='Output directory (default: <stem>_twiki/)')
parser.add_argument('--dp-number', default='2026/XXX',
help='DP note number, e.g. 2026/042')
parser.add_argument('--cds-record', default='XXXXXXX',
help='CDS record number or full URL')
parser.add_argument('--title', default=None,
help='Override document title')
parser.add_argument('--figures-section', default=None, type=int,
help='Force the Figures section to be section N')
parser.add_argument('--no-latex', action='store_true',
help='Disable automatic physics-notation LaTeX substitutions')
args = parser.parse_args()
if not os.path.isfile(args.pptx):
sys.exit(f'ERROR: File not found: {args.pptx}')
out_dir = args.out_dir or (Path(args.pptx).stem + '_twiki')
convert(
pptx_path = args.pptx,
out_dir = out_dir,
dp_number = args.dp_number,
cds_record = args.cds_record,
title_override = args.title,
figures_section = args.figures_section,
do_latex = not args.no_latex,
)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment