lib.template_utils

Gestión de la configuración de plantilla ODP.

Permite importar una plantilla ODP y generar un template.yaml con los índices de diapositiva y nombres de estilo detectados automáticamente.

  1"""
  2Gestión de la configuración de plantilla ODP.
  3
  4Permite importar una plantilla ODP y generar un template.yaml con los índices
  5de diapositiva y nombres de estilo detectados automáticamente.
  6"""
  7
  8import re
  9import zipfile
 10from collections import Counter
 11from dataclasses import dataclass, field
 12from pathlib import Path
 13
 14import yaml
 15
 16
 17@dataclass
 18class TemplateConfig:
 19    """Configuración de una plantilla ODP para el generador de presentaciones."""
 20
 21    nombre: str = ''
 22    fichero: str = ''
 23
 24    # Índices de diapositiva en el ODP base (0-based)
 25    idx_portada: int = 0
 26    idx_objetivos: int = 1
 27    idx_indice: int = 2
 28    idx_intro: int = 3
 29    idx_contenido: int = 10
 30    idx_conclusion: int = 19
 31    idx_referencias: int = 20
 32    idx_contacto: int = 21
 33    idx_licencia: int = 22
 34
 35    # Estilos del ODP base
 36    portada_titulo: str = 'T1'
 37    portada_unidad: str = 'T2'
 38    portada_nivel: str = 'T4'
 39    indicador: str = 'T7'
 40    titulo: str = 'T10'
 41    intro_cuerpo: str = 'T9'
 42    lista_obj: str = 'L3'
 43    parrafo_obj: str = 'P16'
 44    span_obj: str = 'T6'
 45    umbral_obj_sm: int = 6
 46    umbral_obj_xs: int = 8
 47    parrafo_indice: str = 'P21'
 48    span_indice: str = 'T8'
 49    lista_contenido: str = 'L4'
 50    parrafo_contenido: str = 'P27'
 51    span_contenido: str = 'T6'
 52
 53    # Datos del slide de contacto
 54    url_contacto: str = ''
 55    email_contacto: str = ''
 56
 57    # Umbrales de partición de slides
 58    code_split_lines: int = 20
 59    list_split_items: int = 14
 60    compare_split_lines: int = 20
 61
 62    # Config de estilos inyectados en cada generación
 63    titulo_pt: int = 20
 64    obj_sm_pt: int = 18
 65    fuente_mono: str = 'Liberation Mono'
 66    codigo_pt: int = 18
 67    color_codigo: str = '#1e2d3d'
 68    color_comentario: str = '#6b7280'
 69    color_java: str = '#0d4f3c'
 70    color_subtitulo: str = '#000000'
 71    java_sizes: list = field(
 72        default_factory=lambda: [(12, 16), (16, 14), (21, 12), (27, 10), (9999, 9)]
 73    )
 74
 75    # Dimensiones del frame de contenido (extraídas del ODP en import)
 76    frame_ancho_cm: float = 23.898
 77    terminal_pt: int = 17
 78
 79    @property
 80    def terminal_chars_por_linea(self) -> int:
 81        """Caracteres por línea en slides terminal según frame y fuente."""
 82        char_w_cm = self.terminal_pt * 0.6 * 0.03528
 83        return max(40, int(self.frame_ancho_cm / char_w_cm) - 2)
 84
 85    # ── Serialización ──────────────────────────────────────────────────────────
 86
 87    def to_dict(self) -> dict:
 88        return {
 89            'meta': {
 90                'nombre': self.nombre,
 91                'fichero': self.fichero,
 92            },
 93            'diapositivas': {
 94                'portada': self.idx_portada,
 95                'objetivos': self.idx_objetivos,
 96                'indice': self.idx_indice,
 97                'intro': self.idx_intro,
 98                'contenido': self.idx_contenido,
 99                'conclusion': self.idx_conclusion,
100                'referencias': self.idx_referencias,
101                'contacto': self.idx_contacto,
102                'licencia': self.idx_licencia,
103            },
104            'estilos': {
105                'portada_titulo': self.portada_titulo,
106                'portada_unidad': self.portada_unidad,
107                'portada_nivel': self.portada_nivel,
108                'indicador': self.indicador,
109                'titulo': self.titulo,
110                'intro_cuerpo': self.intro_cuerpo,
111                'lista_obj': self.lista_obj,
112                'parrafo_obj': self.parrafo_obj,
113                'span_obj': self.span_obj,
114                'umbral_obj_sm': self.umbral_obj_sm,
115                'umbral_obj_xs': self.umbral_obj_xs,
116                'parrafo_indice': self.parrafo_indice,
117                'span_indice': self.span_indice,
118                'lista_contenido': self.lista_contenido,
119                'parrafo_contenido': self.parrafo_contenido,
120                'span_contenido': self.span_contenido,
121            },
122            'contacto': {
123                'url': self.url_contacto,
124                'email': self.email_contacto,
125            },
126            'generacion': {
127                'code_split_lines':    self.code_split_lines,
128                'list_split_items':    self.list_split_items,
129                'compare_split_lines': self.compare_split_lines,
130            },
131            'estilos_inyectados': {
132                'titulo_pt': self.titulo_pt,
133                'obj_sm_pt': self.obj_sm_pt,
134                'fuente_mono': self.fuente_mono,
135                'codigo_pt': self.codigo_pt,
136                'color_codigo': self.color_codigo,
137                'color_comentario': self.color_comentario,
138                'color_java': self.color_java,
139                'color_subtitulo': self.color_subtitulo,
140                'java_sizes': [list(pair) for pair in self.java_sizes],
141            },
142            'terminal': {
143                'frame_ancho_cm': self.frame_ancho_cm,
144                'terminal_pt': self.terminal_pt,
145            },
146        }
147
148    @classmethod
149    def from_dict(cls, d: dict) -> 'TemplateConfig':
150        cfg = cls()
151        meta = d.get('meta', {})
152        cfg.nombre = meta.get('nombre', cfg.nombre)
153        cfg.fichero = meta.get('fichero', cfg.fichero)
154
155        diaps = d.get('diapositivas', {})
156        cfg.idx_portada    = diaps.get('portada',    cfg.idx_portada)
157        cfg.idx_objetivos  = diaps.get('objetivos',  cfg.idx_objetivos)
158        cfg.idx_indice     = diaps.get('indice',     cfg.idx_indice)
159        cfg.idx_intro      = diaps.get('intro',      cfg.idx_intro)
160        cfg.idx_contenido  = diaps.get('contenido',  cfg.idx_contenido)
161        cfg.idx_conclusion = diaps.get('conclusion', cfg.idx_conclusion)
162        cfg.idx_referencias = diaps.get('referencias', cfg.idx_referencias)
163        cfg.idx_contacto   = diaps.get('contacto',  cfg.idx_contacto)
164        cfg.idx_licencia   = diaps.get('licencia',   cfg.idx_licencia)
165
166        estilos = d.get('estilos', {})
167        cfg.portada_titulo   = estilos.get('portada_titulo',   cfg.portada_titulo)
168        cfg.portada_unidad   = estilos.get('portada_unidad',   cfg.portada_unidad)
169        cfg.portada_nivel    = estilos.get('portada_nivel',    cfg.portada_nivel)
170        cfg.indicador        = estilos.get('indicador',        cfg.indicador)
171        cfg.titulo           = estilos.get('titulo',           cfg.titulo)
172        cfg.intro_cuerpo     = estilos.get('intro_cuerpo',     cfg.intro_cuerpo)
173        cfg.lista_obj        = estilos.get('lista_obj',        cfg.lista_obj)
174        cfg.parrafo_obj      = estilos.get('parrafo_obj',      cfg.parrafo_obj)
175        cfg.span_obj         = estilos.get('span_obj',         cfg.span_obj)
176        cfg.umbral_obj_sm    = estilos.get('umbral_obj_sm',    cfg.umbral_obj_sm)
177        cfg.umbral_obj_xs    = estilos.get('umbral_obj_xs',    cfg.umbral_obj_xs)
178        cfg.parrafo_indice   = estilos.get('parrafo_indice',   cfg.parrafo_indice)
179        cfg.span_indice      = estilos.get('span_indice',      cfg.span_indice)
180        cfg.lista_contenido  = estilos.get('lista_contenido',  cfg.lista_contenido)
181        cfg.parrafo_contenido = estilos.get('parrafo_contenido', cfg.parrafo_contenido)
182        cfg.span_contenido   = estilos.get('span_contenido',   cfg.span_contenido)
183
184        contacto = d.get('contacto', {})
185        cfg.url_contacto    = contacto.get('url',   cfg.url_contacto)
186        cfg.email_contacto  = contacto.get('email', cfg.email_contacto)
187
188        gen = d.get('generacion', {})
189        cfg.code_split_lines    = gen.get('code_split_lines',    cfg.code_split_lines)
190        cfg.list_split_items    = gen.get('list_split_items',    cfg.list_split_items)
191        cfg.compare_split_lines = gen.get('compare_split_lines', cfg.compare_split_lines)
192
193        inj = d.get('estilos_inyectados', {})
194        cfg.titulo_pt        = inj.get('titulo_pt',        cfg.titulo_pt)
195        cfg.obj_sm_pt        = inj.get('obj_sm_pt',        cfg.obj_sm_pt)
196        cfg.fuente_mono      = inj.get('fuente_mono',      cfg.fuente_mono)
197        cfg.codigo_pt        = inj.get('codigo_pt',        cfg.codigo_pt)
198        cfg.color_codigo     = inj.get('color_codigo',     cfg.color_codigo)
199        cfg.color_comentario = inj.get('color_comentario', cfg.color_comentario)
200        cfg.color_java       = inj.get('color_java',       cfg.color_java)
201        cfg.color_subtitulo  = inj.get('color_subtitulo',  cfg.color_subtitulo)
202        if 'java_sizes' in inj:
203            cfg.java_sizes = [tuple(p) for p in inj['java_sizes']]
204
205        term = d.get('terminal', {})
206        cfg.frame_ancho_cm = term.get('frame_ancho_cm', cfg.frame_ancho_cm)
207        cfg.terminal_pt    = term.get('terminal_pt',    cfg.terminal_pt)
208
209        return cfg
210
211    @classmethod
212    def from_yaml(cls, path: Path) -> 'TemplateConfig':
213        return cls.from_dict(yaml.safe_load(path.read_text(encoding='utf-8')))
214
215    def save_yaml(self, path: Path) -> None:
216        path.parent.mkdir(parents=True, exist_ok=True)
217        path.write_text(
218            yaml.dump(
219                self.to_dict(),
220                allow_unicode=True,
221                default_flow_style=False,
222                sort_keys=False,
223            ),
224            encoding='utf-8',
225        )
226
227
228# ── Helpers de detección ──────────────────────────────────────────────────────
229
230def _detect_slide_roles(slides: list) -> dict:
231    """
232    Detect the semantic role of each slide based on structural patterns.
233    Returns dict mapping role name → slide index.
234    """
235    n = len(slides)
236    roles: dict = {
237        'portada': 0,
238        'contacto': n - 2,
239        'licencia': n - 1,
240    }
241
242    list_no_xlink = []
243    list_with_xlink = []
244    no_list_no_xlink = []
245
246    for i in range(1, n - 2):
247        s = slides[i]
248        has_list = bool(re.search(r'<text:list\s+text:style-name="[^"]+">', s))
249        has_xlink = 'xlink:href' in s
250
251        if has_list:
252            (list_with_xlink if has_xlink else list_no_xlink).append(i)
253        elif not has_xlink:
254            no_list_no_xlink.append(i)
255
256    # First two list-no-xlink = objetivos, indice; first additional = contenido
257    if len(list_no_xlink) >= 2:
258        roles['objetivos'] = list_no_xlink[0]
259        roles['indice'] = list_no_xlink[1]
260    elif list_no_xlink:
261        roles['objetivos'] = list_no_xlink[0]
262
263    if len(list_no_xlink) >= 3:
264        roles['contenido'] = list_no_xlink[2]
265
266    # First list-with-xlink = referencias
267    if list_with_xlink:
268        roles['referencias'] = list_with_xlink[0]
269
270    # No-list-no-xlink: first = intro, last before referencias = conclusion
271    ref_idx = roles.get('referencias', n)
272    before_refs = [i for i in no_list_no_xlink if i < ref_idx]
273    if before_refs:
274        roles['intro'] = before_refs[0]
275        roles['conclusion'] = before_refs[-1]
276
277    return roles
278
279
280def _extract_portada_styles(slide: str, cfg: TemplateConfig) -> None:
281    spans = [
282        (m.group(1), m.group(2).strip())
283        for m in re.finditer(r'<text:span text:style-name="([^"]+)">([^<]+)</text:span>', slide)
284        if m.group(2).strip()
285    ]
286
287    unidad_idx = None
288    for i, (style, text) in enumerate(spans):
289        if re.match(r'Unidad\s+\d+', text):
290            cfg.portada_unidad = style
291            unidad_idx = i
292            break
293
294    if unidad_idx is not None and unidad_idx > 0:
295        cfg.portada_titulo = spans[0][0]
296
297    if unidad_idx is not None:
298        # portada_nivel: first short span (≤2 words) after unidad
299        for style, text in spans[unidad_idx + 1:]:
300            if len(text.split()) <= 2:
301                cfg.portada_nivel = style
302                break
303
304
305def _extract_objetivos_styles(slide: str, cfg: TemplateConfig) -> None:
306    m = re.search(r'<text:list\s+text:style-name="([^"]+)">', slide)
307    if m:
308        cfg.lista_obj = m.group(1)
309
310    item_paras = re.findall(
311        r'<text:list-item>.*?<text:p\s+text:style-name="([^"]+)">', slide, re.DOTALL
312    )
313    if item_paras:
314        cfg.parrafo_obj = Counter(item_paras).most_common(1)[0][0]
315
316    item_spans = re.findall(
317        r'<text:list-item>.*?<text:span\s+text:style-name="([^"]+)">', slide, re.DOTALL
318    )
319    if item_spans:
320        cfg.span_obj = Counter(item_spans).most_common(1)[0][0]
321
322
323def _extract_indice_styles(slide: str, cfg: TemplateConfig) -> None:
324    item_paras = re.findall(
325        r'<text:list-item>.*?<text:p\s+text:style-name="([^"]+)">', slide, re.DOTALL
326    )
327    if item_paras:
328        cfg.parrafo_indice = Counter(item_paras).most_common(1)[0][0]
329
330    item_spans = re.findall(
331        r'<text:list-item>.*?<text:span\s+text:style-name="([^"]+)">', slide, re.DOTALL
332    )
333    if item_spans:
334        cfg.span_indice = Counter(item_spans).most_common(1)[0][0]
335
336
337def _extract_single_styles(slide: str, cfg: TemplateConfig) -> None:
338    spans = [
339        (m.group(1), m.group(2).strip())
340        for m in re.finditer(r'<text:span text:style-name="([^"]+)">([^<]*)</text:span>', slide)
341        if m.group(2).strip()
342    ]
343    if len(spans) < 2:
344        return
345
346    # Indicator: span with shortest content (typically "1", "2", "C")
347    indicator = min(spans, key=lambda x: len(x[1]))
348    cfg.indicador = indicator[0]
349
350    # Body: span with longest content
351    body = max(spans, key=lambda x: len(x[1]))
352    if body[0] != indicator[0]:
353        cfg.intro_cuerpo = body[0]
354
355    # Title: first span that's neither indicator nor body
356    for style, text in spans:
357        if style not in (cfg.indicador, cfg.intro_cuerpo) and len(text) > 2:
358            cfg.titulo = style
359            break
360
361
362def _extract_contacto_links(slide: str, cfg: TemplateConfig) -> None:
363    """Detecta URL web y email del slide de contacto."""
364    for href in re.findall(r'xlink:href="([^"]+)"', slide):
365        if href.startswith('mailto:'):
366            email = re.sub(r'^mailto:([^?]+).*', r'\1', href)
367            cfg.email_contacto = email
368        elif href.startswith('http'):
369            # Normaliza: https, sin www, sin barra final
370            url = re.sub(r'^https?://(www\.)?', 'https://', href).rstrip('/')
371            cfg.url_contacto = url
372
373
374def _extract_contenido_styles(slide: str, cfg: TemplateConfig) -> None:
375    m = re.search(r'<text:list\s+text:style-name="([^"]+)">', slide)
376    if m:
377        cfg.lista_contenido = m.group(1)
378
379    item_paras = re.findall(
380        r'<text:list-item>.*?<text:p\s+text:style-name="([^"]+)">', slide, re.DOTALL
381    )
382    if item_paras:
383        cfg.parrafo_contenido = Counter(item_paras).most_common(1)[0][0]
384
385    item_spans = re.findall(
386        r'<text:list-item>.*?<text:span\s+text:style-name="([^"]+)">', slide, re.DOTALL
387    )
388    if item_spans:
389        cfg.span_contenido = Counter(item_spans).most_common(1)[0][0]
390
391
392# ── API pública ───────────────────────────────────────────────────────────────
393
394def extraer_template(odp_path: Path) -> TemplateConfig:
395    """
396    Analiza un ODP de plantilla y devuelve un TemplateConfig con los índices
397    de diapositiva y nombres de estilo detectados automáticamente.
398
399    La detección es heurística y puede necesitar ajuste manual para plantillas
400    no estándar.
401    """
402    from lib.odp_utils import extract_slides
403
404    with zipfile.ZipFile(odp_path) as z:
405        content_xml = z.read('content.xml').decode('utf-8')
406
407    slides = extract_slides(content_xml)
408    cfg = TemplateConfig(nombre=odp_path.stem, fichero=odp_path.name)
409
410    roles = _detect_slide_roles(slides)
411
412    # Map detected roles to TemplateConfig indices
413    _ROLE_ATTR = {
414        'portada':    'idx_portada',
415        'objetivos':  'idx_objetivos',
416        'indice':     'idx_indice',
417        'intro':      'idx_intro',
418        'contenido':  'idx_contenido',
419        'conclusion': 'idx_conclusion',
420        'referencias': 'idx_referencias',
421        'contacto':   'idx_contacto',
422        'licencia':   'idx_licencia',
423    }
424    for role, attr in _ROLE_ATTR.items():
425        if role in roles:
426            setattr(cfg, attr, roles[role])
427
428    # Extract style names from detected slides
429    if 'portada' in roles:
430        _extract_portada_styles(slides[roles['portada']], cfg)
431    if 'objetivos' in roles:
432        _extract_objetivos_styles(slides[roles['objetivos']], cfg)
433    if 'indice' in roles:
434        _extract_indice_styles(slides[roles['indice']], cfg)
435    if 'intro' in roles:
436        _extract_single_styles(slides[roles['intro']], cfg)
437    if 'contenido' in roles:
438        _extract_contenido_styles(slides[roles['contenido']], cfg)
439    if 'contacto' in roles:
440        _extract_contacto_links(slides[roles['contacto']], cfg)
441
442    # Extraer ancho del frame de contenido del slide de contenido
443    if 'contenido' in roles:
444        slide = slides[roles['contenido']]
445        m = re.search(
446            r'<draw:frame\b[^>]*svg:width="([0-9.]+)cm"[^>]*>'
447            r'\s*<draw:text-box>',
448            slide,
449        )
450        if m:
451            cfg.frame_ancho_cm = float(m.group(1))
452
453    return cfg
454
455
456def load_course_config(course_dir: Path) -> TemplateConfig:
457    """
458    Carga template.yaml del directorio de curso si existe, aplicando primero
459    los valores globales de generación del fichero de usuario como defaults.
460    """
461    from lib.slides_config import get_generation_config
462    gen = get_generation_config()
463
464    template_yaml = course_dir / 'template' / 'template.yaml'
465    if template_yaml.exists():
466        cfg = TemplateConfig.from_yaml(template_yaml)
467    else:
468        cfg = TemplateConfig()
469
470    # Aplica config global solo si el template.yaml no sobreescribe el valor
471    if not template_yaml.exists() or 'generacion' not in (
472        yaml.safe_load(template_yaml.read_text(encoding='utf-8')) or {}
473    ):
474        cfg.code_split_lines    = gen['code_split_lines']
475        cfg.list_split_items    = gen['list_split_items']
476        cfg.compare_split_lines = gen['compare_split_lines']
477        cfg.java_sizes          = [tuple(p) for p in gen['code_font_sizes']]
478
479    return cfg
@dataclass
class TemplateConfig:
 18@dataclass
 19class TemplateConfig:
 20    """Configuración de una plantilla ODP para el generador de presentaciones."""
 21
 22    nombre: str = ''
 23    fichero: str = ''
 24
 25    # Índices de diapositiva en el ODP base (0-based)
 26    idx_portada: int = 0
 27    idx_objetivos: int = 1
 28    idx_indice: int = 2
 29    idx_intro: int = 3
 30    idx_contenido: int = 10
 31    idx_conclusion: int = 19
 32    idx_referencias: int = 20
 33    idx_contacto: int = 21
 34    idx_licencia: int = 22
 35
 36    # Estilos del ODP base
 37    portada_titulo: str = 'T1'
 38    portada_unidad: str = 'T2'
 39    portada_nivel: str = 'T4'
 40    indicador: str = 'T7'
 41    titulo: str = 'T10'
 42    intro_cuerpo: str = 'T9'
 43    lista_obj: str = 'L3'
 44    parrafo_obj: str = 'P16'
 45    span_obj: str = 'T6'
 46    umbral_obj_sm: int = 6
 47    umbral_obj_xs: int = 8
 48    parrafo_indice: str = 'P21'
 49    span_indice: str = 'T8'
 50    lista_contenido: str = 'L4'
 51    parrafo_contenido: str = 'P27'
 52    span_contenido: str = 'T6'
 53
 54    # Datos del slide de contacto
 55    url_contacto: str = ''
 56    email_contacto: str = ''
 57
 58    # Umbrales de partición de slides
 59    code_split_lines: int = 20
 60    list_split_items: int = 14
 61    compare_split_lines: int = 20
 62
 63    # Config de estilos inyectados en cada generación
 64    titulo_pt: int = 20
 65    obj_sm_pt: int = 18
 66    fuente_mono: str = 'Liberation Mono'
 67    codigo_pt: int = 18
 68    color_codigo: str = '#1e2d3d'
 69    color_comentario: str = '#6b7280'
 70    color_java: str = '#0d4f3c'
 71    color_subtitulo: str = '#000000'
 72    java_sizes: list = field(
 73        default_factory=lambda: [(12, 16), (16, 14), (21, 12), (27, 10), (9999, 9)]
 74    )
 75
 76    # Dimensiones del frame de contenido (extraídas del ODP en import)
 77    frame_ancho_cm: float = 23.898
 78    terminal_pt: int = 17
 79
 80    @property
 81    def terminal_chars_por_linea(self) -> int:
 82        """Caracteres por línea en slides terminal según frame y fuente."""
 83        char_w_cm = self.terminal_pt * 0.6 * 0.03528
 84        return max(40, int(self.frame_ancho_cm / char_w_cm) - 2)
 85
 86    # ── Serialización ──────────────────────────────────────────────────────────
 87
 88    def to_dict(self) -> dict:
 89        return {
 90            'meta': {
 91                'nombre': self.nombre,
 92                'fichero': self.fichero,
 93            },
 94            'diapositivas': {
 95                'portada': self.idx_portada,
 96                'objetivos': self.idx_objetivos,
 97                'indice': self.idx_indice,
 98                'intro': self.idx_intro,
 99                'contenido': self.idx_contenido,
100                'conclusion': self.idx_conclusion,
101                'referencias': self.idx_referencias,
102                'contacto': self.idx_contacto,
103                'licencia': self.idx_licencia,
104            },
105            'estilos': {
106                'portada_titulo': self.portada_titulo,
107                'portada_unidad': self.portada_unidad,
108                'portada_nivel': self.portada_nivel,
109                'indicador': self.indicador,
110                'titulo': self.titulo,
111                'intro_cuerpo': self.intro_cuerpo,
112                'lista_obj': self.lista_obj,
113                'parrafo_obj': self.parrafo_obj,
114                'span_obj': self.span_obj,
115                'umbral_obj_sm': self.umbral_obj_sm,
116                'umbral_obj_xs': self.umbral_obj_xs,
117                'parrafo_indice': self.parrafo_indice,
118                'span_indice': self.span_indice,
119                'lista_contenido': self.lista_contenido,
120                'parrafo_contenido': self.parrafo_contenido,
121                'span_contenido': self.span_contenido,
122            },
123            'contacto': {
124                'url': self.url_contacto,
125                'email': self.email_contacto,
126            },
127            'generacion': {
128                'code_split_lines':    self.code_split_lines,
129                'list_split_items':    self.list_split_items,
130                'compare_split_lines': self.compare_split_lines,
131            },
132            'estilos_inyectados': {
133                'titulo_pt': self.titulo_pt,
134                'obj_sm_pt': self.obj_sm_pt,
135                'fuente_mono': self.fuente_mono,
136                'codigo_pt': self.codigo_pt,
137                'color_codigo': self.color_codigo,
138                'color_comentario': self.color_comentario,
139                'color_java': self.color_java,
140                'color_subtitulo': self.color_subtitulo,
141                'java_sizes': [list(pair) for pair in self.java_sizes],
142            },
143            'terminal': {
144                'frame_ancho_cm': self.frame_ancho_cm,
145                'terminal_pt': self.terminal_pt,
146            },
147        }
148
149    @classmethod
150    def from_dict(cls, d: dict) -> 'TemplateConfig':
151        cfg = cls()
152        meta = d.get('meta', {})
153        cfg.nombre = meta.get('nombre', cfg.nombre)
154        cfg.fichero = meta.get('fichero', cfg.fichero)
155
156        diaps = d.get('diapositivas', {})
157        cfg.idx_portada    = diaps.get('portada',    cfg.idx_portada)
158        cfg.idx_objetivos  = diaps.get('objetivos',  cfg.idx_objetivos)
159        cfg.idx_indice     = diaps.get('indice',     cfg.idx_indice)
160        cfg.idx_intro      = diaps.get('intro',      cfg.idx_intro)
161        cfg.idx_contenido  = diaps.get('contenido',  cfg.idx_contenido)
162        cfg.idx_conclusion = diaps.get('conclusion', cfg.idx_conclusion)
163        cfg.idx_referencias = diaps.get('referencias', cfg.idx_referencias)
164        cfg.idx_contacto   = diaps.get('contacto',  cfg.idx_contacto)
165        cfg.idx_licencia   = diaps.get('licencia',   cfg.idx_licencia)
166
167        estilos = d.get('estilos', {})
168        cfg.portada_titulo   = estilos.get('portada_titulo',   cfg.portada_titulo)
169        cfg.portada_unidad   = estilos.get('portada_unidad',   cfg.portada_unidad)
170        cfg.portada_nivel    = estilos.get('portada_nivel',    cfg.portada_nivel)
171        cfg.indicador        = estilos.get('indicador',        cfg.indicador)
172        cfg.titulo           = estilos.get('titulo',           cfg.titulo)
173        cfg.intro_cuerpo     = estilos.get('intro_cuerpo',     cfg.intro_cuerpo)
174        cfg.lista_obj        = estilos.get('lista_obj',        cfg.lista_obj)
175        cfg.parrafo_obj      = estilos.get('parrafo_obj',      cfg.parrafo_obj)
176        cfg.span_obj         = estilos.get('span_obj',         cfg.span_obj)
177        cfg.umbral_obj_sm    = estilos.get('umbral_obj_sm',    cfg.umbral_obj_sm)
178        cfg.umbral_obj_xs    = estilos.get('umbral_obj_xs',    cfg.umbral_obj_xs)
179        cfg.parrafo_indice   = estilos.get('parrafo_indice',   cfg.parrafo_indice)
180        cfg.span_indice      = estilos.get('span_indice',      cfg.span_indice)
181        cfg.lista_contenido  = estilos.get('lista_contenido',  cfg.lista_contenido)
182        cfg.parrafo_contenido = estilos.get('parrafo_contenido', cfg.parrafo_contenido)
183        cfg.span_contenido   = estilos.get('span_contenido',   cfg.span_contenido)
184
185        contacto = d.get('contacto', {})
186        cfg.url_contacto    = contacto.get('url',   cfg.url_contacto)
187        cfg.email_contacto  = contacto.get('email', cfg.email_contacto)
188
189        gen = d.get('generacion', {})
190        cfg.code_split_lines    = gen.get('code_split_lines',    cfg.code_split_lines)
191        cfg.list_split_items    = gen.get('list_split_items',    cfg.list_split_items)
192        cfg.compare_split_lines = gen.get('compare_split_lines', cfg.compare_split_lines)
193
194        inj = d.get('estilos_inyectados', {})
195        cfg.titulo_pt        = inj.get('titulo_pt',        cfg.titulo_pt)
196        cfg.obj_sm_pt        = inj.get('obj_sm_pt',        cfg.obj_sm_pt)
197        cfg.fuente_mono      = inj.get('fuente_mono',      cfg.fuente_mono)
198        cfg.codigo_pt        = inj.get('codigo_pt',        cfg.codigo_pt)
199        cfg.color_codigo     = inj.get('color_codigo',     cfg.color_codigo)
200        cfg.color_comentario = inj.get('color_comentario', cfg.color_comentario)
201        cfg.color_java       = inj.get('color_java',       cfg.color_java)
202        cfg.color_subtitulo  = inj.get('color_subtitulo',  cfg.color_subtitulo)
203        if 'java_sizes' in inj:
204            cfg.java_sizes = [tuple(p) for p in inj['java_sizes']]
205
206        term = d.get('terminal', {})
207        cfg.frame_ancho_cm = term.get('frame_ancho_cm', cfg.frame_ancho_cm)
208        cfg.terminal_pt    = term.get('terminal_pt',    cfg.terminal_pt)
209
210        return cfg
211
212    @classmethod
213    def from_yaml(cls, path: Path) -> 'TemplateConfig':
214        return cls.from_dict(yaml.safe_load(path.read_text(encoding='utf-8')))
215
216    def save_yaml(self, path: Path) -> None:
217        path.parent.mkdir(parents=True, exist_ok=True)
218        path.write_text(
219            yaml.dump(
220                self.to_dict(),
221                allow_unicode=True,
222                default_flow_style=False,
223                sort_keys=False,
224            ),
225            encoding='utf-8',
226        )

Configuración de una plantilla ODP para el generador de presentaciones.

TemplateConfig( nombre: str = '', fichero: str = '', idx_portada: int = 0, idx_objetivos: int = 1, idx_indice: int = 2, idx_intro: int = 3, idx_contenido: int = 10, idx_conclusion: int = 19, idx_referencias: int = 20, idx_contacto: int = 21, idx_licencia: int = 22, portada_titulo: str = 'T1', portada_unidad: str = 'T2', portada_nivel: str = 'T4', indicador: str = 'T7', titulo: str = 'T10', intro_cuerpo: str = 'T9', lista_obj: str = 'L3', parrafo_obj: str = 'P16', span_obj: str = 'T6', umbral_obj_sm: int = 6, umbral_obj_xs: int = 8, parrafo_indice: str = 'P21', span_indice: str = 'T8', lista_contenido: str = 'L4', parrafo_contenido: str = 'P27', span_contenido: str = 'T6', url_contacto: str = '', email_contacto: str = '', code_split_lines: int = 20, list_split_items: int = 14, compare_split_lines: int = 20, titulo_pt: int = 20, obj_sm_pt: int = 18, fuente_mono: str = 'Liberation Mono', codigo_pt: int = 18, color_codigo: str = '#1e2d3d', color_comentario: str = '#6b7280', color_java: str = '#0d4f3c', color_subtitulo: str = '#000000', java_sizes: list = <factory>, frame_ancho_cm: float = 23.898, terminal_pt: int = 17)
nombre: str = ''
fichero: str = ''
idx_portada: int = 0
idx_objetivos: int = 1
idx_indice: int = 2
idx_intro: int = 3
idx_contenido: int = 10
idx_conclusion: int = 19
idx_referencias: int = 20
idx_contacto: int = 21
idx_licencia: int = 22
portada_titulo: str = 'T1'
portada_unidad: str = 'T2'
portada_nivel: str = 'T4'
indicador: str = 'T7'
titulo: str = 'T10'
intro_cuerpo: str = 'T9'
lista_obj: str = 'L3'
parrafo_obj: str = 'P16'
span_obj: str = 'T6'
umbral_obj_sm: int = 6
umbral_obj_xs: int = 8
parrafo_indice: str = 'P21'
span_indice: str = 'T8'
lista_contenido: str = 'L4'
parrafo_contenido: str = 'P27'
span_contenido: str = 'T6'
url_contacto: str = ''
email_contacto: str = ''
code_split_lines: int = 20
list_split_items: int = 14
compare_split_lines: int = 20
titulo_pt: int = 20
obj_sm_pt: int = 18
fuente_mono: str = 'Liberation Mono'
codigo_pt: int = 18
color_codigo: str = '#1e2d3d'
color_comentario: str = '#6b7280'
color_java: str = '#0d4f3c'
color_subtitulo: str = '#000000'
java_sizes: list
frame_ancho_cm: float = 23.898
terminal_pt: int = 17
terminal_chars_por_linea: int
80    @property
81    def terminal_chars_por_linea(self) -> int:
82        """Caracteres por línea en slides terminal según frame y fuente."""
83        char_w_cm = self.terminal_pt * 0.6 * 0.03528
84        return max(40, int(self.frame_ancho_cm / char_w_cm) - 2)

Caracteres por línea en slides terminal según frame y fuente.

def to_dict(self) -> dict:
 88    def to_dict(self) -> dict:
 89        return {
 90            'meta': {
 91                'nombre': self.nombre,
 92                'fichero': self.fichero,
 93            },
 94            'diapositivas': {
 95                'portada': self.idx_portada,
 96                'objetivos': self.idx_objetivos,
 97                'indice': self.idx_indice,
 98                'intro': self.idx_intro,
 99                'contenido': self.idx_contenido,
100                'conclusion': self.idx_conclusion,
101                'referencias': self.idx_referencias,
102                'contacto': self.idx_contacto,
103                'licencia': self.idx_licencia,
104            },
105            'estilos': {
106                'portada_titulo': self.portada_titulo,
107                'portada_unidad': self.portada_unidad,
108                'portada_nivel': self.portada_nivel,
109                'indicador': self.indicador,
110                'titulo': self.titulo,
111                'intro_cuerpo': self.intro_cuerpo,
112                'lista_obj': self.lista_obj,
113                'parrafo_obj': self.parrafo_obj,
114                'span_obj': self.span_obj,
115                'umbral_obj_sm': self.umbral_obj_sm,
116                'umbral_obj_xs': self.umbral_obj_xs,
117                'parrafo_indice': self.parrafo_indice,
118                'span_indice': self.span_indice,
119                'lista_contenido': self.lista_contenido,
120                'parrafo_contenido': self.parrafo_contenido,
121                'span_contenido': self.span_contenido,
122            },
123            'contacto': {
124                'url': self.url_contacto,
125                'email': self.email_contacto,
126            },
127            'generacion': {
128                'code_split_lines':    self.code_split_lines,
129                'list_split_items':    self.list_split_items,
130                'compare_split_lines': self.compare_split_lines,
131            },
132            'estilos_inyectados': {
133                'titulo_pt': self.titulo_pt,
134                'obj_sm_pt': self.obj_sm_pt,
135                'fuente_mono': self.fuente_mono,
136                'codigo_pt': self.codigo_pt,
137                'color_codigo': self.color_codigo,
138                'color_comentario': self.color_comentario,
139                'color_java': self.color_java,
140                'color_subtitulo': self.color_subtitulo,
141                'java_sizes': [list(pair) for pair in self.java_sizes],
142            },
143            'terminal': {
144                'frame_ancho_cm': self.frame_ancho_cm,
145                'terminal_pt': self.terminal_pt,
146            },
147        }
@classmethod
def from_dict(cls, d: dict) -> TemplateConfig:
149    @classmethod
150    def from_dict(cls, d: dict) -> 'TemplateConfig':
151        cfg = cls()
152        meta = d.get('meta', {})
153        cfg.nombre = meta.get('nombre', cfg.nombre)
154        cfg.fichero = meta.get('fichero', cfg.fichero)
155
156        diaps = d.get('diapositivas', {})
157        cfg.idx_portada    = diaps.get('portada',    cfg.idx_portada)
158        cfg.idx_objetivos  = diaps.get('objetivos',  cfg.idx_objetivos)
159        cfg.idx_indice     = diaps.get('indice',     cfg.idx_indice)
160        cfg.idx_intro      = diaps.get('intro',      cfg.idx_intro)
161        cfg.idx_contenido  = diaps.get('contenido',  cfg.idx_contenido)
162        cfg.idx_conclusion = diaps.get('conclusion', cfg.idx_conclusion)
163        cfg.idx_referencias = diaps.get('referencias', cfg.idx_referencias)
164        cfg.idx_contacto   = diaps.get('contacto',  cfg.idx_contacto)
165        cfg.idx_licencia   = diaps.get('licencia',   cfg.idx_licencia)
166
167        estilos = d.get('estilos', {})
168        cfg.portada_titulo   = estilos.get('portada_titulo',   cfg.portada_titulo)
169        cfg.portada_unidad   = estilos.get('portada_unidad',   cfg.portada_unidad)
170        cfg.portada_nivel    = estilos.get('portada_nivel',    cfg.portada_nivel)
171        cfg.indicador        = estilos.get('indicador',        cfg.indicador)
172        cfg.titulo           = estilos.get('titulo',           cfg.titulo)
173        cfg.intro_cuerpo     = estilos.get('intro_cuerpo',     cfg.intro_cuerpo)
174        cfg.lista_obj        = estilos.get('lista_obj',        cfg.lista_obj)
175        cfg.parrafo_obj      = estilos.get('parrafo_obj',      cfg.parrafo_obj)
176        cfg.span_obj         = estilos.get('span_obj',         cfg.span_obj)
177        cfg.umbral_obj_sm    = estilos.get('umbral_obj_sm',    cfg.umbral_obj_sm)
178        cfg.umbral_obj_xs    = estilos.get('umbral_obj_xs',    cfg.umbral_obj_xs)
179        cfg.parrafo_indice   = estilos.get('parrafo_indice',   cfg.parrafo_indice)
180        cfg.span_indice      = estilos.get('span_indice',      cfg.span_indice)
181        cfg.lista_contenido  = estilos.get('lista_contenido',  cfg.lista_contenido)
182        cfg.parrafo_contenido = estilos.get('parrafo_contenido', cfg.parrafo_contenido)
183        cfg.span_contenido   = estilos.get('span_contenido',   cfg.span_contenido)
184
185        contacto = d.get('contacto', {})
186        cfg.url_contacto    = contacto.get('url',   cfg.url_contacto)
187        cfg.email_contacto  = contacto.get('email', cfg.email_contacto)
188
189        gen = d.get('generacion', {})
190        cfg.code_split_lines    = gen.get('code_split_lines',    cfg.code_split_lines)
191        cfg.list_split_items    = gen.get('list_split_items',    cfg.list_split_items)
192        cfg.compare_split_lines = gen.get('compare_split_lines', cfg.compare_split_lines)
193
194        inj = d.get('estilos_inyectados', {})
195        cfg.titulo_pt        = inj.get('titulo_pt',        cfg.titulo_pt)
196        cfg.obj_sm_pt        = inj.get('obj_sm_pt',        cfg.obj_sm_pt)
197        cfg.fuente_mono      = inj.get('fuente_mono',      cfg.fuente_mono)
198        cfg.codigo_pt        = inj.get('codigo_pt',        cfg.codigo_pt)
199        cfg.color_codigo     = inj.get('color_codigo',     cfg.color_codigo)
200        cfg.color_comentario = inj.get('color_comentario', cfg.color_comentario)
201        cfg.color_java       = inj.get('color_java',       cfg.color_java)
202        cfg.color_subtitulo  = inj.get('color_subtitulo',  cfg.color_subtitulo)
203        if 'java_sizes' in inj:
204            cfg.java_sizes = [tuple(p) for p in inj['java_sizes']]
205
206        term = d.get('terminal', {})
207        cfg.frame_ancho_cm = term.get('frame_ancho_cm', cfg.frame_ancho_cm)
208        cfg.terminal_pt    = term.get('terminal_pt',    cfg.terminal_pt)
209
210        return cfg
@classmethod
def from_yaml(cls, path: pathlib.Path) -> TemplateConfig:
212    @classmethod
213    def from_yaml(cls, path: Path) -> 'TemplateConfig':
214        return cls.from_dict(yaml.safe_load(path.read_text(encoding='utf-8')))
def save_yaml(self, path: pathlib.Path) -> None:
216    def save_yaml(self, path: Path) -> None:
217        path.parent.mkdir(parents=True, exist_ok=True)
218        path.write_text(
219            yaml.dump(
220                self.to_dict(),
221                allow_unicode=True,
222                default_flow_style=False,
223                sort_keys=False,
224            ),
225            encoding='utf-8',
226        )
def extraer_template(odp_path: pathlib.Path) -> TemplateConfig:
395def extraer_template(odp_path: Path) -> TemplateConfig:
396    """
397    Analiza un ODP de plantilla y devuelve un TemplateConfig con los índices
398    de diapositiva y nombres de estilo detectados automáticamente.
399
400    La detección es heurística y puede necesitar ajuste manual para plantillas
401    no estándar.
402    """
403    from lib.odp_utils import extract_slides
404
405    with zipfile.ZipFile(odp_path) as z:
406        content_xml = z.read('content.xml').decode('utf-8')
407
408    slides = extract_slides(content_xml)
409    cfg = TemplateConfig(nombre=odp_path.stem, fichero=odp_path.name)
410
411    roles = _detect_slide_roles(slides)
412
413    # Map detected roles to TemplateConfig indices
414    _ROLE_ATTR = {
415        'portada':    'idx_portada',
416        'objetivos':  'idx_objetivos',
417        'indice':     'idx_indice',
418        'intro':      'idx_intro',
419        'contenido':  'idx_contenido',
420        'conclusion': 'idx_conclusion',
421        'referencias': 'idx_referencias',
422        'contacto':   'idx_contacto',
423        'licencia':   'idx_licencia',
424    }
425    for role, attr in _ROLE_ATTR.items():
426        if role in roles:
427            setattr(cfg, attr, roles[role])
428
429    # Extract style names from detected slides
430    if 'portada' in roles:
431        _extract_portada_styles(slides[roles['portada']], cfg)
432    if 'objetivos' in roles:
433        _extract_objetivos_styles(slides[roles['objetivos']], cfg)
434    if 'indice' in roles:
435        _extract_indice_styles(slides[roles['indice']], cfg)
436    if 'intro' in roles:
437        _extract_single_styles(slides[roles['intro']], cfg)
438    if 'contenido' in roles:
439        _extract_contenido_styles(slides[roles['contenido']], cfg)
440    if 'contacto' in roles:
441        _extract_contacto_links(slides[roles['contacto']], cfg)
442
443    # Extraer ancho del frame de contenido del slide de contenido
444    if 'contenido' in roles:
445        slide = slides[roles['contenido']]
446        m = re.search(
447            r'<draw:frame\b[^>]*svg:width="([0-9.]+)cm"[^>]*>'
448            r'\s*<draw:text-box>',
449            slide,
450        )
451        if m:
452            cfg.frame_ancho_cm = float(m.group(1))
453
454    return cfg

Analiza un ODP de plantilla y devuelve un TemplateConfig con los índices de diapositiva y nombres de estilo detectados automáticamente.

La detección es heurística y puede necesitar ajuste manual para plantillas no estándar.

def load_course_config(course_dir: pathlib.Path) -> TemplateConfig:
457def load_course_config(course_dir: Path) -> TemplateConfig:
458    """
459    Carga template.yaml del directorio de curso si existe, aplicando primero
460    los valores globales de generación del fichero de usuario como defaults.
461    """
462    from lib.slides_config import get_generation_config
463    gen = get_generation_config()
464
465    template_yaml = course_dir / 'template' / 'template.yaml'
466    if template_yaml.exists():
467        cfg = TemplateConfig.from_yaml(template_yaml)
468    else:
469        cfg = TemplateConfig()
470
471    # Aplica config global solo si el template.yaml no sobreescribe el valor
472    if not template_yaml.exists() or 'generacion' not in (
473        yaml.safe_load(template_yaml.read_text(encoding='utf-8')) or {}
474    ):
475        cfg.code_split_lines    = gen['code_split_lines']
476        cfg.list_split_items    = gen['list_split_items']
477        cfg.compare_split_lines = gen['compare_split_lines']
478        cfg.java_sizes          = [tuple(p) for p in gen['code_font_sizes']]
479
480    return cfg

Carga template.yaml del directorio de curso si existe, aplicando primero los valores globales de generación del fichero de usuario como defaults.