Cyril Dupland commited on
Commit ·
3cd0aad
1
Parent(s): 49420f1
Enhance PDF generation and update configurations: Add PDF_LOGO_PATH to .env.example, include markdown-pdf in requirements.txt, and refactor PDF generation logic to support logo integration. Introduce tests for PDF generation from Markdown input and update summarizer agent to streamline export functionality.
Browse files- .env.example +4 -1
- assets/.gitkeep +0 -0
- assets/logo.png +0 -0
- config/settings.py +3 -0
- graphs/agents/summarizer_agent.py +7 -9
- graphs/workflows/conversation_with_summary.py +2 -4
- graphs/workflows/orchestrated.py +2 -4
- requirements.txt +4 -0
- tests/ai_synthesis_response.md +71 -0
- tests/conftest.py +6 -0
- tests/test_pdf_generation.py +21 -0
- tools/pdf.py +46 -45
.env.example
CHANGED
|
@@ -18,4 +18,7 @@ LANGCHAIN_API_KEY=
|
|
| 18 |
LANGCHAIN_PROJECT=routeur-ia
|
| 19 |
|
| 20 |
# Voice WebRTC ICE servers (STUN/TURN pour NAT traversal sur HF Spaces)
|
| 21 |
-
# DAILY_API_KEY=61efebb10fa3956006a11e194980876c4453311ef25737fb2e434bc040326deb
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
LANGCHAIN_PROJECT=routeur-ia
|
| 19 |
|
| 20 |
# Voice WebRTC ICE servers (STUN/TURN pour NAT traversal sur HF Spaces)
|
| 21 |
+
# DAILY_API_KEY=61efebb10fa3956006a11e194980876c4453311ef25737fb2e434bc040326deb
|
| 22 |
+
|
| 23 |
+
# PDF - logo en haut du document (section dédiée)
|
| 24 |
+
PDF_LOGO_PATH=assets/logo.png
|
assets/.gitkeep
ADDED
|
File without changes
|
assets/logo.png
ADDED
|
config/settings.py
CHANGED
|
@@ -108,6 +108,9 @@ class Settings(BaseSettings):
|
|
| 108 |
# Voice / Daily.co (alternative transport for HF Spaces, NAT traversal)
|
| 109 |
daily_api_key: Optional[str] = None
|
| 110 |
|
|
|
|
|
|
|
|
|
|
| 111 |
model_config = SettingsConfigDict(
|
| 112 |
env_file=".env",
|
| 113 |
env_file_encoding="utf-8",
|
|
|
|
| 108 |
# Voice / Daily.co (alternative transport for HF Spaces, NAT traversal)
|
| 109 |
daily_api_key: Optional[str] = None
|
| 110 |
|
| 111 |
+
# PDF branding - logo displayed in top section (assets/logo.png)
|
| 112 |
+
pdf_logo_path: Optional[str] = None
|
| 113 |
+
|
| 114 |
model_config = SettingsConfigDict(
|
| 115 |
env_file=".env",
|
| 116 |
env_file_encoding="utf-8",
|
graphs/agents/summarizer_agent.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Summarizer agents: split into two nodes (LLM -> Export PDF/Upload)."""
|
| 2 |
-
from
|
|
|
|
| 3 |
from datetime import datetime, timezone
|
| 4 |
|
| 5 |
from langchain_core.messages import SystemMessage
|
|
@@ -38,8 +39,7 @@ def summarizer_llm_node(
|
|
| 38 |
|
| 39 |
|
| 40 |
def summarizer_export_node(
|
| 41 |
-
|
| 42 |
-
html_to_pdf: Callable[[str], bytes],
|
| 43 |
upload_pdf: Callable[[bytes, str], str],
|
| 44 |
):
|
| 45 |
"""Node: takes `summary_markdown` from state, builds PDF and uploads it.
|
|
@@ -54,9 +54,8 @@ def summarizer_export_node(
|
|
| 54 |
messages = list(state.get("messages", []))
|
| 55 |
summary_markdown = state.get("summary_markdown", "") or ""
|
| 56 |
|
| 57 |
-
# Convert to
|
| 58 |
-
|
| 59 |
-
pdf_result = html_to_pdf(html)
|
| 60 |
|
| 61 |
# Ensure pdf_bytes is of type bytes (not BytesIO)
|
| 62 |
if hasattr(pdf_result, "read"):
|
|
@@ -105,13 +104,12 @@ def summarizer_export_node(
|
|
| 105 |
|
| 106 |
def summarizer_node(
|
| 107 |
llm: BaseChatModel,
|
| 108 |
-
|
| 109 |
-
html_to_pdf: Callable[[str], bytes],
|
| 110 |
upload_pdf: Callable[[bytes, str], str],
|
| 111 |
):
|
| 112 |
"""Compatibility wrapper: runs LLM summarization then export in one node."""
|
| 113 |
llm_runner = summarizer_llm_node(llm)
|
| 114 |
-
export_runner = summarizer_export_node(
|
| 115 |
|
| 116 |
def _run(state: AgentState) -> AgentState:
|
| 117 |
state_after_llm = llm_runner(state)
|
|
|
|
| 1 |
"""Summarizer agents: split into two nodes (LLM -> Export PDF/Upload)."""
|
| 2 |
+
from io import BytesIO
|
| 3 |
+
from typing import Callable, Union
|
| 4 |
from datetime import datetime, timezone
|
| 5 |
|
| 6 |
from langchain_core.messages import SystemMessage
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def summarizer_export_node(
|
| 42 |
+
markdown_to_pdf: Callable[[str], Union[bytes, BytesIO]],
|
|
|
|
| 43 |
upload_pdf: Callable[[bytes, str], str],
|
| 44 |
):
|
| 45 |
"""Node: takes `summary_markdown` from state, builds PDF and uploads it.
|
|
|
|
| 54 |
messages = list(state.get("messages", []))
|
| 55 |
summary_markdown = state.get("summary_markdown", "") or ""
|
| 56 |
|
| 57 |
+
# Convert Markdown to PDF
|
| 58 |
+
pdf_result = markdown_to_pdf(summary_markdown)
|
|
|
|
| 59 |
|
| 60 |
# Ensure pdf_bytes is of type bytes (not BytesIO)
|
| 61 |
if hasattr(pdf_result, "read"):
|
|
|
|
| 104 |
|
| 105 |
def summarizer_node(
|
| 106 |
llm: BaseChatModel,
|
| 107 |
+
markdown_to_pdf: Callable[[str], Union[bytes, BytesIO]],
|
|
|
|
| 108 |
upload_pdf: Callable[[bytes, str], str],
|
| 109 |
):
|
| 110 |
"""Compatibility wrapper: runs LLM summarization then export in one node."""
|
| 111 |
llm_runner = summarizer_llm_node(llm)
|
| 112 |
+
export_runner = summarizer_export_node(markdown_to_pdf, upload_pdf)
|
| 113 |
|
| 114 |
def _run(state: AgentState) -> AgentState:
|
| 115 |
state_after_llm = llm_runner(state)
|
graphs/workflows/conversation_with_summary.py
CHANGED
|
@@ -6,8 +6,7 @@ from graphs.state import AgentState
|
|
| 6 |
from graphs.nodes.retrieval import retrieve_both_types
|
| 7 |
from graphs.agents.chat_agent import chat_node
|
| 8 |
from graphs.agents.summarizer_agent import summarizer_llm_node, summarizer_export_node
|
| 9 |
-
from tools.
|
| 10 |
-
from tools.pdf import html_to_pdf
|
| 11 |
from tools.storage import upload_pdf_to_supabase
|
| 12 |
|
| 13 |
|
|
@@ -21,8 +20,7 @@ def create_conversation_with_summary_graph(llm: BaseChatModel):
|
|
| 21 |
workflow.add_node(
|
| 22 |
"summarizer_export",
|
| 23 |
summarizer_export_node(
|
| 24 |
-
|
| 25 |
-
html_to_pdf=html_to_pdf,
|
| 26 |
upload_pdf=upload_pdf_to_supabase,
|
| 27 |
),
|
| 28 |
)
|
|
|
|
| 6 |
from graphs.nodes.retrieval import retrieve_both_types
|
| 7 |
from graphs.agents.chat_agent import chat_node
|
| 8 |
from graphs.agents.summarizer_agent import summarizer_llm_node, summarizer_export_node
|
| 9 |
+
from tools.pdf import markdown_to_pdf
|
|
|
|
| 10 |
from tools.storage import upload_pdf_to_supabase
|
| 11 |
|
| 12 |
|
|
|
|
| 20 |
workflow.add_node(
|
| 21 |
"summarizer_export",
|
| 22 |
summarizer_export_node(
|
| 23 |
+
markdown_to_pdf=markdown_to_pdf,
|
|
|
|
| 24 |
upload_pdf=upload_pdf_to_supabase,
|
| 25 |
),
|
| 26 |
)
|
graphs/workflows/orchestrated.py
CHANGED
|
@@ -7,8 +7,7 @@ from graphs.agents.classifier_agent import classifier_node
|
|
| 7 |
from graphs.nodes.retrieval import retrieve_catalogue, retrieve_projects
|
| 8 |
from graphs.agents.chat_agent import chat_node
|
| 9 |
from graphs.agents.summarizer_agent import summarizer_llm_node, summarizer_export_node
|
| 10 |
-
|
| 11 |
-
from tools.pdf import html_to_pdf, markdown_to_html
|
| 12 |
from tools.storage import upload_pdf_to_supabase
|
| 13 |
|
| 14 |
|
|
@@ -30,8 +29,7 @@ def create_orchestrated_graph(llm: BaseChatModel, checkpointer=None):
|
|
| 30 |
workflow.add_node(
|
| 31 |
"summarizer_export",
|
| 32 |
summarizer_export_node(
|
| 33 |
-
|
| 34 |
-
html_to_pdf=html_to_pdf,
|
| 35 |
upload_pdf=upload_pdf_to_supabase,
|
| 36 |
),
|
| 37 |
)
|
|
|
|
| 7 |
from graphs.nodes.retrieval import retrieve_catalogue, retrieve_projects
|
| 8 |
from graphs.agents.chat_agent import chat_node
|
| 9 |
from graphs.agents.summarizer_agent import summarizer_llm_node, summarizer_export_node
|
| 10 |
+
from tools.pdf import markdown_to_pdf
|
|
|
|
| 11 |
from tools.storage import upload_pdf_to_supabase
|
| 12 |
|
| 13 |
|
|
|
|
| 29 |
workflow.add_node(
|
| 30 |
"summarizer_export",
|
| 31 |
summarizer_export_node(
|
| 32 |
+
markdown_to_pdf=markdown_to_pdf,
|
|
|
|
| 33 |
upload_pdf=upload_pdf_to_supabase,
|
| 34 |
),
|
| 35 |
)
|
requirements.txt
CHANGED
|
@@ -37,6 +37,10 @@ daily-python>=0.10.0 ; sys_platform == "linux"
|
|
| 37 |
# Summarizer tooling
|
| 38 |
reportlab>=4.4.4,<5
|
| 39 |
markdown>=3.7,<4
|
|
|
|
| 40 |
|
| 41 |
# Ecologits Carbon Footprint
|
| 42 |
ecologits>=0.8.2,<1
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
# Summarizer tooling
|
| 38 |
reportlab>=4.4.4,<5
|
| 39 |
markdown>=3.7,<4
|
| 40 |
+
markdown-pdf>=1.13.0
|
| 41 |
|
| 42 |
# Ecologits Carbon Footprint
|
| 43 |
ecologits>=0.8.2,<1
|
| 44 |
+
|
| 45 |
+
# Tests
|
| 46 |
+
pytest>=8.0.0,<9
|
tests/ai_synthesis_response.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# **Synthèse de l'entretien avec Cédric LAMBERT**
|
| 2 |
+
*Exploitant en arboriculture et productions de semences*
|
| 3 |
+
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
## **Contexte**
|
| 7 |
+
- **Exploitation** : Arboriculture (espèces/variétés à préciser) et productions de semences (légumières, fourragères, etc.).
|
| 8 |
+
- **Enjeux identifiés** (à confirmer lors de la visite) :
|
| 9 |
+
- Optimisation des itinéraires techniques (arboriculture et semences).
|
| 10 |
+
- Gestion des aléas climatiques (gel, sécheresse) et sanitaires (maladies, ravageurs).
|
| 11 |
+
- Rentabilité économique et diversification des débouchés.
|
| 12 |
+
- Transition agroécologique (réduction des intrants, certification HVE/bio).
|
| 13 |
+
- **Objectif de la visite** : Réaliser un premier diagnostic technique et économique pour proposer des solutions adaptées (prestations, formations).
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## **Objectifs de l'agriculteur**
|
| 18 |
+
*(À compléter après l’entretien)*
|
| 19 |
+
- **Priorités techniques** :
|
| 20 |
+
- Exemple : Améliorer la gestion du gel en verger, réduire l’usage des phytosanitaires.
|
| 21 |
+
- **Priorités économiques** :
|
| 22 |
+
- Exemple : Augmenter la marge brute des cultures, diversifier les canaux de vente.
|
| 23 |
+
- **Projets** :
|
| 24 |
+
- Exemple : Plantation de nouveaux vergers, développement de la vente directe de semences.
|
| 25 |
+
- **Attentes vis-à-vis de la Chambre d’Agriculture** :
|
| 26 |
+
- Exemple : Accompagnement sur la transition agroenvironnementale, formation des salariés.
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## **Recommandations**
|
| 31 |
+
|
| 32 |
+
### **Prestations**
|
| 33 |
+
| **Prestation** | **Page** | **Source** | **Contact** |
|
| 34 |
+
|----------------|----------|------------|-------------|
|
| 35 |
+
| Conseil en conduite de vergers (itinéraire technique, protection phytosanitaire) | p.28 | Catalogue Prestations 2024 – Chambre d’Agriculture Pays de la Loire | Service Productions Végétales – [Voir contacts p.50-51] |
|
| 36 |
+
| Diagnostic et plan d’action agro-environnemental (HVE, bio, réduction des intrants) | p.28 | Catalogue Prestations 2024 – Chambre d’Agriculture Pays de la Loire | Service Environnement – [Voir contacts p.50-51] |
|
| 37 |
+
| Accompagnement à la plantation et gestion du bocage (haies, agroforesterie) | p.32 | Catalogue Prestations 2024 – Chambre d’Agriculture Pays de la Loire | Aurélie PAYRAUDEAU (p.32) |
|
| 38 |
+
| Étude économique et stratégie commerciale (diversification, vente directe) | p.16 | Catalogue Prestations 2024 – Chambre d’Agriculture Pays de la Loire | Service Circuits Courts – [Voir contacts p.50-51] |
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
### **Formations**
|
| 43 |
+
| **Formation** | **Page** | **Source** | **Contact** | **Prochaine date / Message par défaut** |
|
| 44 |
+
|---------------|----------|------------|-------------|----------------------------------------|
|
| 45 |
+
| Réussir ses plantations : des choix stratégiques (arboriculture) | p.103 | Catalogue Formations 2024-2025 – Chambre d’Agriculture Pays de la Loire | Florent BANCTEL (conseiller Viticulture) | 05/03 et 12/03/2025 (Clisson) ou contacter pour 49. |
|
| 46 |
+
| Se protéger contre le gel de printemps | p.103 | Catalogue Formations 2024-2025 – Chambre d’Agriculture Pays de la Loire | Thomas CHASSAING (conseiller Viticulture) | 04/03/2025 (44) ou 05/03/2025 (49). |
|
| 47 |
+
| Renforcer l'agronomie pour plus de durabilité | p.46 | Catalogue Formations 2024-2025 – Chambre d’Agriculture Pays de la Loire | Alexandre HATET (expert Sols) | Contacter le service formation pour connaître la prochaine date. |
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## **Prochaines étapes**
|
| 52 |
+
1. **Après l’entretien** :
|
| 53 |
+
- Envoyer un email de remerciement à Cédric LAMBERT avec :
|
| 54 |
+
- Un résumé des points clés abordés.
|
| 55 |
+
- Les prestations/formations proposées (liens vers les pages du catalogue).
|
| 56 |
+
- Une proposition de rendez-vous pour affiner les solutions.
|
| 57 |
+
- Transmettre les documents demandés (parcellaire, registres) au service concerné pour analyse.
|
| 58 |
+
|
| 59 |
+
2. **Plan d’action personnalisé** :
|
| 60 |
+
- Prioriser les prestations/formations en fonction de ses enjeux (ex : si gel = formation "Se protéger contre le gel").
|
| 61 |
+
- Proposer un calendrier (ex : diagnostic agroenvironnemental en mars, formation en avril).
|
| 62 |
+
|
| 63 |
+
3. **Suivi** :
|
| 64 |
+
- Planifier un deuxième rendez-vous pour valider le plan d’action et les devis.
|
| 65 |
+
- Informer les services internes (Environnement, Productions Végétales) pour préparer les interventions.
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
**Documents joints** (à adapter) :
|
| 69 |
+
- Fiche de synthèse personnalisée (cette version).
|
| 70 |
+
- Extraits des catalogues prestations/formations (pages citées).
|
| 71 |
+
- Modèle d’email de suivi.
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pytest configuration and fixtures."""
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
# Add project root to path so "from tools.xxx" imports work
|
| 6 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
tests/test_pdf_generation.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tests/test_pdf_generation.py
|
| 2 |
+
"""Test PDF generation from Markdown input (markdown-pdf)."""
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from tools.pdf import markdown_to_pdf
|
| 6 |
+
|
| 7 |
+
_DIR = Path(__file__).resolve().parent
|
| 8 |
+
MD_INPUT_PATH = _DIR / "ai_synthesis_response.md"
|
| 9 |
+
PDF_OUTPUT_PATH = _DIR / "ai_synthesis_response.pdf"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_generates_pdf_from_markdown_locally():
|
| 13 |
+
"""Generate a PDF from markdown input and verify it is written locally."""
|
| 14 |
+
md_input = MD_INPUT_PATH.read_text(encoding="utf-8")
|
| 15 |
+
buffer = markdown_to_pdf(md_input)
|
| 16 |
+
|
| 17 |
+
PDF_OUTPUT_PATH.write_bytes(buffer.getvalue())
|
| 18 |
+
|
| 19 |
+
assert PDF_OUTPUT_PATH.exists()
|
| 20 |
+
assert PDF_OUTPUT_PATH.stat().st_size > 0
|
| 21 |
+
assert PDF_OUTPUT_PATH.read_bytes()[:4] == b"%PDF"
|
tools/pdf.py
CHANGED
|
@@ -1,50 +1,51 @@
|
|
| 1 |
-
|
| 2 |
-
import re
|
| 3 |
-
|
| 4 |
from io import BytesIO
|
| 5 |
-
from
|
| 6 |
-
from
|
| 7 |
-
|
| 8 |
-
from
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
buffer = BytesIO()
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
styles = getSampleStyleSheet()
|
| 35 |
-
style_title = styles["Title"]
|
| 36 |
-
style_title.alignment = TA_CENTER # Centrer le titre
|
| 37 |
-
style_header = styles["Heading2"]
|
| 38 |
-
style_message = styles["BodyText"]
|
| 39 |
-
|
| 40 |
-
elements = []
|
| 41 |
-
|
| 42 |
-
# Titre principal du document
|
| 43 |
-
elements.append(Paragraph("Synthèse", style_title))
|
| 44 |
-
elements.append(Spacer(1, 20)) # Espacement après le titre
|
| 45 |
-
|
| 46 |
-
elements.append(Paragraph(html, style_message))
|
| 47 |
-
|
| 48 |
-
doc.build(elements)
|
| 49 |
buffer.seek(0)
|
| 50 |
return buffer
|
|
|
|
| 1 |
+
"""PDF generation from Markdown using markdown-pdf."""
|
|
|
|
|
|
|
| 2 |
from io import BytesIO
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from markdown_pdf import MarkdownPdf, Section
|
| 7 |
+
|
| 8 |
+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def markdown_to_pdf(
|
| 12 |
+
md_text: str,
|
| 13 |
+
toc_level: int = 2,
|
| 14 |
+
logo_path: Optional[str] = None,
|
| 15 |
+
) -> BytesIO:
|
| 16 |
+
"""Convert Markdown to PDF using markdown-pdf (PyMuPDF + markdown-it-py).
|
| 17 |
+
|
| 18 |
+
Supports UTF-8, tables, links, images, and TOC from headings.
|
| 19 |
+
Optionally adds a dedicated logo section at the top (centered) if logo_path is set.
|
| 20 |
+
"""
|
| 21 |
+
if logo_path is None:
|
| 22 |
+
try:
|
| 23 |
+
from config.settings import settings
|
| 24 |
+
logo_path = getattr(settings, "pdf_logo_path", None)
|
| 25 |
+
except Exception:
|
| 26 |
+
logo_path = None
|
| 27 |
+
|
| 28 |
+
pdf = MarkdownPdf(toc_level=toc_level, optimize=True)
|
| 29 |
+
|
| 30 |
+
# Section 1: logo centered at top (if file exists)
|
| 31 |
+
logo_file = _PROJECT_ROOT / logo_path if logo_path else None
|
| 32 |
+
if logo_file and logo_file.is_file():
|
| 33 |
+
logo_md = f""
|
| 34 |
+
logo_css = "img { display: block; margin-left: auto; margin-right: auto; }"
|
| 35 |
+
logo_section = Section(logo_md, toc=False)
|
| 36 |
+
logo_section.root = str(_PROJECT_ROOT)
|
| 37 |
+
pdf.add_section(logo_section, user_css=logo_css)
|
| 38 |
+
|
| 39 |
+
# Section 2: main content (réduit la police)
|
| 40 |
+
content_css = (
|
| 41 |
+
"body, p, li, ul, ol, td, th { font-size: 9pt; } "
|
| 42 |
+
"h1 { font-size: 14pt; } h2 { font-size: 12pt; } h3 { font-size: 10pt; }"
|
| 43 |
+
)
|
| 44 |
+
content_section = Section(md_text)
|
| 45 |
+
content_section.root = str(_PROJECT_ROOT)
|
| 46 |
+
pdf.add_section(content_section, user_css=content_css)
|
| 47 |
|
| 48 |
buffer = BytesIO()
|
| 49 |
+
pdf.save_bytes(buffer)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
buffer.seek(0)
|
| 51 |
return buffer
|