lib.odp_utils

Generador de presentaciones ODP — paquete principal.

Uso:

from lib.odp_utils import SlideTemplates, load_template, save_odp, build_slides_from_yaml

 1# Copyright (C) 2026  David Vaquero <pepesan@gmail.com>
 2#
 3# This program is free software: you can redistribute it and/or modify
 4# it under the terms of the GNU General Public License as published by
 5# the Free Software Foundation, either version 3 of the License, or
 6# (at your option) any later version.
 7#
 8# This program is distributed in the hope that it will be useful,
 9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program.  If not, see <https://www.gnu.org/licenses/>.
15
16"""
17Generador de presentaciones ODP — paquete principal.
18
19Uso:
20    from lib.odp_utils import SlideTemplates, load_template, save_odp, build_slides_from_yaml
21"""
22
23DEFAULT_VERSION = '1'
24
25from .xml_helpers import xe, preserve_spaces, build_list, build_link_list, extract_slides, finalize, inject_notes, xe_multiline
26from .styles import inject_code_styles
27from .tokenizers.java import _java_pt
28from .builders.base import SlideTemplates
29from .builders.core import (
30    make_portada, make_objectives, make_index, make_single,
31    make_bullets, make_refs, make_code, make_java,
32)
33from .builders.code import (
34    make_codigo, make_javascript, make_python,
35    make_xml, make_json, make_yaml, make_html, make_jsp, make_css,
36)
37from .builders.content import (
38    make_salida, make_terminal, make_diagrama, make_tabla, make_aviso,
39    make_imagen, make_imagen_texto, make_dos_columnas,
40    make_comparar, make_comparar_python, make_comparar_js,
41    make_comparar_xml, make_comparar_jsp, make_comparar_css, make_comparar_json,
42)
43from .builders.pipeline import build_slides_from_yaml, load_template, save_odp
44
45__all__ = [
46    'DEFAULT_VERSION',
47    'SlideTemplates',
48    'load_template',
49    'save_odp',
50    'preserve_spaces',
51    'xe',
52    'make_portada',
53    'make_objectives',
54    'make_index',
55    'make_single',
56    'make_bullets',
57    'make_refs',
58    'make_code',
59    'make_java',
60    'make_salida',
61    'make_terminal',
62    'make_diagrama',
63    'make_tabla',
64    'make_aviso',
65    'make_dos_columnas',
66    'make_comparar',
67    'make_comparar_python',
68    'make_comparar_js',
69    'make_comparar_xml',
70    'make_comparar_jsp',
71    'make_comparar_css',
72    'make_comparar_json',
73    'make_codigo',
74    'make_javascript',
75    'make_python',
76    'make_yaml',
77    'make_xml',
78    'make_json',
79    'make_html',
80    'make_jsp',
81    'make_css',
82    'make_imagen',
83    'make_imagen_texto',
84    'build_slides_from_yaml',
85    # privadas/helpers usadas en tests
86    '_java_pt',
87    'build_list',
88    'build_link_list',
89    'extract_slides',
90    'finalize',
91    'inject_notes',
92    'xe_multiline',
93]
DEFAULT_VERSION = '1'
class SlideTemplates:
51class SlideTemplates:
52    """Mapeador para encapsular y desacoplar los índices mágicos de las plantillas ODP."""
53    def __init__(self, tpls: list):
54        self.portada    = tpls[0]
55        self.objectives = tpls[1]
56        self.index      = tpls[2]
57        self.single     = tpls[3]
58        self.bullets    = tpls[10]
59        self.conclusion = tpls[19]
60        self.references = tpls[20]
61        self.contact    = tpls[21]
62        self.license    = tpls[22]
63
64    @classmethod
65    def from_config(cls, tpls: list, cfg) -> 'SlideTemplates':
66        obj = object.__new__(cls)
67        obj.portada    = tpls[cfg.idx_portada]
68        obj.objectives = tpls[cfg.idx_objetivos]
69        obj.index      = tpls[cfg.idx_indice]
70        obj.single     = tpls[cfg.idx_intro]
71        obj.bullets    = tpls[cfg.idx_contenido]
72        obj.conclusion = tpls[cfg.idx_conclusion]
73        obj.references = tpls[cfg.idx_referencias]
74        obj.contact    = tpls[cfg.idx_contacto]
75        obj.license    = tpls[cfg.idx_licencia]
76        return obj

Mapeador para encapsular y desacoplar los índices mágicos de las plantillas ODP.

SlideTemplates(tpls: list)
53    def __init__(self, tpls: list):
54        self.portada    = tpls[0]
55        self.objectives = tpls[1]
56        self.index      = tpls[2]
57        self.single     = tpls[3]
58        self.bullets    = tpls[10]
59        self.conclusion = tpls[19]
60        self.references = tpls[20]
61        self.contact    = tpls[21]
62        self.license    = tpls[22]
portada
objectives
index
single
bullets
conclusion
references
contact
license
@classmethod
def from_config(cls, tpls: list, cfg) -> SlideTemplates:
64    @classmethod
65    def from_config(cls, tpls: list, cfg) -> 'SlideTemplates':
66        obj = object.__new__(cls)
67        obj.portada    = tpls[cfg.idx_portada]
68        obj.objectives = tpls[cfg.idx_objetivos]
69        obj.index      = tpls[cfg.idx_indice]
70        obj.single     = tpls[cfg.idx_intro]
71        obj.bullets    = tpls[cfg.idx_contenido]
72        obj.conclusion = tpls[cfg.idx_conclusion]
73        obj.references = tpls[cfg.idx_referencias]
74        obj.contact    = tpls[cfg.idx_contacto]
75        obj.license    = tpls[cfg.idx_licencia]
76        return obj
def load_template(template: pathlib.Path):
297def load_template(template: Path):
298    """Returns (content_xml, all_files, tpls)."""
299    if not template.exists():
300        raise FileNotFoundError(f"La plantilla especificada no existe en: {template.absolute()}")
301    with zipfile.ZipFile(template) as zin:
302        content_xml = zin.read('content.xml').decode('utf-8')
303        all_files = {name: zin.read(name) for name in zin.namelist()}
304    tpls = extract_slides(content_xml)
305    return content_xml, all_files, tpls

Returns (content_xml, all_files, tpls).

def save_odp( output: pathlib.Path, slides: list, content_xml: str, all_files: dict, cfg=None, extra_styles: list = None) -> None:
338def save_odp(output: Path, slides: list, content_xml: str, all_files: dict,
339             cfg=None, extra_styles: list = None) -> None:
340    """Finalize slide numbers, inject styles and write the ODP file."""
341    slides = [finalize(s, i + 1) for i, s in enumerate(slides)]
342    first = content_xml.find('<draw:page ')
343    last  = content_xml.rfind('</draw:page>') + len('</draw:page>')
344    new_xml = inject_code_styles(
345        content_xml[:first] + ''.join(slides) + content_xml[last:],
346        extra_styles,
347    )
348
349    if cfg and cfg.url_contacto:
350        for variant in _url_variants(cfg.url_contacto):
351            if variant != cfg.url_contacto:
352                new_xml = new_xml.replace(variant, cfg.url_contacto)
353    else:
354        new_xml = new_xml.replace('http://www.cursosdedesarrollo.com', 'https://temario.app')
355        new_xml = new_xml.replace('http://cursosdedesarrollo.com', 'https://temario.app')
356        new_xml = new_xml.replace('https://cursosdedesarrollo.com', 'https://temario.app')
357        new_xml = new_xml.replace('http://www.cursosdesarrollo.com', 'https://temario.app')
358        new_xml = new_xml.replace('http://cursosdesarrollo.com', 'https://temario.app')
359
360    all_files = dict(all_files)
361    _update_manifest(all_files)
362
363    buf = BytesIO()
364    with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
365        for name, data in all_files.items():
366            zout.writestr(name, new_xml.encode('utf-8') if name == 'content.xml' else data)
367    output.parent.mkdir(parents=True, exist_ok=True)
368    output.write_bytes(buf.getvalue())
369    try:
370        shown = output.relative_to(Path.cwd())
371    except ValueError:
372        shown = output
373    print(_('Generado {} con {} diapositivas').format(shown, len(slides)))

Finalize slide numbers, inject styles and write the ODP file.

def preserve_spaces(s: str) -> str:
71def preserve_spaces(s: str) -> str:
72    """Conserva la indentación de código convirtiendo múltiples espacios consecutivos en elementos <text:s text:c="N"/>."""
73    def repl(match):
74        count = len(match.group(0))
75        return " " + f'<text:s text:c="{count - 1}"/>' if count > 1 else " "
76    return re.sub(r' {2,}', repl, s)

Conserva la indentación de código convirtiendo múltiples espacios consecutivos en elementos .

def xe(s: str) -> str:
21def xe(s: str) -> str:
22    """Escapa de forma segura caracteres especiales para XML usando la biblioteca estándar."""
23    return xml.sax.saxutils.escape(str(s))

Escapa de forma segura caracteres especiales para XML usando la biblioteca estándar.

def make_portada(tpl: str, title: str, unit: str, level: str = 'Básico', cfg=None) -> str:
25def make_portada(tpl: str, title: str, unit: str, level: str = 'Básico', cfg=None) -> str:
26    pt_style = cfg.portada_titulo if cfg else 'T1'
27    pu_style = cfg.portada_unidad if cfg else 'T2'
28    pn_style = cfg.portada_nivel if cfg else 'T4'
29    title_style = _portada_title_style(title, pt_style)
30    s = re.sub(
31        rf'<text:span text:style-name="{re.escape(pt_style)}">[^<]*</text:span>',
32        f'<text:span text:style-name="{title_style}">{xe(title)}</text:span>',
33        tpl,
34    )
35    s = re.sub(rf'(<text:span text:style-name="{re.escape(pu_style)}">)[^<]*(</text:span>)',
36               rf'\g<1>{xe(unit)}\2', s)
37    s = re.sub(rf'(<text:span text:style-name="{re.escape(pn_style)}">)[^<]*(</text:span>)',
38               rf'\g<1>{xe(level)}\2', s)
39    return s
def make_objectives(tpl: str, bullets: list, cfg=None) -> str:
52def make_objectives(tpl: str, bullets: list, cfg=None) -> str:
53    l  = cfg.lista_obj if cfg else 'L3'
54    p  = cfg.parrafo_obj if cfg else 'P16'
55    base = cfg.span_obj if cfg else 'T6'
56    sm_thr = cfg.umbral_obj_sm if cfg else 7
57    xs_thr = getattr(cfg, 'umbral_obj_xs', 8) if cfg else 11
58    span = _list_span(len(bullets), base, 'T_OBJ_SM', 'T_OBJ_XS',
59                      sm_threshold=sm_thr, xs_threshold=xs_thr)
60    new_list = build_list(l, p, span, bullets)
61    return re.sub(rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
62                  new_list, tpl, flags=re.DOTALL)
def make_index(tpl: str, items: list, cfg=None) -> str:
65def make_index(tpl: str, items: list, cfg=None) -> str:
66    l  = cfg.lista_obj if cfg else 'L3'
67    p  = cfg.parrafo_indice if cfg else 'P21'
68    base = cfg.span_indice if cfg else 'T8'
69    span = _list_span(len(items), base, 'T_IDX_SM', 'T_IDX_XS')
70    new_list = build_list(l, p, span, items)
71    return re.sub(rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
72                  new_list, tpl, flags=re.DOTALL)
def make_single(tpl: str, indicator: str, sec_title: str, body: str, cfg=None) -> str:
75def make_single(tpl: str, indicator: str, sec_title: str, body: str, cfg=None) -> str:
76    """Slide con un párrafo de texto (intro de sección o conclusiones)."""
77    ind = cfg.indicador if cfg else 'T7'
78    tit = cfg.titulo if cfg else 'T10'
79    bod = cfg.intro_cuerpo if cfg else 'T9'
80    s = re.sub(rf'(<text:span text:style-name="{re.escape(ind)}">)[^<]*(</text:span>)',
81               rf'\g<1>{xe(indicator)}\2', tpl)
82    s = re.sub(rf'<text:span text:style-name="{re.escape(tit)}">[^<]*</text:span>',
83               f'<text:span text:style-name="T_TITLE">{xe(sec_title)}</text:span>', s)
84    s = _center_title(s)
85    s = re.sub(rf'(<text:span text:style-name="{re.escape(bod)}">).*?(</text:span>)',
86               rf'\g<1>{xe_multiline(body, bod)}\2', s, flags=re.DOTALL)
87    return s

Slide con un párrafo de texto (intro de sección o conclusiones).

def make_bullets( tpl: str, indicator: str, sec_title: str, bullets: list, cfg=None, subtitle: str = '') -> str:
 90def make_bullets(tpl: str, indicator: str, sec_title: str, bullets: list,
 91                 cfg=None, subtitle: str = '') -> str:
 92    """Slide con lista de viñetas. subtitle aparece centrado justo bajo el título."""
 93    ind = cfg.indicador if cfg else 'T7'
 94    tit = cfg.titulo if cfg else 'T10'
 95    l   = cfg.lista_contenido if cfg else 'L4'
 96    p   = cfg.parrafo_contenido if cfg else 'P27'
 97    base = cfg.span_contenido if cfg else 'T6'
 98    sp  = _list_span(len(bullets), base, 'T_BUL_SM', 'T_BUL_XS')
 99    s = re.sub(rf'(<text:span text:style-name="{re.escape(ind)}">)[^<]*(</text:span>)',
100               rf'\g<1>{xe(indicator)}\2', tpl)
101    s = re.sub(rf'<text:span text:style-name="{re.escape(tit)}">[^<]*</text:span>',
102               f'<text:span text:style-name="T_TITLE">{xe(sec_title)}</text:span>', s)
103    s = _center_title(s)
104    new_list = build_list(l, p, sp, bullets)
105    s = re.sub(rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
106               new_list, s, flags=re.DOTALL)
107    if subtitle:
108        sub_frame = (
109            '<draw:frame draw:style-name="gr4" draw:layer="layout"'
110            ' svg:width="22.4cm" svg:height="0.8cm"'
111            ' svg:x="2.6cm" svg:y="1.95cm">'
112            '<draw:text-box>'
113            f'<text:p text:style-name="P_SUBTITLE">'
114            f'<text:span text:style-name="T_SUBTITLE">{xe(subtitle)}</text:span>'
115            f'</text:p>'
116            '</draw:text-box>'
117            '</draw:frame>'
118        )
119        if '<presentation:notes' in s:
120            s = s.replace('<presentation:notes', sub_frame + '<presentation:notes', 1)
121        else:
122            s = s.replace('</draw:page>', sub_frame + '</draw:page>', 1)
123    return s

Slide con lista de viñetas. subtitle aparece centrado justo bajo el título.

def make_refs(tpl: str, refs: list, cfg=None) -> str:
126def make_refs(tpl: str, refs: list, cfg=None) -> str:
127    """Slide de referencias con enlaces clicables."""
128    l = cfg.lista_contenido if cfg else 'L4'
129    p = cfg.parrafo_contenido if cfg else 'P27'
130    sp = cfg.span_contenido if cfg else 'T6'
131    new_list = build_link_list(l, p, sp, refs)
132    return re.sub(rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
133                  new_list, tpl, flags=re.DOTALL)

Slide de referencias con enlaces clicables.

def make_code(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
136def make_code(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
137    """Slide con comandos de shell."""
138    l = cfg.lista_contenido if cfg else 'L4'
139    s = _sec_header(tpl, indicator, sec_title, cfg)
140    parts = []
141    for item in lines:
142        if isinstance(item, tuple) and item[0] == 'cmt':
143            text = '# ' + item[1]
144            span = 'T_COMMENT'
145        elif isinstance(item, tuple) and item[0] == 'ok':
146            text = item[1]
147            span = 'T_CODE_OK'
148        else:
149            text = item
150            span = 'T_CODE'
151        parts.append(
152            f'<text:p text:style-name="P_CODE">'
153            f'<text:span text:style-name="{span}">{preserve_spaces(xe(text))}</text:span>'
154            f'</text:p>'
155        )
156    return re.sub(rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
157                  ''.join(parts), s, flags=re.DOTALL)

Slide con comandos de shell.

def make_java(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
160def make_java(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
161    """Slide con código fuente Java con resaltado de sintaxis por tokens."""
162    l = cfg.lista_contenido if cfg else 'L4'
163    pt = _java_pt(len(lines))
164    s = _sec_header(tpl, indicator, sec_title, cfg)
165
166    in_block_cmt = False
167    parts = []
168    for line in lines:
169        if line == '':
170            parts.append(f'<text:p text:style-name="P_JAVA_{pt}"/>')
171            continue
172        tokens, in_block_cmt = _tokenize_java_line(line, in_block_cmt)
173        if not tokens:
174            parts.append(f'<text:p text:style-name="P_JAVA_{pt}"/>')
175            continue
176        spans = ''.join(
177            f'<text:span text:style-name="{_java_token_style(typ, pt)}">'
178            f'{preserve_spaces(xe(text))}</text:span>'
179            for typ, text in tokens
180        )
181        parts.append(f'<text:p text:style-name="P_JAVA_{pt}">{spans}</text:p>')
182
183    return re.sub(
184        rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
185        ''.join(parts), s, flags=re.DOTALL,
186    )

Slide con código fuente Java con resaltado de sintaxis por tokens.

def make_salida(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
38def make_salida(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
39    """Slide con salida de consola. Items: str | ('err', text) | ('ok', text)."""
40    l = cfg.lista_contenido if cfg else 'L4'
41    s = _sec_header(tpl, indicator, sec_title, cfg)
42    parts = []
43    for item in lines:
44        if isinstance(item, tuple):
45            kind, text = item[0], item[1]
46            span = 'T_SALIDA_ERR' if kind == 'err' else 'T_SALIDA_OK' if kind == 'ok' else 'T_SALIDA'
47        else:
48            text, span = item, 'T_SALIDA'
49        parts.append(
50            f'<text:p text:style-name="P_SALIDA">'
51            f'<text:span text:style-name="{span}">{preserve_spaces(xe(text))}</text:span>'
52            f'</text:p>'
53        )
54    return re.sub(rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
55                  ''.join(parts), s, flags=re.DOTALL)

Slide con salida de consola. Items: str | ('err', text) | ('ok', text).

def make_terminal(tpl: str, indicator: str, sec_title: str, items: list, cfg=None) -> str:
 76def make_terminal(tpl: str, indicator: str, sec_title: str, items: list, cfg=None) -> str:
 77    """Slide de sesión de terminal interactivo."""
 78    l = cfg.lista_contenido if cfg else 'L4'
 79    s = _sec_header(tpl, indicator, sec_title, cfg)
 80
 81    def _p(content: str) -> str:
 82        return f'<text:p text:style-name="P_TERM">{content}</text:p>'
 83
 84    def _span(style: str, text: str) -> str:
 85        return f'<text:span text:style-name="{style}">{preserve_spaces(xe(text))}</text:span>'
 86
 87    line_width = cfg.terminal_chars_por_linea if cfg else _TERM_LINE_WIDTH
 88
 89    def _render_cmd(prompt_style: str, prompt_char: str, cmd_text: str) -> list:
 90        segs = _split_cmd(cmd_text, line_width)
 91        lines = []
 92        for i, seg in enumerate(segs):
 93            is_last = (i == len(segs) - 1)
 94            cont = _span('T_TERM_CMT', ' \\') if not is_last else ''
 95            if i == 0:
 96                lines.append(_p(_span(prompt_style, prompt_char) + _span('T_TERM_CMD', seg) + cont))
 97            else:
 98                indent = ' ' * _TERM_CONT_INDENT
 99                lines.append(_p(_span('T_TERM_CMD', indent + seg) + cont))
100        return lines
101
102    parts = []
103    for item in items:
104        if item == '' or item == {}:
105            parts.append('<text:p text:style-name="P_TERM"/>')
106            continue
107        if isinstance(item, dict):
108            if 'cmd' in item:
109                parts.extend(_render_cmd('T_TERM_PROMPT', '$ ', item['cmd']))
110            elif 'root' in item:
111                parts.extend(_render_cmd('T_TERM_ROOT_P', '# ', item['root']))
112            elif 'out' in item:
113                parts.append(_p(_span('T_TERM_OUT', item['out'])))
114            elif 'ok' in item:
115                parts.append(_p(_span('T_TERM_OK', item['ok'])))
116            elif 'err' in item:
117                parts.append(_p(_span('T_TERM_ERR', item['err'])))
118            elif 'cmt' in item:
119                parts.append(_p(_span('T_TERM_CMT', '# ' + item['cmt'])))
120        else:
121            parts.extend(_render_cmd('T_TERM_PROMPT', '$ ', item))
122
123    return re.sub(rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
124                  ''.join(parts), s, flags=re.DOTALL)

Slide de sesión de terminal interactivo.

def make_diagrama(tpl: str, indicator: str, sec_title: str, items: list, cfg=None) -> str:
150def make_diagrama(tpl: str, indicator: str, sec_title: str, items: list, cfg=None) -> str:
151    """Slide con diagrama en texto / jerarquía ASCII en monoespaciado."""
152    l = cfg.lista_contenido if cfg else 'L4'
153    s = _sec_header(tpl, indicator, sec_title, cfg)
154    parts = [
155        f'<text:p text:style-name="P_DIAG">'
156        f'<text:span text:style-name="T_DIAG">{preserve_spaces(xe(_force_text(item)))}</text:span>'
157        f'</text:p>'
158        for item in items
159    ]
160    return re.sub(rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
161                  ''.join(parts), s, flags=re.DOTALL)

Slide con diagrama en texto / jerarquía ASCII en monoespaciado.

def make_tabla( tpl: str, indicator: str, sec_title: str, cabeceras: list, filas: list, colores: dict = None, extra_styles: list = None, cfg=None) -> str:
190def make_tabla(tpl: str, indicator: str, sec_title: str,
191               cabeceras: list, filas: list,
192               colores: dict = None, extra_styles: list = None, cfg=None) -> str:
193    """Slide con tabla de datos. colores: {cabecera, texto_cabecera, texto, borde}."""
194    s = _sec_header(tpl, indicator, sec_title, cfg)
195
196    if colores:
197        bg      = colores.get('cabecera',       '#1e3a5f')
198        fg      = colores.get('texto_cabecera', '#ffffff')
199        cell_fg = colores.get('texto',          '#1a1a1a')
200        borde   = colores.get('borde',          '#cccccc')
201        key     = bg.lstrip('#')
202        hdr_cell_sty = f'GS_TC_H_{key}'
203        cell_sty     = f'GS_TC_{key}'
204        hdr_txt_sty  = f'T_TBL_H_{key}'
205        txt_sty      = f'T_TBL_{key}'
206        hdr_p_sty    = f'P_TBL_H_{key}'
207        p_sty        = f'P_TBL_{key}'
208        if extra_styles is not None and not any(key in s for s in extra_styles):
209            extra_styles.append(_table_color_styles(key, bg, fg, cell_fg, borde))
210    else:
211        hdr_cell_sty = 'GS_TC_H'
212        cell_sty     = 'GS_TC'
213        hdr_txt_sty  = 'T_TBL_H'
214        txt_sty      = 'T_TBL'
215        hdr_p_sty    = 'P_TBL_H'
216        p_sty        = 'P_TBL'
217
218    ncols    = max(len(cabeceras), max((len(f) for f in filas), default=1))
219    cols_xml = _tabla_col_xml(cabeceras, filas, ncols, extra_styles)
220
221    header_row = ''
222    if cabeceras:
223        cells = ''.join(
224            f'<table:table-cell table:style-name="{hdr_cell_sty}" office:value-type="string">'
225            f'<text:p text:style-name="{hdr_p_sty}">'
226            f'<text:span text:style-name="{hdr_txt_sty}">{xe(h)}</text:span>'
227            f'</text:p></table:table-cell>'
228            for h in cabeceras
229        )
230        header_row = f'<table:table-row>{cells}</table:table-row>'
231
232    data_rows = ''
233    for fila in filas:
234        cells = ''.join(
235            f'<table:table-cell table:style-name="{cell_sty}" office:value-type="string">'
236            f'<text:p text:style-name="{p_sty}">'
237            f'<text:span text:style-name="{txt_sty}">{xe(str(c))}</text:span>'
238            f'</text:p></table:table-cell>'
239            for c in fila
240        )
241        data_rows += f'<table:table-row>{cells}</table:table-row>'
242
243    table_xml = f'<table:table>{cols_xml}{header_row}{data_rows}</table:table>'
244    table_frame = _new_frame(_FRAME_W, _FRAME_H, _FRAME_X, _FRAME_Y, table_xml)
245    return _content_frame_re(cfg).sub(table_frame, s, count=1)

Slide con tabla de datos. colores: {cabecera, texto_cabecera, texto, borde}.

def make_aviso( tpl: str, indicator: str, sec_title: str, nivel: str, texto: str, cfg=None) -> str:
312def make_aviso(tpl: str, indicator: str, sec_title: str,
313               nivel: str, texto: str, cfg=None) -> str:
314    """Slide de aviso/callout. nivel: warning|info|tip."""
315    _LABELS  = {'warning': 'Atención', 'info': 'Nota', 'tip': 'Consejo'}
316    _ESTILOS = {'warning': 'T_AVISO_WARN', 'info': 'T_AVISO_INFO', 'tip': 'T_AVISO_TIP'}
317
318    label  = _LABELS.get(nivel, nivel.capitalize())
319    estilo = _ESTILOS.get(nivel, 'T_AVISO_WARN')
320    ind = cfg.indicador if cfg else 'T7'
321    tit = cfg.titulo if cfg else 'T10'
322    bod = cfg.intro_cuerpo if cfg else 'T9'
323
324    s = re.sub(rf'(<text:span text:style-name="{re.escape(ind)}">)[^<]*(</text:span>)',
325               rf'\g<1>{xe(indicator)}\2', tpl)
326    s = re.sub(rf'<text:span text:style-name="{re.escape(tit)}">[^<]*</text:span>',
327               f'<text:span text:style-name="{estilo}">{xe(label)}</text:span>', s)
328    s = re.sub(rf'(<text:span text:style-name="{re.escape(bod)}">).*?(</text:span>)',
329               rf'\g<1>{xe(texto)}\2', s, flags=re.DOTALL)
330    return s

Slide de aviso/callout. nivel: warning|info|tip.

def make_dos_columnas( tpl: str, indicator: str, sec_title: str, izquierda: list, derecha: list, cfg=None) -> str:
333def make_dos_columnas(tpl: str, indicator: str, sec_title: str,
334                      izquierda: list, derecha: list, cfg=None) -> str:
335    l = cfg.lista_contenido if cfg else 'L4'
336    p = cfg.parrafo_contenido if cfg else 'P27'
337    sp = cfg.span_contenido if cfg else 'T6'
338    s = _sec_header(tpl, indicator, sec_title, cfg)
339    left_xml  = build_list(l, p, sp, izquierda)
340    right_xml = build_list(l, p, sp, derecha)
341    frames = (
342        _new_frame(_COL_W_2, _FRAME_H, _FRAME_X, _FRAME_Y,
343                   f'<draw:text-box>{left_xml}</draw:text-box>')
344        + _new_frame(_COL_W_2, _FRAME_H, _COL_X2, _FRAME_Y,
345                     f'<draw:text-box>{right_xml}</draw:text-box>')
346    )
347    return _content_frame_re(cfg).sub(frames, s, count=1)
def make_comparar( tpl: str, indicator: str, sec_title: str, izq: list, der: list, cfg=None) -> str:
368def make_comparar(tpl: str, indicator: str, sec_title: str,
369                  izq: list, der: list, cfg=None) -> str:
370    pt = _java_pt(max(len(izq), len(der)) * 2)
371    return _make_comparar_cols(tpl, indicator, sec_title,
372                                _java_col_xml(izq, pt), _java_col_xml(der, pt), cfg)
def make_comparar_python( tpl: str, indicator: str, sec_title: str, izq: list, der: list, cfg=None) -> str:
375def make_comparar_python(tpl: str, indicator: str, sec_title: str,
376                         izq: list, der: list, cfg=None) -> str:
377    pt = _java_pt(max(len(izq), len(der)) * 2)
378    return _make_comparar_cols(tpl, indicator, sec_title,
379                                _col_xml_lang_stateful(izq, pt, 'PY', _tokenize_py_line),
380                                _col_xml_lang_stateful(der, pt, 'PY', _tokenize_py_line), cfg)
def make_comparar_js( tpl: str, indicator: str, sec_title: str, izq: list, der: list, cfg=None) -> str:
383def make_comparar_js(tpl: str, indicator: str, sec_title: str,
384                     izq: list, der: list, cfg=None) -> str:
385    pt = _java_pt(max(len(izq), len(der)) * 2)
386    return _make_comparar_cols(tpl, indicator, sec_title,
387                                _col_xml_lang_stateful(izq, pt, 'JS', _tokenize_js_line),
388                                _col_xml_lang_stateful(der, pt, 'JS', _tokenize_js_line), cfg)
def make_comparar_xml( tpl: str, indicator: str, sec_title: str, izq: list, der: list, cfg=None) -> str:
391def make_comparar_xml(tpl: str, indicator: str, sec_title: str,
392                      izq: list, der: list, cfg=None) -> str:
393    pt = _java_pt(max(len(izq), len(der)) * 2)
394    return _make_comparar_cols(tpl, indicator, sec_title,
395                                _col_xml_xml(izq, pt), _col_xml_xml(der, pt), cfg)
def make_comparar_jsp( tpl: str, indicator: str, sec_title: str, izq: list, der: list, cfg=None) -> str:
398def make_comparar_jsp(tpl: str, indicator: str, sec_title: str,
399                      izq: list, der: list, cfg=None) -> str:
400    pt = _java_pt(max(len(izq), len(der)) * 2)
401    return _make_comparar_cols(tpl, indicator, sec_title,
402                                _col_xml_jsp(izq, pt), _col_xml_jsp(der, pt), cfg)
def make_comparar_css( tpl: str, indicator: str, sec_title: str, izq: list, der: list, cfg=None) -> str:
405def make_comparar_css(tpl: str, indicator: str, sec_title: str,
406                      izq: list, der: list, cfg=None) -> str:
407    pt = _java_pt(max(len(izq), len(der)) * 2)
408    return _make_comparar_cols(tpl, indicator, sec_title,
409                                _col_xml_css(izq, pt), _col_xml_css(der, pt), cfg)
def make_comparar_json( tpl: str, indicator: str, sec_title: str, izq: list, der: list, cfg=None) -> str:
412def make_comparar_json(tpl: str, indicator: str, sec_title: str,
413                       izq: list, der: list, cfg=None) -> str:
414    pt = _java_pt(max(len(izq), len(der)) * 2)
415    return _make_comparar_cols(tpl, indicator, sec_title,
416                                _col_xml_json(izq, pt), _col_xml_json(der, pt), cfg)
def make_codigo(tpl: str, indicator: str, sec_title: str, items: list, cfg=None) -> str:
56def make_codigo(tpl: str, indicator: str, sec_title: str, items: list, cfg=None) -> str:
57    """Slide de código genérico (monoespaciado, sin resaltado de lenguaje)."""
58    l = cfg.lista_contenido if cfg else 'L4'
59    s = _sec_header(tpl, indicator, sec_title, cfg)
60    pt = _java_pt(len(items))
61    parts = [
62        f'<text:p text:style-name="P_JAVA_{pt}">'
63        f'<text:span text:style-name="T_JAVA_{pt}">{preserve_spaces(xe(line))}</text:span>'
64        f'</text:p>'
65        if line else f'<text:p text:style-name="P_JAVA_{pt}"/>'
66        for line in items
67    ]
68    return re.sub(rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
69                  ''.join(parts), s, flags=re.DOTALL)

Slide de código genérico (monoespaciado, sin resaltado de lenguaje).

def make_javascript(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
72def make_javascript(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
73    return _make_lang_slide(tpl, indicator, sec_title, lines, _tokenize_js_line, 'JS', cfg)
def make_python(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
76def make_python(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
77    return _make_lang_slide(tpl, indicator, sec_title, lines, _tokenize_py_line, 'PY', cfg)
def make_yaml(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
132def make_yaml(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
133    l = cfg.lista_contenido if cfg else 'L4'
134    pt = _java_pt(len(lines))
135    s = _sec_header(tpl, indicator, sec_title, cfg)
136    parts = []
137    for line in lines:
138        if line == '':
139            parts.append(f'<text:p text:style-name="P_YAML_{pt}"/>'); continue
140        tokens = _tokenize_yaml_line(line)
141        if not tokens:
142            parts.append(f'<text:p text:style-name="P_YAML_{pt}"/>'); continue
143        spans = ''.join(
144            f'<text:span text:style-name="'
145            f'T_YAML_{pt if typ == "default" else typ.upper() + "_" + str(pt)}">'
146            f'{preserve_spaces(xe(text))}</text:span>'
147            for typ, text in tokens
148        )
149        parts.append(f'<text:p text:style-name="P_YAML_{pt}">{spans}</text:p>')
150    return re.sub(
151        rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
152        ''.join(parts), s, flags=re.DOTALL,
153    )
def make_xml(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
 80def make_xml(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
 81    l = cfg.lista_contenido if cfg else 'L4'
 82    pt = _java_pt(len(lines))
 83    s = _sec_header(tpl, indicator, sec_title, cfg)
 84    parts = []
 85    for line in lines:
 86        if line == '':
 87            parts.append(f'<text:p text:style-name="P_XML_{pt}"/>'); continue
 88        tokens = _tokenize_xml_line(line)
 89        if not tokens:
 90            parts.append(f'<text:p text:style-name="P_XML_{pt}"/>'); continue
 91        spans = ''.join(
 92            f'<text:span text:style-name="'
 93            f'T_XML_{pt if typ == "default" else typ.upper() + "_" + str(pt)}">'
 94            f'{preserve_spaces(xe(text))}</text:span>'
 95            for typ, text in tokens
 96        )
 97        parts.append(f'<text:p text:style-name="P_XML_{pt}">{spans}</text:p>')
 98    return re.sub(
 99        rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
100        ''.join(parts), s, flags=re.DOTALL,
101    )
def make_json(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
108def make_json(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
109    l = cfg.lista_contenido if cfg else 'L4'
110    pt = _java_pt(len(lines))
111    s = _sec_header(tpl, indicator, sec_title, cfg)
112    parts = []
113    for line in lines:
114        if line == '':
115            parts.append(f'<text:p text:style-name="P_JSON_{pt}"/>'); continue
116        tokens = _tokenize_json_line(line)
117        if not tokens:
118            parts.append(f'<text:p text:style-name="P_JSON_{pt}"/>'); continue
119        spans = ''.join(
120            f'<text:span text:style-name="'
121            f'T_JSON_{pt if typ == "default" else typ.upper() + "_" + str(pt)}">'
122            f'{preserve_spaces(xe(text))}</text:span>'
123            for typ, text in tokens
124        )
125        parts.append(f'<text:p text:style-name="P_JSON_{pt}">{spans}</text:p>')
126    return re.sub(
127        rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
128        ''.join(parts), s, flags=re.DOTALL,
129    )
def make_html(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
104def make_html(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
105    return make_xml(tpl, indicator, sec_title, lines, cfg)
def make_jsp(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
156def make_jsp(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
157    l = cfg.lista_contenido if cfg else 'L4'
158    pt = _java_pt(len(lines))
159    s = _sec_header(tpl, indicator, sec_title, cfg)
160    in_sc, in_jcmt = False, False
161    parts = []
162    for line in lines:
163        if line == '':
164            parts.append(f'<text:p text:style-name="P_JSP_{pt}"/>'); continue
165        tokens, in_sc, in_jcmt = _tokenize_jsp_line(line, in_sc, in_jcmt)
166        if not tokens:
167            parts.append(f'<text:p text:style-name="P_JSP_{pt}"/>'); continue
168        spans = ''.join(
169            f'<text:span text:style-name="'
170            f'T_JSP_{pt if typ == "default" else typ.upper() + "_" + str(pt)}">'
171            f'{preserve_spaces(xe(text))}</text:span>'
172            for typ, text in tokens
173        )
174        parts.append(f'<text:p text:style-name="P_JSP_{pt}">{spans}</text:p>')
175    return re.sub(
176        rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
177        ''.join(parts), s, flags=re.DOTALL,
178    )
def make_css(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
181def make_css(tpl: str, indicator: str, sec_title: str, lines: list, cfg=None) -> str:
182    l = cfg.lista_contenido if cfg else 'L4'
183    pt = _java_pt(len(lines))
184    s = _sec_header(tpl, indicator, sec_title, cfg)
185    in_cmt, in_rule = False, False
186    parts = []
187    for line in lines:
188        if line == '':
189            parts.append(f'<text:p text:style-name="P_CSS_{pt}"/>'); continue
190        tokens, in_cmt, in_rule = _tokenize_css_line(line, in_cmt, in_rule)
191        if not tokens:
192            parts.append(f'<text:p text:style-name="P_CSS_{pt}"/>'); continue
193        spans = ''.join(
194            f'<text:span text:style-name="'
195            f'T_CSS_{pt if typ == "default" else typ.upper() + "_" + str(pt)}">'
196            f'{preserve_spaces(xe(text))}</text:span>'
197            for typ, text in tokens
198        )
199        parts.append(f'<text:p text:style-name="P_CSS_{pt}">{spans}</text:p>')
200    return re.sub(
201        rf'<text:list text:style-name="{re.escape(l)}">.*?</text:list>',
202        ''.join(parts), s, flags=re.DOTALL,
203    )
def make_imagen( tpl: str, indicator: str, sec_title: str, ruta: str, media: dict, course_dir, cfg=None) -> str:
286def make_imagen(tpl: str, indicator: str, sec_title: str,
287                ruta: str, media: dict, course_dir, cfg=None) -> str:
288    zip_name = _add_image(ruta, media, course_dir)
289    if zip_name is None:
290        return _placeholder_slide(tpl, indicator, sec_title, ruta, cfg)
291    s = _sec_header(tpl, indicator, sec_title, cfg)
292    img_frame = _image_frame(zip_name, _FRAME_W, _FRAME_H, _FRAME_X, _FRAME_Y)
293    return _content_frame_re(cfg).sub(img_frame, s, count=1)
def make_imagen_texto( tpl: str, indicator: str, sec_title: str, ruta: str, items: list, media: dict, course_dir, cfg=None) -> str:
296def make_imagen_texto(tpl: str, indicator: str, sec_title: str,
297                      ruta: str, items: list,
298                      media: dict, course_dir, cfg=None) -> str:
299    l = cfg.lista_contenido if cfg else 'L4'
300    p = cfg.parrafo_contenido if cfg else 'P27'
301    sp = cfg.span_contenido if cfg else 'T6'
302    zip_name = _add_image(ruta, media, course_dir)
303    if zip_name is None:
304        return _placeholder_slide(tpl, indicator, sec_title, ruta, cfg)
305    s = _sec_header(tpl, indicator, sec_title, cfg)
306    img_frame  = _image_frame(zip_name, _COL_W_2, _FRAME_H, _FRAME_X, _FRAME_Y)
307    text_frame = _new_frame(_COL_W_2, _FRAME_H, _COL_X2, _FRAME_Y,
308                            f'<draw:text-box>{build_list(l, p, sp, items)}</draw:text-box>')
309    return _content_frame_re(cfg).sub(img_frame + text_frame, s, count=1)
def build_slides_from_yaml( t: SlideTemplates, data: dict, ctx: dict = None) -> list:
137def build_slides_from_yaml(t: SlideTemplates, data: dict, ctx: dict = None) -> list:
138    """Build a complete slide list from a YAML content dict."""
139    ctx          = ctx or {}
140    media        = ctx.get('media', {})
141    course_dir   = ctx.get('course_dir')
142    extra_styles = ctx.get('extra_styles', [])
143    cfg          = ctx.get('cfg')
144
145    extra_styles.append(subtitle_text_style(
146        getattr(cfg, 'color_subtitulo', '#000000') if cfg else '#000000'
147    ))
148
149    slides = []
150    meta = data['meta']
151    secciones = data['secciones']
152    indice_list = data.get('indice', [])
153
154    index_titles = indice_list if indice_list else [s['titulo'] for s in secciones]
155
156    slides.append(make_portada(t.portada, meta['portada'], meta['unidad'], cfg=cfg))
157    _emit_list(slides, make_objectives, t.objectives, data['objetivos'], 'Objetivos', cfg=cfg)
158    _emit_list(slides, make_index,      t.index,      index_titles,      'Índice',   cfg=cfg)
159
160    for i, sec in enumerate(secciones, 1):
161        num = str(sec['indice']) if 'indice' in sec else str(i)
162        if 'titulo' in sec:
163            titulo = sec['titulo']
164        elif 'indice' in sec and indice_list:
165            idx = sec['indice'] - 1
166            titulo = indice_list[idx] if 0 <= idx < len(indice_list) else ''
167        else:
168            titulo = ''
169
170        if 'intro' in sec:
171            slides.append(make_single(t.single, num, titulo, sec['intro'], cfg=cfg))
172        for slide in sec.get('slides', []):
173            tipo    = slide['tipo']
174            texto   = slide.get('texto', '')
175            items   = texto.splitlines() if texto else slide.get('items', [])
176            fichero = slide.get('fichero', '')
177            notas   = slide.get('notas', '')
178            if tipo == 'bullets':
179                _emit_list(slides, make_bullets, t.bullets, items, titulo, num, titulo, cfg=cfg,
180                           notas=notas, subtitle=slide.get('titulo', ''))
181            elif tipo == 'code':
182                lines = [
183                    ('cmt', item['cmt']) if isinstance(item, dict) and 'cmt' in item
184                    else ('ok', item['ok']) if isinstance(item, dict) and 'ok' in item
185                    else item
186                    for item in items
187                ]
188                xml = make_code(t.bullets, num, titulo, lines, cfg=cfg)
189                slides.append(inject_notes(xml, notas) if notas else xml)
190            elif tipo == 'java':
191                _emit_code(slides, make_java, t.bullets, num, titulo, items, fichero, cfg, notas=notas)
192            elif tipo == 'salida':
193                lines = [
194                    ('err', item['err']) if isinstance(item, dict) and 'err' in item
195                    else ('ok', item['ok']) if isinstance(item, dict) and 'ok' in item
196                    else item
197                    for item in items
198                ]
199                xml = make_salida(t.bullets, num, titulo, lines, cfg=cfg)
200                slides.append(inject_notes(xml, notas) if notas else xml)
201            elif tipo == 'terminal':
202                xml = make_terminal(t.bullets, num, titulo, items, cfg=cfg)
203                slides.append(inject_notes(xml, notas) if notas else xml)
204            elif tipo == 'diagrama':
205                xml = make_diagrama(t.bullets, num, titulo, items, cfg=cfg)
206                slides.append(inject_notes(xml, notas) if notas else xml)
207            elif tipo == 'tabla':
208                xml = make_tabla(t.bullets, num, titulo,
209                                 slide.get('cabeceras', []),
210                                 slide.get('filas', []),
211                                 slide.get('colores'),
212                                 extra_styles, cfg=cfg)
213                slides.append(inject_notes(xml, notas) if notas else xml)
214            elif tipo == 'aviso':
215                xml = make_aviso(t.single, num, titulo,
216                                 slide['nivel'], slide['texto'], cfg=cfg)
217                slides.append(inject_notes(xml, notas) if notas else xml)
218            elif tipo == 'dos_columnas':
219                xml = make_dos_columnas(t.bullets, num, titulo,
220                                        slide['izquierda'], slide['derecha'], cfg=cfg)
221                slides.append(inject_notes(xml, notas) if notas else xml)
222            elif tipo == 'comparar':
223                _emit_comparar(slides, make_comparar, t.bullets, num, titulo,
224                               slide['izquierda'], slide['derecha'], cfg=cfg, notas=notas)
225            elif tipo == 'comparar_python':
226                _emit_comparar(slides, make_comparar_python, t.bullets, num, titulo,
227                               slide['izquierda'], slide['derecha'], cfg=cfg, notas=notas)
228            elif tipo == 'comparar_js':
229                _emit_comparar(slides, make_comparar_js, t.bullets, num, titulo,
230                               slide['izquierda'], slide['derecha'], cfg=cfg, notas=notas)
231            elif tipo in ('comparar_xml', 'comparar_html'):
232                _emit_comparar(slides, make_comparar_xml, t.bullets, num, titulo,
233                               slide['izquierda'], slide['derecha'], cfg=cfg, notas=notas)
234            elif tipo == 'comparar_jsp':
235                _emit_comparar(slides, make_comparar_jsp, t.bullets, num, titulo,
236                               slide['izquierda'], slide['derecha'], cfg=cfg, notas=notas)
237            elif tipo == 'comparar_css':
238                _emit_comparar(slides, make_comparar_css, t.bullets, num, titulo,
239                               slide['izquierda'], slide['derecha'], cfg=cfg, notas=notas)
240            elif tipo == 'comparar_json':
241                _emit_comparar(slides, make_comparar_json, t.bullets, num, titulo,
242                               slide['izquierda'], slide['derecha'], cfg=cfg, notas=notas)
243            elif tipo == 'codigo':
244                _emit_code(slides, make_codigo, t.bullets, num, titulo, items, fichero, cfg, notas=notas)
245            elif tipo == 'javascript':
246                _emit_code(slides, make_javascript, t.bullets, num, titulo, items, fichero, cfg, notas=notas)
247            elif tipo == 'python':
248                _emit_code(slides, make_python, t.bullets, num, titulo, items, fichero, cfg, notas=notas)
249            elif tipo == 'yaml':
250                _emit_code(slides, make_yaml, t.bullets, num, titulo, items, fichero, cfg, notas=notas)
251            elif tipo in ('xml', 'html'):
252                _emit_code(slides, make_xml, t.bullets, num, titulo, items, fichero, cfg, notas=notas)
253            elif tipo == 'jsp':
254                _emit_code(slides, make_jsp, t.bullets, num, titulo, items, fichero, cfg, notas=notas)
255            elif tipo == 'css':
256                _emit_code(slides, make_css, t.bullets, num, titulo, items, fichero, cfg, notas=notas)
257            elif tipo == 'json':
258                _emit_code(slides, make_json, t.bullets, num, titulo, items, fichero, cfg, notas=notas)
259            elif tipo == 'imagen':
260                xml = make_imagen(t.bullets, num, titulo,
261                                  slide['ruta'], media, course_dir, cfg=cfg)
262                slides.append(inject_notes(xml, notas) if notas else xml)
263            elif tipo == 'imagen_texto':
264                xml = make_imagen_texto(t.bullets, num, titulo,
265                                        slide['ruta'], items,
266                                        media, course_dir, cfg=cfg)
267                slides.append(inject_notes(xml, notas) if notas else xml)
268            elif tipo == 'texto':
269                xml = make_single(t.single, num,
270                                  slide.get('titulo', titulo),
271                                  slide.get('texto', ''), cfg=cfg)
272                slides.append(inject_notes(xml, notas) if notas else xml)
273            elif tipo == 'cita':
274                cita_txt = slide.get('cita', '')
275                autor    = slide.get('autor', '')
276                fuente   = slide.get('fuente', '')
277                atrib    = f'— {autor}' + (f', {fuente}' if fuente else '') if autor else ''
278                body     = f{cita_txt}»' + (f'\n\n{atrib}' if atrib else '')
279                xml = make_single(t.single, num,
280                                  slide.get('titulo', titulo), body, cfg=cfg)
281                slides.append(inject_notes(xml, notas) if notas else xml)
282
283    conclusion = data['conclusion']
284    if isinstance(conclusion, list):
285        _emit_list(slides, make_bullets, t.bullets, conclusion, 'Conclusiones',
286                   'C', 'Conclusiones', cfg=cfg)
287    else:
288        slides.append(make_single(t.conclusion, 'C', 'Conclusiones', conclusion, cfg=cfg))
289    refs = [tuple(r) if isinstance(r, list) else r for r in data['referencias']]
290    slides.append(make_refs(t.references, refs, cfg=cfg))
291    slides.append(t.contact)
292    slides.append(t.license)
293
294    return slides

Build a complete slide list from a YAML content dict.

def _java_pt(n: int) -> int:
42def _java_pt(n: int) -> int:
43    """Font size for a Java code slide with n lines."""
44    for max_n, pt in _JAVA_SIZES:
45        if n <= max_n:
46            return pt
47    return 9

Font size for a Java code slide with n lines.

def build_list(style_list: str, style_p: str, style_span: str, items: list) -> str:
79def build_list(style_list: str, style_p: str, style_span: str, items: list) -> str:
80    parts = [
81        f'<text:list-item>'
82        f'<text:p text:style-name="{style_p}">'
83        f'<text:span text:style-name="{style_span}">{_xe_line_with_links(item, style_span)}</text:span>'
84        f'</text:p>'
85        f'</text:list-item>'
86        for item in items
87    ]
88    return f'<text:list text:style-name="{style_list}">{"".join(parts)}</text:list>'
def extract_slides(content: str) -> list:
133def extract_slides(content: str) -> list:
134    slides, pos = [], 0
135    while True:
136        start = content.find('<draw:page ', pos)
137        if start == -1:
138            break
139        end = content.find('</draw:page>', start) + len('</draw:page>')
140        slides.append(content[start:end])
141        pos = end
142    return slides
def finalize(slide: str, n: int) -> str:
145def finalize(slide: str, n: int) -> str:
146    s = re.sub(r'(draw:page-number=")(\d+)(")', rf'\g<1>{n}\3', slide)
147    s = re.sub(r'(draw:page draw:name=")([^"]+)(")', rf'\g<1>page{n}\3', s, count=1)
148    return s
def inject_notes(slide_xml: str, notes_text: str) -> str:
116def inject_notes(slide_xml: str, notes_text: str) -> str:
117    """Inject speaker notes into the presentation:notes text-box of a slide."""
118    if not notes_text:
119        return slide_xml
120    paragraphs = ''.join(
121        f'<text:p>{xe(line)}</text:p>'
122        for line in notes_text.split('\n')
123    )
124    return re.sub(
125        r'(<presentation:notes\b.*?<draw:text-box)/>',
126        rf'\g<1>>{paragraphs}</draw:text-box>',
127        slide_xml,
128        count=1,
129        flags=re.DOTALL,
130    )

Inject speaker notes into the presentation:notes text-box of a slide.

def xe_multiline(text: str, span_style: str) -> str:
58def xe_multiline(text: str, span_style: str) -> str:
59    """Como xe() pero:
60    - convierte \\n en <text:line-break/> (cerrando y reabriendo el span)
61    - convierte URLs (https?://…) en <text:a> clicables
62    Devuelve contenido para insertar entre el span de apertura y cierre.
63    """
64    open_s  = f'<text:span text:style-name="{span_style}">'
65    close_s = '</text:span>'
66    lines = text.split('\n')
67    processed = [_xe_line_with_links(line, span_style) for line in lines]
68    return (close_s + '<text:line-break/>' + open_s).join(processed)

Como xe() pero:

  • convierte \n en (cerrando y reabriendo el span)
  • convierte URLs (https?://…) en clicables Devuelve contenido para insertar entre el span de apertura y cierre.