Created
June 16, 2026 08:12
-
-
Save artlbv/482e2c8924cbcd0cdb8b1816a6f4677e to your computer and use it in GitHub Desktop.
CMS DP note PPTX to TWIKI converter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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