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