9c1b7dd0f3
Subsistema papers/: pieza de entrega + control de calidad. - render_paper_pdf_py_datascience (Python, impure, dominio datascience, grupo `papers`): convierte papers/<slug>/paper.md (frontmatter YAML + cuerpo IMRaD) en papers/<slug>/out/paper.pdf. Reutiliza el motor de paginación de flujo del paquete automatic_eda (matplotlib PdfPages, el mismo PDF móvil A5 de los informes EDA) — no reimplementa paginación ni toca matplotlib, y no añade dependencias. Cada sección IMRaD (# H1) → un Chapter en página nueva; portada desde el frontmatter (title/authors/date europea/abstract); detecta las imágenes Markdown  que el motor no entiende y las parte en bloques Image resueltos contra base_dir y base_dir/figures/. dict-no-throw estricto. 5 tests verdes (golden + edges: sin frontmatter, path inexistente, figura inexistente, ruta directa al .md). - .claude/agents/paper-reviewer: revisor académico adversarial read-only (gate anti paper-mill). Puntúa novedad/rigor/reproducibilidad/validez (0-5), intenta refutar cada claim contra la evidencia citada, detecta HARKing contra el preregistration.md, exige limitaciones declaradas y claims ≤ evidencia, y emite veredicto estructurado JSON (accept|major_revision|reject) con default conservador. Tools: Read, Grep, Glob, Bash (sin Edit/Write: solo juzga). Diseño completo: reports/0001-2026-06-30-papers-system-design.md (agente C). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""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 ````; 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: 
|
|
_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/<slug>/``, 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 ```` 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/<basename>``; 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 ""
|