Commit
Β·
598559d
1
Parent(s):
7d0c16f
probando si funciona el json -> pdf
Browse files- Dockerfile +22 -0
- app/__init__.py/config.py +61 -0
- app/__init__.py/main.py +84 -0
- app/__init__.py/renderer.py +95 -0
- {templates β app/templates}/ClientScript.html.j2 +0 -0
- {templates β app/templates}/IndexDocument.html.j2 +0 -0
- {templates β app/templates}/InternalModule.html.j2 +0 -0
- {templates β app/templates}/MapReduce.html.j2 +0 -0
- {templates β app/templates}/RESTlet.html.j2 +0 -0
- {templates β app/templates}/ScheduledScript.html.j2 +0 -0
- {templates β app/templates}/Suitelet.html.j2 +0 -0
- {templates β app/templates}/UserEventScript.html.j2 +0 -0
- {templates β app/templates}/base.html.j2 +0 -0
- {templates β app/templates}/components.html.j2 +0 -0
- requirements.txt +5 -0
Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 4 |
+
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
RUN apt-get update && \
|
| 8 |
+
apt-get install -y --no-install-recommends \
|
| 9 |
+
wkhtmltopdf \
|
| 10 |
+
fonts-dejavu-core \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
COPY app/ app/
|
| 17 |
+
|
| 18 |
+
ENV PYTHONUNBUFFERED=1
|
| 19 |
+
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
|
| 22 |
+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
app/__init__.py/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
import pdfkit
|
| 9 |
+
|
| 10 |
+
# Carpeta de este mΓ³dulo (app/)
|
| 11 |
+
BASE_DIR = Path(__file__).parent
|
| 12 |
+
|
| 13 |
+
# Cache de configuraciΓ³n de pdfkit
|
| 14 |
+
# False => aΓΊn no inicializado
|
| 15 |
+
# None => se intentΓ³ inicializar pero no se encontrΓ³ wkhtmltopdf
|
| 16 |
+
# config => configuraciΓ³n vΓ‘lida
|
| 17 |
+
_PDFKIT_CONFIG: Optional[pdfkit.configuration] | bool = False
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _detect_wkhtmltopdf() -> Optional[str]:
|
| 21 |
+
"""
|
| 22 |
+
Para entorno Docker (Linux):
|
| 23 |
+
|
| 24 |
+
1) Si hay WKHTMLTOPDF_PATH y apunta a un archivo, usarlo.
|
| 25 |
+
2) Si no, devolver None y dejar que pdfkit use lo que haya en PATH.
|
| 26 |
+
|
| 27 |
+
No nos preocupamos por .exe ni por Windows aquΓ.
|
| 28 |
+
"""
|
| 29 |
+
env_path = os.getenv("WKHTMLTOPDF_PATH")
|
| 30 |
+
if env_path and Path(env_path).is_file():
|
| 31 |
+
return env_path
|
| 32 |
+
|
| 33 |
+
# None = que pdfkit use wkhtmltopdf del PATH (/usr/bin/wkhtmltopdf)
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_pdfkit_config() -> Optional[pdfkit.configuration]:
|
| 38 |
+
"""
|
| 39 |
+
Devuelve una instancia de pdfkit.configuration o None si
|
| 40 |
+
no se pudo inicializar wkhtmltopdf.
|
| 41 |
+
"""
|
| 42 |
+
global _PDFKIT_CONFIG
|
| 43 |
+
|
| 44 |
+
if _PDFKIT_CONFIG is not False:
|
| 45 |
+
return _PDFKIT_CONFIG # type: ignore[return-value]
|
| 46 |
+
|
| 47 |
+
wkhtml_path = _detect_wkhtmltopdf()
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
if wkhtml_path:
|
| 51 |
+
_PDFKIT_CONFIG = pdfkit.configuration(wkhtmltopdf=wkhtml_path)
|
| 52 |
+
logging.info("wkhtmltopdf detectado en WKHTMLTOPDF_PATH: %s", wkhtml_path)
|
| 53 |
+
else:
|
| 54 |
+
# Usar binario en PATH (caso normal en Docker: /usr/bin/wkhtmltopdf)
|
| 55 |
+
_PDFKIT_CONFIG = pdfkit.configuration()
|
| 56 |
+
logging.info("wkhtmltopdf detectado en PATH.")
|
| 57 |
+
except OSError as exc:
|
| 58 |
+
logging.error("No se pudo inicializar pdfkit: %s", exc)
|
| 59 |
+
_PDFKIT_CONFIG = None
|
| 60 |
+
|
| 61 |
+
return _PDFKIT_CONFIG # type: ignore[return-value]
|
app/__init__.py/main.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
from typing import Any, Dict, Literal, Optional
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI, HTTPException
|
| 7 |
+
from fastapi.responses import HTMLResponse, StreamingResponse
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
|
| 10 |
+
from .renderer import render_html, render_pdf_bytes
|
| 11 |
+
|
| 12 |
+
app = FastAPI(
|
| 13 |
+
title="Doc Compiler Service",
|
| 14 |
+
version="1.0.0",
|
| 15 |
+
description=(
|
| 16 |
+
"Servicio de compilaciΓ³n que recibe JSON de documentaciΓ³n, "
|
| 17 |
+
"renderiza plantillas Jinja2 y devuelve HTML o PDF."
|
| 18 |
+
),
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class CompileRequest(BaseModel):
|
| 23 |
+
doc: Dict[str, Any] = Field(
|
| 24 |
+
...,
|
| 25 |
+
description="JSON de documentaciΓ³n generado por la IA (incluyendo metadata.script_type).",
|
| 26 |
+
)
|
| 27 |
+
job_id: Optional[str] = Field(
|
| 28 |
+
None,
|
| 29 |
+
description="Identificador opcional para el archivo (por ejemplo, nombre del script o job).",
|
| 30 |
+
)
|
| 31 |
+
output: Literal["html", "pdf"] = Field(
|
| 32 |
+
"pdf",
|
| 33 |
+
description="Tipo de salida deseada. En estos endpoints se usa como referencia.",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@app.get("/health")
|
| 38 |
+
def health_check():
|
| 39 |
+
"""
|
| 40 |
+
Endpoint simple de healthcheck para monitoreo.
|
| 41 |
+
"""
|
| 42 |
+
return {"status": "ok"}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.post("/compile/html")
|
| 46 |
+
def compile_html(req: CompileRequest):
|
| 47 |
+
"""
|
| 48 |
+
Recibe JSON y devuelve el HTML renderizado (ΓΊtil para depuraciΓ³n o previsualizaciΓ³n).
|
| 49 |
+
"""
|
| 50 |
+
if not req.doc:
|
| 51 |
+
raise HTTPException(status_code=400, detail="Campo 'doc' es obligatorio")
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
html = render_html(req.doc)
|
| 55 |
+
except Exception as exc:
|
| 56 |
+
raise HTTPException(status_code=500, detail=f"Error al renderizar HTML: {exc}")
|
| 57 |
+
|
| 58 |
+
return HTMLResponse(content=html)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@app.post("/compile/pdf")
|
| 62 |
+
def compile_pdf(req: CompileRequest):
|
| 63 |
+
"""
|
| 64 |
+
Recibe JSON y devuelve el PDF compilado usando wkhtmltopdf.
|
| 65 |
+
"""
|
| 66 |
+
if not req.doc:
|
| 67 |
+
raise HTTPException(status_code=400, detail="Campo 'doc' es obligatorio")
|
| 68 |
+
|
| 69 |
+
job_id = req.job_id or "document"
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
pdf_bytes = render_pdf_bytes(req.doc)
|
| 73 |
+
except RuntimeError as exc:
|
| 74 |
+
raise HTTPException(status_code=500, detail=str(exc))
|
| 75 |
+
except Exception as exc:
|
| 76 |
+
raise HTTPException(status_code=500, detail=f"Error al generar PDF: {exc}")
|
| 77 |
+
|
| 78 |
+
filename = f"{job_id}.pdf"
|
| 79 |
+
|
| 80 |
+
return StreamingResponse(
|
| 81 |
+
io.BytesIO(pdf_bytes),
|
| 82 |
+
media_type="application/pdf",
|
| 83 |
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
| 84 |
+
)
|
app/__init__.py/renderer.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Any, Dict
|
| 5 |
+
|
| 6 |
+
import pdfkit
|
| 7 |
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
| 8 |
+
|
| 9 |
+
from .config import BASE_DIR, get_pdfkit_config
|
| 10 |
+
|
| 11 |
+
# Carpeta de plantillas Jinja2
|
| 12 |
+
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 13 |
+
|
| 14 |
+
# Mapeo script_type -> plantilla
|
| 15 |
+
TEMPLATE_MAP: Dict[str, str] = {
|
| 16 |
+
"RESTlet": "RESTlet.html.j2",
|
| 17 |
+
"ClientScript": "ClientScript.html.j2",
|
| 18 |
+
"Map/Reduce": "MapReduce.html.j2",
|
| 19 |
+
"MapReduce": "MapReduce.html.j2",
|
| 20 |
+
"Scheduled": "ScheduledScript.html.j2",
|
| 21 |
+
"ScheduledScript": "ScheduledScript.html.j2",
|
| 22 |
+
"Suitelet": "Suitelet.html.j2",
|
| 23 |
+
"UserEvent": "UserEventScript.html.j2",
|
| 24 |
+
"UserEventScript": "UserEventScript.html.j2",
|
| 25 |
+
"UserModule.js": "InternalModule.html.j2",
|
| 26 |
+
"InternalModule": "InternalModule.html.j2",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
# Entorno Jinja
|
| 30 |
+
env = Environment(
|
| 31 |
+
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
| 32 |
+
autoescape=select_autoescape(["html", "xml"]),
|
| 33 |
+
trim_blocks=True,
|
| 34 |
+
lstrip_blocks=True,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _resolve_template(doc: Dict[str, Any]) -> str:
|
| 39 |
+
"""
|
| 40 |
+
A partir del JSON determina quΓ© plantilla Jinja usar
|
| 41 |
+
leyendo metadata.script_type o metadata.type.
|
| 42 |
+
"""
|
| 43 |
+
metadata = doc.get("metadata") or {}
|
| 44 |
+
script_type = (
|
| 45 |
+
metadata.get("script_type") or metadata.get("type") or "InternalModule"
|
| 46 |
+
)
|
| 47 |
+
return TEMPLATE_MAP.get(script_type, "InternalModule.html.j2")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def render_html(doc: Dict[str, Any]) -> str:
|
| 51 |
+
"""
|
| 52 |
+
Renderiza el HTML final a partir del JSON y la plantilla Jinja2 apropiada.
|
| 53 |
+
"""
|
| 54 |
+
template_name = _resolve_template(doc)
|
| 55 |
+
template = env.get_template(template_name)
|
| 56 |
+
|
| 57 |
+
html = template.render(
|
| 58 |
+
doc=doc,
|
| 59 |
+
generated_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
|
| 60 |
+
)
|
| 61 |
+
return html
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _wkhtml_options() -> Dict[str, str]:
|
| 65 |
+
"""
|
| 66 |
+
Opciones por defecto para wkhtmltopdf.
|
| 67 |
+
"""
|
| 68 |
+
return {
|
| 69 |
+
"page-size": "A4",
|
| 70 |
+
"encoding": "UTF-8",
|
| 71 |
+
"margin-top": "20mm",
|
| 72 |
+
"margin-right": "20mm",
|
| 73 |
+
"margin-bottom": "20mm",
|
| 74 |
+
"margin-left": "20mm",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def render_pdf_bytes(doc: Dict[str, Any]) -> bytes:
|
| 79 |
+
"""
|
| 80 |
+
Toma el JSON, renderiza el HTML y lo compila a PDF (bytes en memoria).
|
| 81 |
+
Lanza RuntimeError si wkhtmltopdf no estΓ‘ disponible.
|
| 82 |
+
"""
|
| 83 |
+
html = render_html(doc)
|
| 84 |
+
config = get_pdfkit_config()
|
| 85 |
+
|
| 86 |
+
if config is None:
|
| 87 |
+
raise RuntimeError("wkhtmltopdf no disponible en el servicio doc-compiler.")
|
| 88 |
+
|
| 89 |
+
pdf_bytes = pdfkit.from_string(
|
| 90 |
+
html,
|
| 91 |
+
False, # False => devuelve bytes en vez de escribir archivo
|
| 92 |
+
options=_wkhtml_options(),
|
| 93 |
+
configuration=config,
|
| 94 |
+
)
|
| 95 |
+
return pdf_bytes
|
{templates β app/templates}/ClientScript.html.j2
RENAMED
|
File without changes
|
{templates β app/templates}/IndexDocument.html.j2
RENAMED
|
File without changes
|
{templates β app/templates}/InternalModule.html.j2
RENAMED
|
File without changes
|
{templates β app/templates}/MapReduce.html.j2
RENAMED
|
File without changes
|
{templates β app/templates}/RESTlet.html.j2
RENAMED
|
File without changes
|
{templates β app/templates}/ScheduledScript.html.j2
RENAMED
|
File without changes
|
{templates β app/templates}/Suitelet.html.j2
RENAMED
|
File without changes
|
{templates β app/templates}/UserEventScript.html.j2
RENAMED
|
File without changes
|
{templates β app/templates}/base.html.j2
RENAMED
|
File without changes
|
{templates β app/templates}/components.html.j2
RENAMED
|
File without changes
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
jinja2
|
| 4 |
+
pdfkit
|
| 5 |
+
pydantic
|