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

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
95    @property
96    def terminal_chars_por_linea(self) -> int:
97        """Caracteres por línea en slides terminal según frame y fuente."""
98        char_w_cm = self.terminal_pt * 0.6 * 0.03528
99        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:
103    def to_dict(self) -> dict:
104        return {
105            'meta': {
106                'nombre': self.nombre,
107                'fichero': self.fichero,
108            },
109            'diapositivas': {
110                'portada': self.idx_portada,
111                'objetivos': self.idx_objetivos,
112                'indice': self.idx_indice,
113                'intro': self.idx_intro,
114                'contenido': self.idx_contenido,
115                'conclusion': self.idx_conclusion,
116                'referencias': self.idx_referencias,
117                'contacto': self.idx_contacto,
118                'licencia': self.idx_licencia,
119            },
120            'estilos': {
121                'portada_titulo': self.portada_titulo,
122                'portada_unidad': self.portada_unidad,
123                'portada_nivel': self.portada_nivel,
124                'indicador': self.indicador,
125                'titulo': self.titulo,
126                'intro_cuerpo': self.intro_cuerpo,
127                'lista_obj': self.lista_obj,
128                'parrafo_obj': self.parrafo_obj,
129                'span_obj': self.span_obj,
130                'umbral_obj_sm': self.umbral_obj_sm,
131                'umbral_obj_xs': self.umbral_obj_xs,
132                'parrafo_indice': self.parrafo_indice,
133                'span_indice': self.span_indice,
134                'lista_contenido': self.lista_contenido,
135                'parrafo_contenido': self.parrafo_contenido,
136                'span_contenido': self.span_contenido,
137            },
138            'contacto': {
139                'url': self.url_contacto,
140                'email': self.email_contacto,
141            },
142            'generacion': {
143                'code_split_lines':    self.code_split_lines,
144                'list_split_items':    self.list_split_items,
145                'compare_split_lines': self.compare_split_lines,
146            },
147            'estilos_inyectados': {
148                'titulo_pt': self.titulo_pt,
149                'obj_sm_pt': self.obj_sm_pt,
150                'fuente_mono': self.fuente_mono,
151                'codigo_pt': self.codigo_pt,
152                'color_codigo': self.color_codigo,
153                'color_comentario': self.color_comentario,
154                'color_java': self.color_java,
155                'color_subtitulo': self.color_subtitulo,
156                'java_sizes': [list(pair) for pair in self.java_sizes],
157            },
158            'terminal': {
159                'frame_ancho_cm': self.frame_ancho_cm,
160                'terminal_pt': self.terminal_pt,
161            },
162        }
@classmethod
def from_dict(cls, d: dict) -> TemplateConfig:
164    @classmethod
165    def from_dict(cls, d: dict) -> 'TemplateConfig':
166        cfg = cls()
167        meta = d.get('meta', {})
168        cfg.nombre = meta.get('nombre', cfg.nombre)
169        cfg.fichero = meta.get('fichero', cfg.fichero)
170
171        diaps = d.get('diapositivas', {})
172        cfg.idx_portada    = diaps.get('portada',    cfg.idx_portada)
173        cfg.idx_objetivos  = diaps.get('objetivos',  cfg.idx_objetivos)
174        cfg.idx_indice     = diaps.get('indice',     cfg.idx_indice)
175        cfg.idx_intro      = diaps.get('intro',      cfg.idx_intro)
176        cfg.idx_contenido  = diaps.get('contenido',  cfg.idx_contenido)
177        cfg.idx_conclusion = diaps.get('conclusion', cfg.idx_conclusion)
178        cfg.idx_referencias = diaps.get('referencias', cfg.idx_referencias)
179        cfg.idx_contacto   = diaps.get('contacto',  cfg.idx_contacto)
180        cfg.idx_licencia   = diaps.get('licencia',   cfg.idx_licencia)
181
182        estilos = d.get('estilos', {})
183        cfg.portada_titulo   = estilos.get('portada_titulo',   cfg.portada_titulo)
184        cfg.portada_unidad   = estilos.get('portada_unidad',   cfg.portada_unidad)
185        cfg.portada_nivel    = estilos.get('portada_nivel',    cfg.portada_nivel)
186        cfg.indicador        = estilos.get('indicador',        cfg.indicador)
187        cfg.titulo           = estilos.get('titulo',           cfg.titulo)
188        cfg.intro_cuerpo     = estilos.get('intro_cuerpo',     cfg.intro_cuerpo)
189        cfg.lista_obj        = estilos.get('lista_obj',        cfg.lista_obj)
190        cfg.parrafo_obj      = estilos.get('parrafo_obj',      cfg.parrafo_obj)
191        cfg.span_obj         = estilos.get('span_obj',         cfg.span_obj)
192        cfg.umbral_obj_sm    = estilos.get('umbral_obj_sm',    cfg.umbral_obj_sm)
193        cfg.umbral_obj_xs    = estilos.get('umbral_obj_xs',    cfg.umbral_obj_xs)
194        cfg.parrafo_indice   = estilos.get('parrafo_indice',   cfg.parrafo_indice)
195        cfg.span_indice      = estilos.get('span_indice',      cfg.span_indice)
196        cfg.lista_contenido  = estilos.get('lista_contenido',  cfg.lista_contenido)
197        cfg.parrafo_contenido = estilos.get('parrafo_contenido', cfg.parrafo_contenido)
198        cfg.span_contenido   = estilos.get('span_contenido',   cfg.span_contenido)
199
200        contacto = d.get('contacto', {})
201        cfg.url_contacto    = contacto.get('url',   cfg.url_contacto)
202        cfg.email_contacto  = contacto.get('email', cfg.email_contacto)
203
204        gen = d.get('generacion', {})
205        cfg.code_split_lines    = gen.get('code_split_lines',    cfg.code_split_lines)
206        cfg.list_split_items    = gen.get('list_split_items',    cfg.list_split_items)
207        cfg.compare_split_lines = gen.get('compare_split_lines', cfg.compare_split_lines)
208
209        inj = d.get('estilos_inyectados', {})
210        cfg.titulo_pt        = inj.get('titulo_pt',        cfg.titulo_pt)
211        cfg.obj_sm_pt        = inj.get('obj_sm_pt',        cfg.obj_sm_pt)
212        cfg.fuente_mono      = inj.get('fuente_mono',      cfg.fuente_mono)
213        cfg.codigo_pt        = inj.get('codigo_pt',        cfg.codigo_pt)
214        cfg.color_codigo     = inj.get('color_codigo',     cfg.color_codigo)
215        cfg.color_comentario = inj.get('color_comentario', cfg.color_comentario)
216        cfg.color_java       = inj.get('color_java',       cfg.color_java)
217        cfg.color_subtitulo  = inj.get('color_subtitulo',  cfg.color_subtitulo)
218        if 'java_sizes' in inj:
219            cfg.java_sizes = [tuple(p) for p in inj['java_sizes']]
220
221        term = d.get('terminal', {})
222        cfg.frame_ancho_cm = term.get('frame_ancho_cm', cfg.frame_ancho_cm)
223        cfg.terminal_pt    = term.get('terminal_pt',    cfg.terminal_pt)
224
225        return cfg
@classmethod
def from_yaml(cls, path: pathlib.Path) -> TemplateConfig:
227    @classmethod
228    def from_yaml(cls, path: Path) -> 'TemplateConfig':
229        return cls.from_dict(yaml.safe_load(path.read_text(encoding='utf-8')))
def save_yaml(self, path: pathlib.Path) -> None:
231    def save_yaml(self, path: Path) -> None:
232        path.parent.mkdir(parents=True, exist_ok=True)
233        path.write_text(
234            yaml.dump(
235                self.to_dict(),
236                allow_unicode=True,
237                default_flow_style=False,
238                sort_keys=False,
239            ),
240            encoding='utf-8',
241        )
def extraer_template(odp_path: pathlib.Path) -> TemplateConfig:
410def extraer_template(odp_path: Path) -> TemplateConfig:
411    """
412    Analiza un ODP de plantilla y devuelve un TemplateConfig con los índices
413    de diapositiva y nombres de estilo detectados automáticamente.
414
415    La detección es heurística y puede necesitar ajuste manual para plantillas
416    no estándar.
417    """
418    from lib.odp_utils import extract_slides
419
420    with zipfile.ZipFile(odp_path) as z:
421        content_xml = z.read('content.xml').decode('utf-8')
422
423    slides = extract_slides(content_xml)
424    cfg = TemplateConfig(nombre=odp_path.stem, fichero=odp_path.name)
425
426    roles = _detect_slide_roles(slides)
427
428    # Map detected roles to TemplateConfig indices
429    _ROLE_ATTR = {
430        'portada':    'idx_portada',
431        'objetivos':  'idx_objetivos',
432        'indice':     'idx_indice',
433        'intro':      'idx_intro',
434        'contenido':  'idx_contenido',
435        'conclusion': 'idx_conclusion',
436        'referencias': 'idx_referencias',
437        'contacto':   'idx_contacto',
438        'licencia':   'idx_licencia',
439    }
440    for role, attr in _ROLE_ATTR.items():
441        if role in roles:
442            setattr(cfg, attr, roles[role])
443
444    # Extract style names from detected slides
445    if 'portada' in roles:
446        _extract_portada_styles(slides[roles['portada']], cfg)
447    if 'objetivos' in roles:
448        _extract_objetivos_styles(slides[roles['objetivos']], cfg)
449    if 'indice' in roles:
450        _extract_indice_styles(slides[roles['indice']], cfg)
451    if 'intro' in roles:
452        _extract_single_styles(slides[roles['intro']], cfg)
453    if 'contenido' in roles:
454        _extract_contenido_styles(slides[roles['contenido']], cfg)
455    if 'contacto' in roles:
456        _extract_contacto_links(slides[roles['contacto']], cfg)
457
458    # Extraer ancho del frame de contenido del slide de contenido
459    if 'contenido' in roles:
460        slide = slides[roles['contenido']]
461        m = re.search(
462            r'<draw:frame\b[^>]*svg:width="([0-9.]+)cm"[^>]*>'
463            r'\s*<draw:text-box>',
464            slide,
465        )
466        if m:
467            cfg.frame_ancho_cm = float(m.group(1))
468
469    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:
472def load_course_config(course_dir: Path) -> TemplateConfig:
473    """
474    Carga template.yaml del directorio de curso si existe, aplicando primero
475    los valores globales de generación del fichero de usuario como defaults.
476    """
477    from lib.slides_config import get_generation_config
478    gen = get_generation_config()
479
480    template_yaml = course_dir / 'template' / 'template.yaml'
481    if template_yaml.exists():
482        cfg = TemplateConfig.from_yaml(template_yaml)
483    else:
484        cfg = TemplateConfig()
485
486    # Aplica config global solo si el template.yaml no sobreescribe el valor
487    if not template_yaml.exists() or 'generacion' not in (
488        yaml.safe_load(template_yaml.read_text(encoding='utf-8')) or {}
489    ):
490        cfg.code_split_lines    = gen['code_split_lines']
491        cfg.list_split_items    = gen['list_split_items']
492        cfg.compare_split_lines = gen['compare_split_lines']
493        cfg.java_sizes          = [tuple(p) for p in gen['code_font_sizes']]
494
495    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.