merge(papers): render_paper_pdf (Markdown IMRaD -> PDF) + agente paper-reviewer adversarial (verificado met)
This commit is contained in:
@@ -75,8 +75,10 @@ from .profile_datetime import profile_datetime
|
||||
from .resample_timeseries import resample_timeseries
|
||||
from .add_pdf_internal_links import add_pdf_internal_links
|
||||
from .suggest_intratable_fk_candidates import suggest_intratable_fk_candidates
|
||||
from .render_paper_pdf import render_paper_pdf
|
||||
|
||||
__all__ = [
|
||||
"render_paper_pdf",
|
||||
"suggest_intratable_fk_candidates",
|
||||
"detect_time_column",
|
||||
"extract_timeseries_raw",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: render_paper_pdf
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def render_paper_pdf(paper_dir: str) -> dict"
|
||||
description: "Convierte un paper académico IMRaD escrito en Markdown (papers/<slug>/paper.md, con frontmatter YAML opcional title/authors/date/abstract + cuerpo) en un PDF papers/<slug>/out/paper.pdf. REUTILIZA el paginador de flujo del paquete automatic_eda (el mismo motor del PDF móvil A5 de los informes EDA): no reimplementa paginación ni toca matplotlib. Cada sección IMRaD (encabezado de nivel 1, p.ej. # Introduction, # Methods) se mapea a un Chapter que empieza en página nueva; el motor parsea por sí mismo headings, listas, tablas pipe, párrafos y **negrita** dentro del texto. Como el motor NO entiende la sintaxis de imagen Markdown , esta función detecta esas líneas y las parte en bloques Image separados, resolviendo el src relativo a base_dir y base_dir/figures/. La portada (si hay título) lista autores y fecha (DD/MM/AAAA si parseable) más el abstract. dict-no-throw: nunca lanza, devuelve {status, pdf_path, n_pages, note}."
|
||||
tags: [papers, pdf, academic, render, report, imrad, mobile, automatic-eda, markdown, no-cut, matplotlib, datascience, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [os, re, datetime, yaml, "datascience.automatic_eda"]
|
||||
params:
|
||||
- name: paper_dir
|
||||
desc: "ruta al directorio del paper (papers/<slug>/, del que se lee paper.md) O directamente la ruta a un archivo paper.md (cualquier ruta terminada en .md). El directorio base para resolver figuras y escribir el PDF es el dirname del paper.md. Si el paper.md no existe (incluida una ruta totalmente inexistente) devuelve status='error' sin crash."
|
||||
output: "dict (nunca lanza): {status: 'ok'|'error', pdf_path: str|None, n_pages: int, note: str}. En éxito status='ok', pdf_path es la ruta del PDF escrito (<base_dir>/out/paper.pdf) y n_pages el total de páginas. En error status='error', pdf_path=None, n_pages=0 y note explica la causa (paper.md no encontrado, fallo del motor, o excepción inesperada)."
|
||||
tested: true
|
||||
tests: ["test_golden_genera_pdf_con_portada_y_secciones", "test_edge_sin_frontmatter_ni_figuras", "test_edge_path_inexistente_no_revienta", "test_edge_figura_inexistente_degrada", "test_acepta_ruta_directa_al_md"]
|
||||
test_file_path: "python/functions/datascience/render_paper_pdf_test.py"
|
||||
file_path: "python/functions/datascience/render_paper_pdf.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import render_paper_pdf
|
||||
|
||||
# Estructura del paper:
|
||||
# papers/zz-demo/paper.md (frontmatter YAML + cuerpo IMRaD)
|
||||
# papers/zz-demo/figures/fig1.png (figuras referenciadas con )
|
||||
#
|
||||
# paper.md:
|
||||
# ---
|
||||
# title: A Minimal IMRaD Paper
|
||||
# authors: [Ada Lovelace, Alan Turing]
|
||||
# date: 2026-06-30
|
||||
# abstract: Demostramos que el motor pagina un paper sin cortar nada.
|
||||
# ---
|
||||
# # Introduction
|
||||
# Texto con **negrita** y una lista:
|
||||
# - Punto uno.
|
||||
# 
|
||||
# # Methods
|
||||
# | Métrica | Valor |
|
||||
# | --- | --- |
|
||||
# | Precisión | 0.91 |
|
||||
|
||||
res = render_paper_pdf("papers/zz-demo")
|
||||
print(res["status"], res["n_pages"], res["pdf_path"])
|
||||
# -> ok 3 papers/zz-demo/out/paper.pdf
|
||||
|
||||
# También acepta la ruta directa al .md:
|
||||
render_paper_pdf("papers/zz-demo/paper.md")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tengas un paper académico (o cualquier documento IMRaD) escrito en
|
||||
Markdown y quieras un **PDF móvil A5 listo para leer**, sin montar LaTeX ni
|
||||
configurar un pipeline de pandoc. Úsala después de redactar `paper.md` con su
|
||||
frontmatter (título, autores, fecha, abstract) y secciones de nivel 1; obtienes
|
||||
`out/paper.pdf` con portada, una página nueva por sección IMRaD, tablas que se
|
||||
parten repitiendo la cabecera y figuras escaladas para caber enteras —
|
||||
garantía de no-corte heredada del motor `automatic_eda`. Es la capa de
|
||||
presentación PDF del grupo `papers`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe `out/paper.pdf` (y crea el directorio `out/`) junto al
|
||||
`paper.md`. Necesita **matplotlib** instalado en el venv (lo usa el motor
|
||||
`automatic_eda.render_pdf` con backend headless `Agg`; corre en agentes/CI sin
|
||||
display). `pyyaml` es opcional: si falta, el frontmatter se parsea con un
|
||||
parser line-based `clave: valor` degradado.
|
||||
- **Reutiliza el motor `automatic_eda.render_pdf`**: NO reimplementa paginación
|
||||
ni toca matplotlib. `render_pdf` no tiene ID propio en el registry (es parte
|
||||
del paquete de soporte `automatic_eda`), por eso `uses_functions` queda vacío;
|
||||
la dependencia real es ese motor del paquete.
|
||||
- **Nunca lanza** (dict-no-throw): `paper.md` inexistente → `{status:"error",
|
||||
pdf_path:None, note:"paper.md no encontrado: ..."}`; cualquier excepción
|
||||
inesperada → `{status:"error", note:"fallo: ..."}`. Frontmatter ausente o
|
||||
incompleto degrada limpio (sin portada, el cuerpo entero se pagina).
|
||||
- **Figuras relativas a `figures/`**: el `src` de `` se resuelve
|
||||
probando `<base_dir>/<src>` y `<base_dir>/figures/<basename>`; usa el primero
|
||||
que exista. Si ninguno existe, el motor **degrada** dibujando
|
||||
"(imagen no encontrada: ...)" — el PDF se genera igual, no crashea. Las URLs
|
||||
`http(s)` se dejan como texto Markdown, no se descargan.
|
||||
- **Solo imágenes en línea propia**: el motor `_place_markdown` NO entiende
|
||||
``; esta función solo convierte a `Image` las líneas cuyo único
|
||||
contenido es la imagen. Una imagen embebida a mitad de un párrafo se quedaría
|
||||
como texto crudo.
|
||||
- **A5 portrait mobile-first**: el formato (tamaño de página, tipografía, pie
|
||||
`Capítulo · vX.Y.Z`) lo fija el motor EDA y no es configurable desde aquí.
|
||||
@@ -0,0 +1,297 @@
|
||||
"""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 ""
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests para render_paper_pdf — DoD: golden + edges + error path.
|
||||
|
||||
Autocontenido y sin red: escribe papers Markdown sintéticos en directorios
|
||||
temporales y verifica que el PDF se genera (estado, nº de páginas, archivo
|
||||
no vacío) reutilizando el motor de paginación de ``automatic_eda``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from datascience.render_paper_pdf import render_paper_pdf
|
||||
|
||||
|
||||
_GOLDEN_PAPER = """---
|
||||
title: A Minimal IMRaD Paper
|
||||
authors:
|
||||
- Ada Lovelace
|
||||
- Alan Turing
|
||||
date: 2026-06-30
|
||||
abstract: >
|
||||
Demostramos que el motor de paginación rinde un paper IMRaD completo en PDF
|
||||
móvil sin cortar texto ni tablas.
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
Este es el cuerpo de la introducción con **texto en negrita** y una lista:
|
||||
|
||||
- Primer punto.
|
||||
- Segundo punto.
|
||||
|
||||
# Methods
|
||||
|
||||
Resultados resumidos en una tabla pipe:
|
||||
|
||||
| Métrica | Valor |
|
||||
| --- | --- |
|
||||
| Precisión | 0.91 |
|
||||
| Recall | 0.88 |
|
||||
|
||||
Texto final de la sección de métodos.
|
||||
"""
|
||||
|
||||
|
||||
def test_golden_genera_pdf_con_portada_y_secciones(tmp_path):
|
||||
"""Golden: paper IMRaD con frontmatter + 2 secciones + tabla → PDF válido."""
|
||||
paper_dir = tmp_path / "zz-demo"
|
||||
paper_dir.mkdir()
|
||||
(paper_dir / "paper.md").write_text(_GOLDEN_PAPER, encoding="utf-8")
|
||||
|
||||
res = render_paper_pdf(str(paper_dir))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert res["n_pages"] >= 1
|
||||
pdf_path = res["pdf_path"]
|
||||
assert pdf_path is not None
|
||||
assert os.path.exists(pdf_path)
|
||||
assert os.path.getsize(pdf_path) > 0
|
||||
|
||||
|
||||
def test_edge_sin_frontmatter_ni_figuras(tmp_path):
|
||||
"""Edge 1: cuerpo plano sin frontmatter ni figuras → genera PDF igual."""
|
||||
paper_dir = tmp_path / "plano"
|
||||
paper_dir.mkdir()
|
||||
(paper_dir / "paper.md").write_text(
|
||||
"Solo un cuerpo plano, sin frontmatter ni encabezados de nivel 1.\n"
|
||||
"Un par de líneas de texto corrido para que el motor lo pagine.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
res = render_paper_pdf(str(paper_dir))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert res["n_pages"] >= 1
|
||||
assert os.path.exists(res["pdf_path"])
|
||||
|
||||
|
||||
def test_edge_path_inexistente_no_revienta():
|
||||
"""Edge 2: directorio inexistente → status error, sin crash, pdf_path None."""
|
||||
res = render_paper_pdf("/tmp/no_existe_xyz_123")
|
||||
|
||||
assert res["status"] == "error"
|
||||
assert res["pdf_path"] is None
|
||||
assert res["n_pages"] == 0
|
||||
assert "no encontrado" in (res["note"] or "")
|
||||
|
||||
|
||||
def test_edge_figura_inexistente_degrada(tmp_path):
|
||||
"""Edge 3: referencia a figura inexistente → el PDF se genera igual."""
|
||||
paper_dir = tmp_path / "con-figura"
|
||||
paper_dir.mkdir()
|
||||
(paper_dir / "paper.md").write_text(
|
||||
"---\n"
|
||||
"title: Paper Con Figura Rota\n"
|
||||
"---\n\n"
|
||||
"# Results\n\n"
|
||||
"Texto antes de la figura.\n\n"
|
||||
"\n\n"
|
||||
"Texto después de la figura.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
res = render_paper_pdf(str(paper_dir))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert res["n_pages"] >= 1
|
||||
assert os.path.exists(res["pdf_path"])
|
||||
|
||||
|
||||
def test_acepta_ruta_directa_al_md(tmp_path):
|
||||
"""Acepta también la ruta directa a un paper.md (no solo el directorio)."""
|
||||
md = tmp_path / "paper.md"
|
||||
md.write_text("# Discussion\n\nCuerpo de la discusión.\n", encoding="utf-8")
|
||||
|
||||
res = render_paper_pdf(str(md))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert os.path.exists(res["pdf_path"])
|
||||
Reference in New Issue
Block a user