#!/usr/bin/env python3 """ Genera slides.pptx desde slides.md usando la plantilla Diplomados Ciberseguridad. Los backgrounds se copian directamente desde los slides del template. """ from pptx import Presentation from pptx.util import Pt, Emu from pptx.dml.color import RGBColor from lxml import etree import copy, re, zipfile, io, tempfile, os TEMPLATE = "../../Red Team.pptx" SOURCE = "slides.md" OUTPUT = "slides.pptx" # Layouts en la plantilla (por nombre español) L_TITLE = 0 # Diapositiva de titulo (idx 0=ctrTitle, 1=subTitle) L_CONTENT = 1 # Titulo y objetos (idx 0=title, 1=content) L_SECTION = 2 # Encabezado de seccion (idx 0=title, 1=body) # Slides del template que sirven como fuente de backgrounds # slide1=portada, slide3=contenido, slide5=seccion divisor BG_TITLE = 0 # image1.jpg — portada con fondo binario rojo BG_CONTENT = 2 # image3.jpg — slide normal con logos y barra roja BG_SECTION = 4 # image5.jpg — divisor rojo degradado PNS = 'http://schemas.openxmlformats.org/presentationml/2006/main' ANS = 'http://schemas.openxmlformats.org/drawingml/2006/main' RNS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' IMG_REL = f'{RNS}/image' # ─── template limpio ───────────────────────────────────────────────────────── def make_clean_template(src): """Devuelve BytesIO: template sin slides (master/layouts/media intactos).""" SLIDE_XML = re.compile(r'^ppt/slides/slide\d+\.xml$') SLIDE_RELS = re.compile(r'^ppt/slides/_rels/slide\d+\.xml\.rels$') SLIDE_CT = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml' SLIDE_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide' PKG_NS = 'http://schemas.openxmlformats.org/package/2006/relationships' PML_NS = 'http://schemas.openxmlformats.org/presentationml/2006/main' CT_NS = 'http://schemas.openxmlformats.org/package/2006/content-types' buf = io.BytesIO() with zipfile.ZipFile(src, 'r') as zin, \ zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout: for item in zin.infolist(): name = item.filename if SLIDE_XML.match(name) or SLIDE_RELS.match(name): continue # eliminar slides y sus rels del ZIP data = zin.read(name) if name == 'ppt/presentation.xml': root = etree.fromstring(data) lst = root.find(f'{{{PML_NS}}}sldIdLst') if lst is not None: lst.clear() data = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) elif name == 'ppt/_rels/presentation.xml.rels': root = etree.fromstring(data) for rel in root.findall(f'{{{PKG_NS}}}Relationship'): if rel.get('Type') == SLIDE_REL_TYPE: root.remove(rel) data = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) elif name == '[Content_Types].xml': root = etree.fromstring(data) for ov in root.findall(f'{{{CT_NS}}}Override'): if ov.get('ContentType') == SLIDE_CT: root.remove(ov) data = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) zout.writestr(item, data) buf.seek(0) return buf # ─── background copy ────────────────────────────────────────────────────────── def copy_background(new_slide, template_slide): """Copia el del template_slide al new_slide, remapeando el rId de imagen.""" cSld_tmpl = template_slide._element.find(f'{{{PNS}}}cSld') bg = cSld_tmpl.find(f'{{{PNS}}}bg') if cSld_tmpl is not None else None if bg is None: return blip = bg.find(f'.//{{{ANS}}}blip') if blip is None: return old_rId = blip.get(f'{{{RNS}}}embed') if not old_rId: return # Obtener la imagen del template y relacionarla con el nuevo slide image_part = template_slide.part.related_part(old_rId) new_rId = new_slide.part.relate_to(image_part, IMG_REL) # Clonar y actualizar el rId new_bg = copy.deepcopy(bg) new_blip = new_bg.find(f'.//{{{ANS}}}blip') new_blip.set(f'{{{RNS}}}embed', new_rId) # Insertar en cSld del nuevo slide (antes de spTree) new_cSld = new_slide._element.find(f'{{{PNS}}}cSld') existing_bg = new_cSld.find(f'{{{PNS}}}bg') if existing_bg is not None: new_cSld.remove(existing_bg) new_cSld.insert(0, new_bg) # ─── helpers de texto ───────────────────────────────────────────────────────── def clean(text): text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) text = re.sub(r'\*(.*?)\*', r'\1', text) text = re.sub(r'`(.*?)`', r'\1', text) text = re.sub(r'^\s*>\s?', '', text, flags=re.MULTILINE) return text.strip() def fill_placeholder(ph, lines): tf = ph.text_frame tf.word_wrap = True tf.clear() in_code = False first_p = True for line in lines: if line.startswith('```'): in_code = not in_code continue if re.match(r'^\s*\|?[-:| ]+\|', line): continue stripped = line.rstrip() if not stripped and not in_code: continue p = tf.paragraphs[0] if first_p else tf.add_paragraph() first_p = False bullet = re.match(r'^(\s*)([-*]|\d+\.) (.+)', stripped) if bullet and not in_code: indent = len(bullet.group(1)) // 2 p.level = min(indent + 1, 4) run = p.add_run() run.text = clean(bullet.group(3)) elif in_code: # Eliminar bullet del párrafo pPr = p._p.get_or_add_pPr() for child in list(pPr): if child.tag.split('}')[-1].startswith('bu'): pPr.remove(child) pPr.append(etree.SubElement(pPr, f'{{{ANS}}}buNone')) run = p.add_run() run.text = stripped.lstrip() run.font.name = 'Courier New' run.font.size = Pt(13) run.font.color.rgb = RGBColor(0xC0, 0x00, 0x00) else: heading = re.match(r'^#{2,4} (.+)', stripped) if heading: run = p.add_run() run.text = clean(heading.group(1)) run.font.bold = True else: run = p.add_run() run.text = clean(stripped) # ─── parser ─────────────────────────────────────────────────────────────────── def parse_slides(path): with open(path) as f: raw = f.read() raw = re.sub(r'^---\n.*?\n---\n', '', raw, flags=re.DOTALL) blocks = re.split(r'\n---\n', raw) return [b.strip() for b in blocks if b.strip()] def classify(block): lines = block.split('\n') first = lines[0] if re.match(r'^# ', first): title = re.sub(r'^# ', '', first).replace('{.center}', '').strip() body = [l for l in lines[1:] if l.strip() and not l.startswith('{')] return ('section', title, body) if re.match(r'^## \{\.center\}', first) or first.strip() == '## {.center}': body = [l for l in lines[1:] if l.strip()] return ('section', '', body) if re.match(r'^## ', first): title = re.sub(r'^## ', '', first).strip() return ('content', title, lines[1:]) return ('content', '', lines) # ─── tables ─────────────────────────────────────────────────────────────────── def parse_md_table(lines): """Separa lineas en (non_table_lines, table_rows). Ignora pipes dentro de code fences para no confundirlos con tablas.""" is_table = lambda l: bool(re.match(r'\s*\|', l.strip()) and '|' in l) is_sep = lambda l: bool(re.match(r'^\s*\|?[-:| ]+\|', l)) non_table, rows = [], [] in_code = False for line in lines: if line.startswith('```'): in_code = not in_code non_table.append(line) continue if in_code: non_table.append(line) continue if is_sep(line): continue if is_table(line): cells = [clean(c.strip()) for c in line.strip().strip('|').split('|')] rows.append(cells) else: non_table.append(line) return non_table, rows def add_pptx_table(slide, rows, left, top, width, height): """Añade una tabla PPTX estilizada en las coordenadas dadas.""" if not rows: return n_rows = len(rows) n_cols = max(len(r) for r in rows) tbl = slide.shapes.add_table(n_rows, n_cols, left, top, width, height).table # Distribuir columnas proporcionalmente (col0 más estrecha = herramienta) if n_cols == 2: tbl.columns[0].width = int(width * 0.25) tbl.columns[1].width = int(width * 0.75) else: for i in range(n_cols): tbl.columns[i].width = width // n_cols RED = RGBColor(0x8B, 0x00, 0x00) WHITE = RGBColor(0xFF, 0xFF, 0xFF) BLACK = RGBColor(0x00, 0x00, 0x00) LIGHT = RGBColor(0xF2, 0xF2, 0xF2) for ri, row in enumerate(rows): header = (ri == 0) for ci in range(n_cols): cell = tbl.cell(ri, ci) text = row[ci] if ci < len(row) else '' cell.text = text cell.fill.solid() cell.fill.fore_color.rgb = RED if header else (LIGHT if ri % 2 == 0 else WHITE) for para in cell.text_frame.paragraphs: for run in para.runs: run.font.size = Pt(14) run.font.bold = header run.font.color.rgb = WHITE if header else BLACK # ─── builder ────────────────────────────────────────────────────────────────── def build(): # Template original: solo para extraer backgrounds tmpl = Presentation(TEMPLATE) tmpl_slides = list(tmpl.slides) # Template limpio: sin slides, para construir desde cero clean_buf = make_clean_template(TEMPLATE) prs = Presentation(clean_buf) blocks = parse_slides(SOURCE) def add(layout_idx, bg_idx): slide = prs.slides.add_slide(prs.slide_layouts[layout_idx]) copy_background(slide, tmpl_slides[bg_idx]) return slide # Slide 1: portada slide = add(L_TITLE, BG_TITLE) for ph in slide.placeholders: if ph.placeholder_format.idx == 0: ph.text = "Que coman tierra" elif ph.placeholder_format.idx == 1: ph.text = "De LOLBINs a ransomware\nDiplomados Ciberseguridad" # Slides desde markdown for block in blocks: kind, title, body_lines = classify(block) if kind == 'section': slide = add(L_SECTION, BG_SECTION) for ph in slide.placeholders: if ph.placeholder_format.idx == 0: ph.text = title elif ph.placeholder_format.idx == 1 and body_lines: fill_placeholder(ph, body_lines) for para in ph.text_frame.paragraphs: for run in para.runs: run.font.color.rgb = RGBColor(0x00, 0x00, 0x00) else: slide = add(L_CONTENT, BG_CONTENT) non_tbl, tbl_rows = parse_md_table(body_lines) for ph in slide.placeholders: if ph.placeholder_format.idx == 0: ph.text = title elif ph.placeholder_format.idx == 1: if tbl_rows: # Usar posicion del placeholder para colocar la tabla sp = ph._element xfrm = sp.find(f'.//{{{ANS}}}xfrm') if xfrm is not None: off = xfrm.find(f'{{{ANS}}}off') ext = xfrm.find(f'{{{ANS}}}ext') l = int(off.get('x', 0)) t = int(off.get('y', 0)) w = int(ext.get('cx', 0)) h = int(ext.get('cy', 0)) else: l, t, w, h = 838200, 1600200, 10515600, 4500000 # Si hay texto además de tabla, reducir altura de tabla if non_tbl: fill_placeholder(ph, non_tbl) t_offset = int(h * 0.45) add_pptx_table(slide, tbl_rows, l, t + t_offset, w, h - t_offset) else: ph.text = '' # vaciar placeholder add_pptx_table(slide, tbl_rows, l, t, w, h) else: fill_placeholder(ph, body_lines) prs.save(OUTPUT) print(f"OK: {OUTPUT} — {len(prs.slides)} slides.") if __name__ == '__main__': build()