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)
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 }
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
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.
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.