"""render_paper_pdf — convierte un paper académico IMRaD en Markdown a un PDF. Toma un paper escrito en Markdown con frontmatter YAML opcional (título, autores, fecha, abstract) más un cuerpo dividido en secciones IMRaD por encabezados de nivel 1 (``# Introduction``, ``# Methods``, ...) y produce un PDF ``out/paper.pdf`` junto al paper. REUTILIZA el paginador de flujo del paquete ``automatic_eda`` (el mismo motor que rinde los informes EDA en PDF móvil A5): no reimplementa paginación ni toca matplotlib directamente. Cada sección IMRaD se mapea a un ``Chapter`` (empieza en página nueva). El motor ``_place_markdown`` parsea por sí mismo headings, listas, tablas pipe, párrafos y ``**negrita**`` dentro del texto, pero NO entiende la sintaxis de imagen Markdown ``![alt](src)``; por eso esta función detecta esas líneas y las convierte en bloques ``Image`` separados, partiendo el texto Markdown alrededor de cada imagen. dict-no-throw (estilo del grupo eda): NUNCA lanza. Devuelve ``{status, pdf_path, n_pages, note}``; ante cualquier fallo devuelve ``status="error"`` con ``pdf_path=None`` y la causa en ``note``. """ from __future__ import annotations import datetime as _dt import os import re from datascience.automatic_eda import Chapter, Heading, Image, Markdown, render_pdf # Una línea cuyo único contenido es una imagen Markdown: ![alt](src) _IMG_LINE = re.compile(r"^\s*!\[([^\]]*)\]\(\s*([^)\s]+)\s*\)\s*$") # Un encabezado de nivel 1 al inicio de línea (un solo '#' seguido de espacio). _H1_LINE = re.compile(r"^#[ \t]+(.+?)\s*$") def render_paper_pdf(paper_dir: str) -> dict: """Renderiza un paper académico Markdown IMRaD en un PDF. Args: paper_dir: ruta al directorio del paper (``papers//``, del que se lee ``paper.md``) o directamente la ruta a un archivo ``paper.md``. Returns: dict (nunca lanza): ``{status: "ok"|"error", pdf_path: str|None, n_pages: int, note: str}``. En éxito ``pdf_path`` es la ruta escrita y ``n_pages`` el total de páginas; en error ``pdf_path`` es None y ``note`` explica la causa. """ try: # 1) Resolver el path del paper.md y el directorio base. arg = str(paper_dir) md_path = arg if arg.endswith(".md") else os.path.join(arg, "paper.md") # 2) Si el paper.md no existe, degradar sin crash. if not os.path.isfile(md_path): return {"status": "error", "pdf_path": None, "n_pages": 0, "note": f"paper.md no encontrado: {md_path}"} base_dir = os.path.dirname(os.path.abspath(md_path)) # 3) Leer el archivo y separar frontmatter del cuerpo. with open(md_path, "r", encoding="utf-8") as fh: text = fh.read() fm_text, body = _split_frontmatter(text) fm = _parse_frontmatter(fm_text) title = _safe_str(fm.get("title")).strip() authors = fm.get("authors") date_raw = fm.get("date") abstract = _safe_str(fm.get("abstract")).strip() # 4) Construir los capítulos: portada (si hay título) + cuerpo IMRaD. chapters: list = [] if title: cover_md = _portada_markdown(authors, date_raw, abstract) cover_blocks: list = [Heading(text=title, level=1)] if cover_md.strip(): cover_blocks.append(Markdown(text=cover_md)) chapters.append(Chapter(id="portada", title=title, version="1.0.0", blocks=cover_blocks)) preamble, sections = _split_body_sections(body) if not sections: # Sin encabezados H1: todo el cuerpo en un único capítulo. chapters.append(Chapter( id="cuerpo", title="Cuerpo", version="1.0.0", blocks=_markdown_to_blocks(body, base_dir))) else: # Texto antes del primer H1 (si lo hay) como capítulo previo. if preamble.strip(): chapters.append(Chapter( id="cuerpo", title="Cuerpo", version="1.0.0", blocks=_markdown_to_blocks(preamble, base_dir))) for idx, (sec_title, sec_body) in enumerate(sections): blocks: list = [Heading(text=sec_title, level=1)] blocks.extend(_markdown_to_blocks(sec_body, base_dir)) chapters.append(Chapter( id=_slugify(sec_title) or f"sec{idx}", title=sec_title, version="1.0.0", blocks=blocks)) # 5) Renderizar con el motor de automatic_eda. out_path = os.path.join(base_dir, "out", "paper.pdf") res = render_pdf(chapters, out_path, meta={"title": title or "paper"}) # 6) Mapear el retorno del motor a la forma de esta función. path = res.get("path") return { "status": "ok" if path else "error", "pdf_path": path, "n_pages": int(res.get("n_pages") or 0), "note": res.get("note"), } except Exception as e: # noqa: BLE001 — dict-no-throw estricto. return {"status": "error", "pdf_path": None, "n_pages": 0, "note": f"fallo: {e}"} # --------------------------------------------------------------------------- # # Frontmatter # --------------------------------------------------------------------------- # def _split_frontmatter(text: str): """Separa el bloque frontmatter YAML inicial del cuerpo. Devuelve ``(fm_text|None, body)``. Si el archivo no empieza con una valla ``---`` o no se cierra, no hay frontmatter y el cuerpo es el texto entero. """ if text.startswith(""): text = text.lstrip("") lines = text.split("\n") if not lines or lines[0].strip() != "---": return None, text for i in range(1, len(lines)): if lines[i].strip() == "---": return "\n".join(lines[1:i]), "\n".join(lines[i + 1:]) # Valla de apertura sin cierre: tratar todo como cuerpo. return None, text def _parse_frontmatter(fm_text) -> dict: """Parsea el frontmatter. Intenta YAML; si no, parser line-based simple.""" if not fm_text: return {} try: import yaml # type: ignore data = yaml.safe_load(fm_text) if isinstance(data, dict): return data except Exception: # noqa: BLE001 — yaml ausente o frontmatter inválido. pass # Fallback degradado: 'clave: valor' por línea. out: dict = {} for line in fm_text.split("\n"): stripped = line.strip() if not stripped or stripped.startswith("#") or ":" not in stripped: continue k, _, v = stripped.partition(":") k = k.strip() v = v.strip().strip('"').strip("'") if k: out[k] = v return out # --------------------------------------------------------------------------- # # Portada # --------------------------------------------------------------------------- # def _portada_markdown(authors, date_raw, abstract) -> str: """Markdown de la portada: autores, fecha y, si hay, el abstract.""" parts: list = [] authors_str = _fmt_authors(authors) if authors_str: parts.append(f"**Autores:** {authors_str}") if date_raw not in (None, ""): parts.append(f"**Fecha:** {_fmt_date(date_raw)}") md = "\n\n".join(parts) abstract = _safe_str(abstract).strip() if abstract: md = (md + "\n\n" if md else "") + "## Abstract\n\n" + abstract return md def _fmt_authors(authors) -> str: """Lista o string de autores → string separado por comas.""" if authors in (None, ""): return "" if isinstance(authors, (list, tuple)): return ", ".join(_safe_str(a).strip() for a in authors if _safe_str(a).strip()) return _safe_str(authors).strip() def _fmt_date(raw) -> str: """Fecha → ``DD/MM/AAAA`` si es parseable; si no, el valor crudo.""" if isinstance(raw, _dt.datetime): return raw.strftime("%d/%m/%Y") if isinstance(raw, _dt.date): return raw.strftime("%d/%m/%Y") s = _safe_str(raw).strip() if not s: return s for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%d-%m-%Y"): try: return _dt.datetime.strptime(s, fmt).strftime("%d/%m/%Y") except ValueError: continue try: return _dt.datetime.fromisoformat(s).strftime("%d/%m/%Y") except Exception: # noqa: BLE001 return s # --------------------------------------------------------------------------- # # Cuerpo y figuras # --------------------------------------------------------------------------- # def _split_body_sections(body: str): """Divide el cuerpo en (preámbulo, [(título_H1, contenido)...]) por H1.""" preamble_lines: list = [] sections: list = [] current = None # (titulo, [lineas]) for line in body.split("\n"): m = _H1_LINE.match(line) if m and not line.startswith("##"): if current is not None: sections.append((current[0], "\n".join(current[1]))) current = (m.group(1).strip(), []) elif current is None: preamble_lines.append(line) else: current[1].append(line) if current is not None: sections.append((current[0], "\n".join(current[1]))) return "\n".join(preamble_lines), sections def _markdown_to_blocks(text: str, base_dir: str) -> list: """Parte un Markdown en bloques Markdown/Image alrededor de cada figura. Las líneas ``![alt](src)`` con ``src`` local se convierten en ``Image``; las que apuntan a URLs http(s) se dejan como texto Markdown. """ blocks: list = [] buf: list = [] def _flush(): chunk = "\n".join(buf).strip("\n") if chunk.strip(): blocks.append(Markdown(text=chunk)) buf.clear() for line in text.split("\n"): m = _IMG_LINE.match(line) if m: alt, src = m.group(1), m.group(2) if src.lower().startswith(("http://", "https://")): buf.append(line) # URL remota: se mantiene como texto. continue _flush() blocks.append(Image(path=_resolve_src(src, base_dir), caption=(alt or None))) else: buf.append(line) _flush() return blocks def _resolve_src(src: str, base_dir: str) -> str: """Resuelve la ruta de una figura relativa al paper. Absoluta → tal cual. Relativa → prueba ``base_dir/src`` y ``base_dir/figures/``; usa la primera que exista, o el join con ``base_dir`` si ninguna (el motor degrada dibujando el aviso de no-encontrada). """ if os.path.isabs(src): return src cand1 = os.path.join(base_dir, src) cand2 = os.path.join(base_dir, "figures", os.path.basename(src)) for c in (cand1, cand2): if os.path.exists(c): return c return cand1 def _slugify(text: str) -> str: """Slug ASCII corto para el id del capítulo.""" s = re.sub(r"[^a-z0-9]+", "_", _safe_str(text).lower()).strip("_") return s[:40] def _safe_str(v) -> str: """str() que nunca lanza y mapea None a ''.""" if v is None: return "" try: return str(v) except Exception: # noqa: BLE001 return ""