From 175410b8b805d22a3427ec71da19916d54993682 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Thu, 7 May 2026 00:32:44 +0200 Subject: [PATCH 1/6] feat(cv): implement PDF download via WeasyPrint Add GET /cv/download endpoint that generates and streams the CV as PDF. Includes IPDFService interface, WeasyPrintPDFService with Jinja2 template, asyncio.to_thread for non-blocking generation, and updated Dockerfile/ requirements with WeasyPrint system and Python dependencies. --- app/api/dependencies.py | 3 +- app/api/v1/routers/cv_router.py | 13 +- app/application/dto/cv_dto.py | 2 +- app/application/services/__init__.py | 3 + app/application/services/pdf_service.py | 8 + .../use_cases/cv/generate_cv_pdf.py | 75 +--- app/infrastructure/services/__init__.py | 2 + .../services/templates/cv_template.html | 417 ++++++++++++++++++ .../services/weasyprint_pdf_service.py | 17 + requirements.txt | 2 + 10 files changed, 467 insertions(+), 75 deletions(-) create mode 100644 app/application/services/pdf_service.py create mode 100644 app/infrastructure/services/templates/cv_template.html create mode 100644 app/infrastructure/services/weasyprint_pdf_service.py diff --git a/app/api/dependencies.py b/app/api/dependencies.py index 333d38e..3cc21c4 100644 --- a/app/api/dependencies.py +++ b/app/api/dependencies.py @@ -104,6 +104,7 @@ ) from app.infrastructure.services.null_email_service import NullEmailService from app.infrastructure.services.resend_email_service import ResendEmailService +from app.infrastructure.services.weasyprint_pdf_service import WeasyPrintPDFService from app.shared.interfaces.email_service import IEmailService # ===================================================================== @@ -601,4 +602,4 @@ async def get_get_complete_cv_use_case( async def get_generate_cv_pdf_use_case( get_cv_uc: GetCompleteCVUseCase = Depends(get_get_complete_cv_use_case), ) -> GenerateCVPDFUseCase: - return GenerateCVPDFUseCase(get_cv_use_case=get_cv_uc) + return GenerateCVPDFUseCase(get_cv_use_case=get_cv_uc, pdf_service=WeasyPrintPDFService()) diff --git a/app/api/v1/routers/cv_router.py b/app/api/v1/routers/cv_router.py index 07a4d75..3c23833 100644 --- a/app/api/v1/routers/cv_router.py +++ b/app/api/v1/routers/cv_router.py @@ -1,5 +1,7 @@ +from io import BytesIO + from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import FileResponse +from fastapi.responses import StreamingResponse from app.api.dependencies import ( get_generate_cv_pdf_use_case, @@ -29,7 +31,7 @@ async def get_complete_cv( "/download", summary="Descargar CV en PDF", description="Genera y descarga el CV en formato PDF profesional", - response_class=FileResponse, + response_class=StreamingResponse, ) async def download_cv_pdf( use_case: GenerateCVPDFUseCase = Depends(get_generate_cv_pdf_use_case), @@ -37,7 +39,8 @@ async def download_cv_pdf( result = await use_case.execute(GenerateCVPDFRequest()) if not result.success: raise HTTPException(status_code=500, detail=result.message) - raise HTTPException( - status_code=501, - detail=result.message, + return StreamingResponse( + BytesIO(result.pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": 'attachment; filename="Alex_Zapata_CV.pdf"'}, ) diff --git a/app/application/dto/cv_dto.py b/app/application/dto/cv_dto.py index 4be9054..c772280 100644 --- a/app/application/dto/cv_dto.py +++ b/app/application/dto/cv_dto.py @@ -95,5 +95,5 @@ class GenerateCVPDFResponse: """Response containing PDF generation result.""" success: bool - file_path: str + pdf_bytes: bytes message: str = "PDF generated successfully" diff --git a/app/application/services/__init__.py b/app/application/services/__init__.py index e69de29..a035be5 100644 --- a/app/application/services/__init__.py +++ b/app/application/services/__init__.py @@ -0,0 +1,3 @@ +from .pdf_service import IPDFService + +__all__ = ["IPDFService"] diff --git a/app/application/services/pdf_service.py b/app/application/services/pdf_service.py new file mode 100644 index 0000000..ddcf394 --- /dev/null +++ b/app/application/services/pdf_service.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod + +from app.application.dto.cv_dto import CompleteCVResponse + + +class IPDFService(ABC): + @abstractmethod + def generate_cv_pdf(self, cv_data: CompleteCVResponse) -> bytes: ... diff --git a/app/application/use_cases/cv/generate_cv_pdf.py b/app/application/use_cases/cv/generate_cv_pdf.py index 1a29304..196a74d 100644 --- a/app/application/use_cases/cv/generate_cv_pdf.py +++ b/app/application/use_cases/cv/generate_cv_pdf.py @@ -1,84 +1,23 @@ -""" -Generate CV PDF Use Case. - -Generates the CV as a PDF file (placeholder implementation). -""" +import asyncio from app.application.dto import ( GenerateCVPDFRequest, GenerateCVPDFResponse, GetCompleteCVRequest, ) +from app.application.services.pdf_service import IPDFService from app.shared.interfaces import IQueryUseCase from .get_complete_cv import GetCompleteCVUseCase class GenerateCVPDFUseCase(IQueryUseCase[GenerateCVPDFRequest, GenerateCVPDFResponse]): - """ - Use case for generating CV as PDF. - - Note: This is a placeholder for future PDF generation functionality. - In a real implementation, this would: - 1. Get complete CV data - 2. Generate PDF using a template engine (e.g., ReportLab, WeasyPrint) - 3. Save to file system or cloud storage - 4. Return file path or URL - - For now, it returns a success response with a placeholder path. - - Business Rules: - - Profile must exist - - Multiple format options supported - - Photo inclusion is configurable - - Dependencies: - - GetCompleteCVUseCase: To get CV data - - Future Implementation: - - PDF template engine integration - - File storage service - - Multiple format support (standard, compact, detailed) - """ - - def __init__(self, get_cv_use_case: GetCompleteCVUseCase): - """ - Initialize use case with dependencies. - - Args: - get_cv_use_case: Use case to get complete CV data - """ + def __init__(self, get_cv_use_case: GetCompleteCVUseCase, pdf_service: IPDFService): self.get_cv_use_case = get_cv_use_case + self.pdf_service = pdf_service async def execute(self, request: GenerateCVPDFRequest) -> GenerateCVPDFResponse: - """ - Execute the use case. - - Args: - request: Generate PDF request with format options - - Returns: - GenerateCVPDFResponse with file path - - Note: - This is a placeholder implementation. - Actual PDF generation should be implemented in infrastructure layer. - """ - # Get CV data cv_data = await self.get_cv_use_case.execute(GetCompleteCVRequest()) - - # TODO: Implement actual PDF generation - # This would involve: - # 1. Creating a PDF template - # 2. Populating it with cv_data - # 3. Saving to storage - # 4. Returning the path/URL - - # For now, return a placeholder response - file_path = f"/tmp/cv_{cv_data.profile.id}_{request.format}.pdf" - - return GenerateCVPDFResponse( - success=True, - file_path=file_path, - message=f"PDF generation not yet implemented. Would generate {request.format} format at {file_path}", - ) + # WeasyPrint is synchronous; run in thread pool to avoid blocking the event loop + pdf_bytes = await asyncio.to_thread(self.pdf_service.generate_cv_pdf, cv_data) + return GenerateCVPDFResponse(success=True, pdf_bytes=pdf_bytes) diff --git a/app/infrastructure/services/__init__.py b/app/infrastructure/services/__init__.py index 367606d..c8bcb88 100644 --- a/app/infrastructure/services/__init__.py +++ b/app/infrastructure/services/__init__.py @@ -1,7 +1,9 @@ from .null_email_service import NullEmailService from .resend_email_service import ResendEmailService +from .weasyprint_pdf_service import WeasyPrintPDFService __all__ = [ "NullEmailService", "ResendEmailService", + "WeasyPrintPDFService", ] diff --git a/app/infrastructure/services/templates/cv_template.html b/app/infrastructure/services/templates/cv_template.html new file mode 100644 index 0000000..8c3fd3f --- /dev/null +++ b/app/infrastructure/services/templates/cv_template.html @@ -0,0 +1,417 @@ + + + + + + + +
+ + +
+
{{ cv.profile.name }}
+
{{ cv.profile.headline }}
+
+ {% if cv.profile.location %} + {{ cv.profile.location }} + {% endif %} + {% if cv.contact_info %} + {% if cv.contact_info.email %} + {{ cv.contact_info.email }} + {% endif %} + {% if cv.contact_info.phone %} + {{ cv.contact_info.phone }} + {% endif %} + {% if cv.contact_info.linkedin %} + LinkedIn + {% endif %} + {% if cv.contact_info.github %} + GitHub + {% endif %} + {% if cv.contact_info.website %} + {{ cv.contact_info.website }} + {% endif %} + {% endif %} +
+
+ + + {% if cv.profile.bio %} +
+
Perfil
+
{{ cv.profile.bio }}
+
+ {% endif %} + + + {% if cv.work_experiences %} +
+
Experiencia
+ {% for exp in cv.work_experiences %} +
+
+
+
{{ exp.role }}
+
{{ exp.company }}
+ {% if exp.location %}
{{ exp.location }}
{% endif %} +
+ +
+ {% if exp.description %} +
{{ exp.description }}
+ {% endif %} + {% if exp.responsibilities %} +
    + {% for r in exp.responsibilities %}
  • {{ r }}
  • {% endfor %} +
+ {% endif %} + {% if exp.technologies %} +
+ {% for t in exp.technologies %}{{ t }}{% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+ {% endif %} + + + {% if cv.education %} +
+
Formación
+ {% for edu in cv.education %} +
+
+
+
{{ edu.degree }} en {{ edu.field }}
+
{{ edu.institution }}
+
+ +
+ {% if edu.description %} +
{{ edu.description }}
+ {% endif %} +
+ {% endfor %} +
+ {% endif %} + + + {% if cv.skills or cv.tools %} +
+
Habilidades técnicas
+
+ {% if cv.skills %} +
+
Skills
+
+ {% for s in cv.skills %} + + {{ s.name }} + {% if s.level %}{{ s.level }}{% endif %} + + {% endfor %} +
+
+ {% endif %} + {% if cv.tools %} +
+
Herramientas
+
+ {% for t in cv.tools %} + {{ t.name }} + {% endfor %} +
+
+ {% endif %} +
+
+ {% endif %} + + + {% if cv.projects %} +
+
Proyectos
+ {% for proj in cv.projects %} +
+
+ {{ proj.title }} + {% if proj.live_url %}{{ proj.live_url }} + {% elif proj.repo_url %}{{ proj.repo_url }}{% endif %} +
+ {% if proj.description %} +
{{ proj.description }}
+ {% endif %} + {% if proj.technologies %} +
+ {% for t in proj.technologies %}{{ t }}{% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+ {% endif %} + + + {% if cv.certifications %} +
+
Certificaciones
+ {% for cert in cv.certifications %} +
+
+ {{ cert.title }} + · {{ cert.issuer }} +
+
{{ cert.issue_date.strftime('%b %Y') }}
+
+ {% endfor %} +
+ {% endif %} + + + {% if cv.additional_training %} +
+
Formación complementaria
+ {% for tr in cv.additional_training %} +
+
+ {{ tr.title }} + · {{ tr.provider }} +
+
{{ tr.completion_date.strftime('%Y') }}
+
+ {% endfor %} +
+ {% endif %} + + + {% if cv.social_networks %} +
+
Redes
+
+ {% for sn in cv.social_networks %} + + {{ sn.platform }} + + {% endfor %} +
+
+ {% endif %} + +
+ + diff --git a/app/infrastructure/services/weasyprint_pdf_service.py b/app/infrastructure/services/weasyprint_pdf_service.py new file mode 100644 index 0000000..f88a74e --- /dev/null +++ b/app/infrastructure/services/weasyprint_pdf_service.py @@ -0,0 +1,17 @@ +from pathlib import Path + +import weasyprint +from jinja2 import Environment, FileSystemLoader + +from app.application.dto.cv_dto import CompleteCVResponse +from app.application.services.pdf_service import IPDFService + +_TEMPLATES_DIR = Path(__file__).parent / "templates" +_jinja_env = Environment(loader=FileSystemLoader(str(_TEMPLATES_DIR)), autoescape=True) + + +class WeasyPrintPDFService(IPDFService): + def generate_cv_pdf(self, cv_data: CompleteCVResponse) -> bytes: + template = _jinja_env.get_template("cv_template.html") + html_str = template.render(cv=cv_data) + return weasyprint.HTML(string=html_str).write_pdf() diff --git a/requirements.txt b/requirements.txt index 98a5f88..9a19725 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,3 +33,5 @@ typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.40.0 resend==2.10.0 +weasyprint==65.0 +jinja2==3.1.6 From 95337317ddfbe09142494de8103dbc5f05efab34 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Thu, 7 May 2026 00:33:02 +0200 Subject: [PATCH 2/6] fix(docker): add libglib2.0-0 for WeasyPrint gobject support WeasyPrint requires libgobject-2.0-0 (provided by libglib2.0-0) at import time. Added to both production Dockerfile and Dockerfile.dev. --- Dockerfile | 11 +++++++++-- docker/development/Dockerfile.dev | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 76429f1..7b9f1f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,8 +5,15 @@ WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 -RUN apt-get update && apt-get install -y --no-install-recommends gcc && \ - rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libglib2.0-0 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf2.0-0 \ + libffi-dev \ + shared-mime-info \ + && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip && \ diff --git a/docker/development/Dockerfile.dev b/docker/development/Dockerfile.dev index aa3ba31..abf9a3e 100644 --- a/docker/development/Dockerfile.dev +++ b/docker/development/Dockerfile.dev @@ -12,6 +12,12 @@ ENV PYTHONUNBUFFERED=1 # Instalar dependencias del sistema RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ + libglib2.0-0 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf2.0-0 \ + libffi-dev \ + shared-mime-info \ && rm -rf /var/lib/apt/lists/* # Copiar archivos de dependencias From 2a6a27cf269aa5aee7922457cad66d17f85398da Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Thu, 7 May 2026 00:35:59 +0200 Subject: [PATCH 3/6] fix(docker): use Debian Bookworm package names for WeasyPrint deps Packages libglib2.0-0, libpango-1.0-0, libpangocairo-1.0-0 and libgdk-pixbuf2.0-0 were renamed with t64 suffix in Bookworm as part of the 64-bit time_t transition. --- Dockerfile | 8 ++++---- docker/development/Dockerfile.dev | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7b9f1f5..0edee1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,10 +7,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ - libglib2.0-0 \ - libpango-1.0-0 \ - libpangocairo-1.0-0 \ - libgdk-pixbuf2.0-0 \ + libglib2.0-0t64 \ + libpango-1.0-0t64 \ + libpangocairo-1.0-0t64 \ + libgdk-pixbuf2.0-0t64 \ libffi-dev \ shared-mime-info \ && rm -rf /var/lib/apt/lists/* diff --git a/docker/development/Dockerfile.dev b/docker/development/Dockerfile.dev index abf9a3e..df989a8 100644 --- a/docker/development/Dockerfile.dev +++ b/docker/development/Dockerfile.dev @@ -12,10 +12,10 @@ ENV PYTHONUNBUFFERED=1 # Instalar dependencias del sistema RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ - libglib2.0-0 \ - libpango-1.0-0 \ - libpangocairo-1.0-0 \ - libgdk-pixbuf2.0-0 \ + libglib2.0-0t64 \ + libpango-1.0-0t64 \ + libpangocairo-1.0-0t64 \ + libgdk-pixbuf2.0-0t64 \ libffi-dev \ shared-mime-info \ && rm -rf /var/lib/apt/lists/* From f393813330088f0cd27c29373c9640e279f021a1 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Thu, 7 May 2026 01:51:44 +0200 Subject: [PATCH 4/6] feat(cv): redesign PDF template to match frontend CV page layout Two-column layout with sidebar (contact, networks, skills, tools, certifications) and main area (header, bio, experience, education, projects, training). Uses frontend color palette and xhtml2pdf-compatible CSS (tables instead of flexbox/grid, no CSS variables). --- Dockerfile | 8 +- .../services/templates/cv_template.html | 1039 ++++++++++------- .../services/weasyprint_pdf_service.py | 9 +- docker/development/Dockerfile.dev | 8 +- requirements.txt | 2 +- 5 files changed, 648 insertions(+), 418 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0edee1a..e36097b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,12 +7,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ - libglib2.0-0t64 \ - libpango-1.0-0t64 \ - libpangocairo-1.0-0t64 \ - libgdk-pixbuf2.0-0t64 \ - libffi-dev \ - shared-mime-info \ + pkg-config \ + libcairo2-dev \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/app/infrastructure/services/templates/cv_template.html b/app/infrastructure/services/templates/cv_template.html index 8c3fd3f..c7776cf 100644 --- a/app/infrastructure/services/templates/cv_template.html +++ b/app/infrastructure/services/templates/cv_template.html @@ -1,417 +1,652 @@ + - - + + + -
- - -
-
{{ cv.profile.name }}
-
{{ cv.profile.headline }}
-
- {% if cv.profile.location %} - {{ cv.profile.location }} - {% endif %} - {% if cv.contact_info %} - {% if cv.contact_info.email %} - {{ cv.contact_info.email }} - {% endif %} - {% if cv.contact_info.phone %} - {{ cv.contact_info.phone }} - {% endif %} - {% if cv.contact_info.linkedin %} - LinkedIn + + + + + + + + + + +
+ + + + + + {% if cv.certifications %} + {% endif %} - {% if cv.contact_info.github %} - GitHub + + + {% if cv.skills %} + {% endif %} - {% if cv.contact_info.website %} - {{ cv.contact_info.website }} + + + {% if cv.tools %} + {% endif %} - {% endif %} - - - - - {% if cv.profile.bio %} -
-
Perfil
-
{{ cv.profile.bio }}
-
- {% endif %} - - - {% if cv.work_experiences %} -
-
Experiencia
- {% for exp in cv.work_experiences %} -
-
-
-
{{ exp.role }}
-
{{ exp.company }}
- {% if exp.location %}
{{ exp.location }}
{% endif %} + + + {% if cv.languages is defined and cv.languages %} + -
+ + +
+
{{ cv.profile.name | upper }}
+ {% if cv.profile.headline %} +
{{ cv.profile.headline | upper }}
+ {% endif %} + {% if cv.profile.bio %} +
{{ cv.profile.bio }}
+ {% endif %}
- - {% if exp.description %} -
{{ exp.description }}
- {% endif %} - {% if exp.responsibilities %} -
    - {% for r in exp.responsibilities %}
  • {{ r }}
  • {% endfor %} -
- {% endif %} - {% if exp.technologies %} -
- {% for t in exp.technologies %}{{ t }}{% endfor %} -
- {% endif %} - - {% endfor %} - - {% endif %} - - - {% if cv.education %} -
-
Formación
- {% for edu in cv.education %} -
-
-
-
{{ edu.degree }} en {{ edu.field }}
-
{{ edu.institution }}
+ + + {% if cv.work_experiences %} +
+
Experiencia Reciente
+
+ {% for exp in cv.work_experiences %} +
+
+ {{ exp.role }}{% if exp.company %} — {{ exp.company }}{% endif %} +
+
+ {% if exp.location %}{{ exp.location }}{% endif %} + + {{- exp.start_date.strftime('%b %Y') if exp.start_date else '' -}} + {%- if exp.is_current %} – Actualidad + {%- elif exp.end_date %} – {{ exp.end_date.strftime('%b %Y') }} + {%- endif -%} + + {% if exp.duration_months and exp.duration_months > 0 %} + {% set tot = exp.duration_months | int %} + {% set yrs = tot // 12 %} + {% set mos = tot % 12 %} + + {%- if yrs > 0 %}{{ yrs }} {{ 'año' if yrs == 1 else 'años' }}{% endif %} + {%- if yrs > 0 and mos > 0 %} {% endif %} + {%- if mos > 0 %}{{ mos }} {{ 'mes' if mos == 1 else 'meses' }}{% endif -%} + + {% endif %} +
+ {% if exp.description %} +
{{ exp.description }}
+ {% endif %} + {% if exp.technologies %} +
+ {% for tech in exp.technologies %}{{ tech }}{% endfor %} +
+ {% endif %} +
+ {% endfor %} +
- - {% if edu.description %} -
{{ edu.description }}
- {% endif %} -
- {% endfor %} -
- {% endif %} - - - {% if cv.skills or cv.tools %} -
-
Habilidades técnicas
-
- {% if cv.skills %} -
-
Skills
-
- {% for s in cv.skills %} - - {{ s.name }} - {% if s.level %}{{ s.level }}{% endif %} - - {% endfor %} + {% endif %} + + + {% if cv.additional_training %} +
+
Formación adicional
+
+ {% for item in cv.additional_training %} +
+
+ {% if item.certificate_url %} + {{ item.title }} + {% else %}{{ item.title }}{% endif %} + — {{ item.provider }} +
+
+ {% if item.completion_date %} + {{ item.completion_date.strftime('%b %Y') }} + {% endif %} + {% if item.duration %} + {{ item.duration }} + {% endif %} +
+ {% if item.description %} +
{{ item.description }}
+ {% endif %} +
+ {% endfor %} +
-
- {% endif %} - {% if cv.tools %} -
-
Herramientas
-
- {% for t in cv.tools %} - {{ t.name }} - {% endfor %} + {% endif %} + + + {% if cv.projects %} +
+
Proyectos
+
+ {% for proj in cv.projects %} +
+
+ {% if proj.live_url or proj.repo_url %} + {{ + proj.title }} + {% else %}{{ proj.title }}{% endif %} + {% if proj.is_ongoing %}En curso{% endif %} +
+
+ + {{- proj.start_date.strftime('%b %Y') if proj.start_date else '' -}} + {%- if proj.is_ongoing %} – Actualidad + {%- elif proj.end_date %} – {{ proj.end_date.strftime('%b %Y') }} + {%- endif -%} + +
+ {% if proj.description %} +
{{ proj.description }}
+ {% endif %} + {% if proj.technologies %} +
+ {% for tech in proj.technologies %}{{ tech }}{% endfor %} +
+ {% endif %} +
+ {% endfor %} +
-
- {% endif %} -
-
- {% endif %} - - - {% if cv.projects %} -
-
Proyectos
- {% for proj in cv.projects %} -
-
- {{ proj.title }} - {% if proj.live_url %}{{ proj.live_url }} - {% elif proj.repo_url %}{{ proj.repo_url }}{% endif %} -
- {% if proj.description %} -
{{ proj.description }}
- {% endif %} - {% if proj.technologies %} -
- {% for t in proj.technologies %}{{ t }}{% endfor %} -
- {% endif %} -
- {% endfor %} -
- {% endif %} - - - {% if cv.certifications %} -
-
Certificaciones
- {% for cert in cv.certifications %} -
-
- {{ cert.title }} - · {{ cert.issuer }} -
-
{{ cert.issue_date.strftime('%b %Y') }}
-
- {% endfor %} -
- {% endif %} - - - {% if cv.additional_training %} -
-
Formación complementaria
- {% for tr in cv.additional_training %} -
-
- {{ tr.title }} - · {{ tr.provider }} -
-
{{ tr.completion_date.strftime('%Y') }}
-
- {% endfor %} -
- {% endif %} - - - {% if cv.social_networks %} -
-
Redes
-
- {% for sn in cv.social_networks %} - - {{ sn.platform }} - - {% endfor %} -
-
- {% endif %} - -
+ {% endif %} + +
+ - + + \ No newline at end of file diff --git a/app/infrastructure/services/weasyprint_pdf_service.py b/app/infrastructure/services/weasyprint_pdf_service.py index f88a74e..c0bcdb6 100644 --- a/app/infrastructure/services/weasyprint_pdf_service.py +++ b/app/infrastructure/services/weasyprint_pdf_service.py @@ -1,7 +1,8 @@ +from io import BytesIO from pathlib import Path -import weasyprint from jinja2 import Environment, FileSystemLoader +from xhtml2pdf import pisa from app.application.dto.cv_dto import CompleteCVResponse from app.application.services.pdf_service import IPDFService @@ -10,8 +11,10 @@ _jinja_env = Environment(loader=FileSystemLoader(str(_TEMPLATES_DIR)), autoescape=True) -class WeasyPrintPDFService(IPDFService): +class WeasyPrintPDFService(IPDFService): # nombre mantenido para no alterar imports externos def generate_cv_pdf(self, cv_data: CompleteCVResponse) -> bytes: template = _jinja_env.get_template("cv_template.html") html_str = template.render(cv=cv_data) - return weasyprint.HTML(string=html_str).write_pdf() + buffer = BytesIO() + pisa.CreatePDF(html_str, dest=buffer) + return buffer.getvalue() diff --git a/docker/development/Dockerfile.dev b/docker/development/Dockerfile.dev index df989a8..1e03510 100644 --- a/docker/development/Dockerfile.dev +++ b/docker/development/Dockerfile.dev @@ -12,12 +12,8 @@ ENV PYTHONUNBUFFERED=1 # Instalar dependencias del sistema RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ - libglib2.0-0t64 \ - libpango-1.0-0t64 \ - libpangocairo-1.0-0t64 \ - libgdk-pixbuf2.0-0t64 \ - libffi-dev \ - shared-mime-info \ + pkg-config \ + libcairo2-dev \ && rm -rf /var/lib/apt/lists/* # Copiar archivos de dependencias diff --git a/requirements.txt b/requirements.txt index 9a19725..343660e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,5 +33,5 @@ typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.40.0 resend==2.10.0 -weasyprint==65.0 +xhtml2pdf==0.2.16 jinja2==3.1.6 From 87641fb1981bdb8b7ed729c04d1fa0752637affc Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Thu, 7 May 2026 01:56:08 +0200 Subject: [PATCH 5/6] fix(lint): rename unused execute argument to _request in GenerateCVPDFUseCase --- app/api/dependencies.py | 4 +++- app/application/use_cases/cv/generate_cv_pdf.py | 2 +- app/infrastructure/services/templates/cv_template.html | 2 +- app/infrastructure/services/weasyprint_pdf_service.py | 4 +++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/api/dependencies.py b/app/api/dependencies.py index 3cc21c4..3c72539 100644 --- a/app/api/dependencies.py +++ b/app/api/dependencies.py @@ -602,4 +602,6 @@ async def get_get_complete_cv_use_case( async def get_generate_cv_pdf_use_case( get_cv_uc: GetCompleteCVUseCase = Depends(get_get_complete_cv_use_case), ) -> GenerateCVPDFUseCase: - return GenerateCVPDFUseCase(get_cv_use_case=get_cv_uc, pdf_service=WeasyPrintPDFService()) + return GenerateCVPDFUseCase( + get_cv_use_case=get_cv_uc, pdf_service=WeasyPrintPDFService() + ) diff --git a/app/application/use_cases/cv/generate_cv_pdf.py b/app/application/use_cases/cv/generate_cv_pdf.py index 196a74d..5fec200 100644 --- a/app/application/use_cases/cv/generate_cv_pdf.py +++ b/app/application/use_cases/cv/generate_cv_pdf.py @@ -16,7 +16,7 @@ def __init__(self, get_cv_use_case: GetCompleteCVUseCase, pdf_service: IPDFServi self.get_cv_use_case = get_cv_use_case self.pdf_service = pdf_service - async def execute(self, request: GenerateCVPDFRequest) -> GenerateCVPDFResponse: + async def execute(self, _request: GenerateCVPDFRequest) -> GenerateCVPDFResponse: cv_data = await self.get_cv_use_case.execute(GetCompleteCVRequest()) # WeasyPrint is synchronous; run in thread pool to avoid blocking the event loop pdf_bytes = await asyncio.to_thread(self.pdf_service.generate_cv_pdf, cv_data) diff --git a/app/infrastructure/services/templates/cv_template.html b/app/infrastructure/services/templates/cv_template.html index c7776cf..955fc6f 100644 --- a/app/infrastructure/services/templates/cv_template.html +++ b/app/infrastructure/services/templates/cv_template.html @@ -254,7 +254,7 @@ } .xp-role { - font-size: 9.5pt; + font-size: 10.5pt; font-weight: 700; color: #1a2747; margin-bottom: 3pt; diff --git a/app/infrastructure/services/weasyprint_pdf_service.py b/app/infrastructure/services/weasyprint_pdf_service.py index c0bcdb6..869fa22 100644 --- a/app/infrastructure/services/weasyprint_pdf_service.py +++ b/app/infrastructure/services/weasyprint_pdf_service.py @@ -11,7 +11,9 @@ _jinja_env = Environment(loader=FileSystemLoader(str(_TEMPLATES_DIR)), autoescape=True) -class WeasyPrintPDFService(IPDFService): # nombre mantenido para no alterar imports externos +class WeasyPrintPDFService( + IPDFService +): # nombre mantenido para no alterar imports externos def generate_cv_pdf(self, cv_data: CompleteCVResponse) -> bytes: template = _jinja_env.get_template("cv_template.html") html_str = template.render(cv=cv_data) From c0e4baf9e370aafa29cfe3c56c7792a88d58fcc1 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Thu, 7 May 2026 02:05:49 +0200 Subject: [PATCH 6/6] fix: migrate PDF generation from xhtml2pdf to WeasyPrint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xhtml2pdf pulls in svglib → rlpycairo → pycairo, which requires the libcairo2-dev system library to compile at pip install time. CI runners lacked that dependency, breaking every pipeline run. WeasyPrint ≥ v53 ships a pure-Python wheel (pydyf renderer), so pip install succeeds without any C compilation. Its runtime system deps (Pango, HarfBuzz, GDK-Pixbuf) are added to both Dockerfiles and to all three CI jobs. The service implementation is simplified to a single HTML(string=...).write_pdf() call, matching the file and class name that was already declared as the intended library. --- .github/workflows/tests.yml | 21 +++++++++++++++++++ Dockerfile | 9 +++++--- .../services/templates/cv_template.html | 2 +- .../services/weasyprint_pdf_service.py | 11 +++------- docker/development/Dockerfile.dev | 9 +++++--- requirements.txt | 2 +- 6 files changed, 38 insertions(+), 16 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68407a7..2774c6d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,13 @@ jobs: cache: 'pip' cache-dependency-path: '**/requirements*.txt' + - name: Install system dependencies (WeasyPrint) + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b \ + libffi-dev libgdk-pixbuf-2.0-0 shared-mime-info + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -102,6 +109,13 @@ jobs: cache: 'pip' cache-dependency-path: '**/requirements*.txt' + - name: Install system dependencies (WeasyPrint) + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b \ + libffi-dev libgdk-pixbuf-2.0-0 shared-mime-info + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -166,6 +180,13 @@ jobs: cache: 'pip' cache-dependency-path: '**/requirements*.txt' + - name: Install system dependencies (WeasyPrint) + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b \ + libffi-dev libgdk-pixbuf-2.0-0 shared-mime-info + - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/Dockerfile b/Dockerfile index e36097b..017ca7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - pkg-config \ - libcairo2-dev \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libharfbuzz0b \ + libffi8 \ + libgdk-pixbuf-2.0-0 \ + shared-mime-info \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/app/infrastructure/services/templates/cv_template.html b/app/infrastructure/services/templates/cv_template.html index 955fc6f..74e2781 100644 --- a/app/infrastructure/services/templates/cv_template.html +++ b/app/infrastructure/services/templates/cv_template.html @@ -267,7 +267,7 @@ color: #4a5a7a; } - /* Meta pills row — inline-block approach for xhtml2pdf */ + /* Meta pills row */ .meta-row { margin-bottom: 5pt; line-height: 2.1; diff --git a/app/infrastructure/services/weasyprint_pdf_service.py b/app/infrastructure/services/weasyprint_pdf_service.py index 869fa22..e691d82 100644 --- a/app/infrastructure/services/weasyprint_pdf_service.py +++ b/app/infrastructure/services/weasyprint_pdf_service.py @@ -1,8 +1,7 @@ -from io import BytesIO from pathlib import Path from jinja2 import Environment, FileSystemLoader -from xhtml2pdf import pisa +from weasyprint import HTML from app.application.dto.cv_dto import CompleteCVResponse from app.application.services.pdf_service import IPDFService @@ -11,12 +10,8 @@ _jinja_env = Environment(loader=FileSystemLoader(str(_TEMPLATES_DIR)), autoescape=True) -class WeasyPrintPDFService( - IPDFService -): # nombre mantenido para no alterar imports externos +class WeasyPrintPDFService(IPDFService): def generate_cv_pdf(self, cv_data: CompleteCVResponse) -> bytes: template = _jinja_env.get_template("cv_template.html") html_str = template.render(cv=cv_data) - buffer = BytesIO() - pisa.CreatePDF(html_str, dest=buffer) - return buffer.getvalue() + return HTML(string=html_str).write_pdf() diff --git a/docker/development/Dockerfile.dev b/docker/development/Dockerfile.dev index 1e03510..23afb56 100644 --- a/docker/development/Dockerfile.dev +++ b/docker/development/Dockerfile.dev @@ -11,9 +11,12 @@ ENV PYTHONUNBUFFERED=1 # Instalar dependencias del sistema RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - pkg-config \ - libcairo2-dev \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libharfbuzz0b \ + libffi-dev \ + libgdk-pixbuf-2.0-0 \ + shared-mime-info \ && rm -rf /var/lib/apt/lists/* # Copiar archivos de dependencias diff --git a/requirements.txt b/requirements.txt index 343660e..966d65a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,5 +33,5 @@ typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.40.0 resend==2.10.0 -xhtml2pdf==0.2.16 +weasyprint==63.1 jinja2==3.1.6