"""
Générateur PDF à partir de contenu Markdown.
"""
import io
import re
from typing import Optional, Tuple
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
SimpleDocTemplate,
Paragraph,
Spacer,
Preformatted,
Table,
TableStyle,
PageBreak,
)
def extract_leading_emojis(text: str) -> Tuple[str, str]:
"""Extrait les emojis en début de texte.
Args:
text: Texte potentiellement commençant par des emojis
Returns:
Tuple (emojis, texte_restant)
"""
emoji_pattern = re.compile(
"["
"\U0001F600-\U0001F64F" # emoticons
"\U0001F300-\U0001F5FF" # symbols & pictographs
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F1E0-\U0001F1FF" # flags
"\U00002702-\U000027B0" # dingbats
"\U000024C2-\U0001F251" # enclosed characters
"\U0001F900-\U0001F9FF" # supplemental symbols
"\U0001FA00-\U0001FA6F" # chess symbols
"\U0001FA70-\U0001FAFF" # symbols extended
"\U00002600-\U000026FF" # misc symbols
"]+"
)
match = emoji_pattern.match(text)
if match:
emojis = match.group()
rest = text[len(emojis):].lstrip()
return emojis, rest
return "", text
def emoji_to_png_bytes(emoji: str, size: int = 32) -> Optional[bytes]:
"""Convertit un emoji en image PNG.
Note: Nécessite Pillow avec support de polices emoji.
Args:
emoji: Caractère emoji
size: Taille en pixels
Returns:
Bytes de l'image PNG ou None si échec
"""
try:
from PIL import Image, ImageDraw, ImageFont
# Créer une image transparente
img = Image.new('RGBA', (size, size), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)
# Essayer différentes polices emoji
font_candidates = [
"/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
"/System/Library/Fonts/Apple Color Emoji.ttc",
"C:\\Windows\\Fonts\\seguiemj.ttf",
"/usr/share/fonts/google-noto-emoji/NotoColorEmoji.ttf",
]
font = None
for font_path in font_candidates:
try:
font = ImageFont.truetype(font_path, size - 4)
break
except (OSError, IOError):
continue
if font is None:
font = ImageFont.load_default()
# Dessiner l'emoji centré
bbox = draw.textbbox((0, 0), emoji, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (size - text_width) // 2
y = (size - text_height) // 2
draw.text((x, y), emoji, font=font, embedded_color=True)
# Convertir en bytes
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
return buffer.getvalue()
except Exception:
return None
def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> bytes:
"""Convertit du contenu Markdown en PDF.
Args:
markdown_content: Contenu au format Markdown
title: Titre du document
Returns:
Bytes du PDF généré
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
# Styles
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
spaceAfter=30,
textColor=colors.HexColor('#1a1a2e'),
)
h1_style = ParagraphStyle(
'CustomH1',
parent=styles['Heading1'],
fontSize=18,
spaceBefore=20,
spaceAfter=12,
textColor=colors.HexColor('#16213e'),
)
h2_style = ParagraphStyle(
'CustomH2',
parent=styles['Heading2'],
fontSize=14,
spaceBefore=15,
spaceAfter=8,
textColor=colors.HexColor('#0f3460'),
)
h3_style = ParagraphStyle(
'CustomH3',
parent=styles['Heading3'],
fontSize=12,
spaceBefore=10,
spaceAfter=6,
textColor=colors.HexColor('#1a1a2e'),
)
body_style = ParagraphStyle(
'CustomBody',
parent=styles['Normal'],
fontSize=10,
spaceBefore=4,
spaceAfter=4,
leading=14,
)
code_style = ParagraphStyle(
'CustomCode',
parent=styles['Code'],
fontSize=8,
fontName='Courier',
backColor=colors.HexColor('#f4f4f4'),
borderColor=colors.HexColor('#e0e0e0'),
borderWidth=1,
borderPadding=5,
spaceBefore=8,
spaceAfter=8,
)
bullet_style = ParagraphStyle(
'CustomBullet',
parent=body_style,
leftIndent=20,
bulletIndent=10,
)
quote_style = ParagraphStyle(
'CustomQuote',
parent=body_style,
leftIndent=20,
borderColor=colors.HexColor('#7c3aed'),
borderWidth=2,
borderPadding=8,
backColor=colors.HexColor('#f5f3ff'),
textColor=colors.HexColor('#5b21b6'),
fontSize=9,
spaceBefore=8,
spaceAfter=8,
)
h4_style = ParagraphStyle(
'CustomH4',
parent=styles['Heading4'],
fontSize=11,
spaceBefore=8,
spaceAfter=4,
textColor=colors.HexColor('#374151'),
)
# Parser le Markdown
elements = []
elements.append(Paragraph(title, title_style))
elements.append(Spacer(1, 20))
lines = markdown_content.split('\n')
in_code_block = False
code_block_content = []
in_table = False
table_rows = []
for line in lines:
# Blocs de code
if line.strip().startswith('```'):
if in_code_block:
# Fin du bloc de code
code_text = '\n'.join(code_block_content)
if code_text.strip():
elements.append(Preformatted(code_text, code_style))
code_block_content = []
in_code_block = False
else:
# Début du bloc de code
in_code_block = True
continue
if in_code_block:
code_block_content.append(line)
continue
# Tables Markdown
if '|' in line and not line.strip().startswith('#'):
cells = [c.strip() for c in line.split('|')]
cells = [c for c in cells if c] # Supprimer les cellules vides
if cells and not all(c.replace('-', '').replace(':', '') == '' for c in cells):
if not in_table:
in_table = True
table_rows.append(cells)
continue
elif in_table and table_rows:
# Fin de la table
if len(table_rows) > 1:
# Créer la table
table = Table(table_rows)
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#e0e0e0')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#1a1a2e')),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, 0), 8),
('TOPPADDING', (0, 0), (-1, -1), 4),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#cccccc')),
]))
elements.append(table)
elements.append(Spacer(1, 10))
table_rows = []
in_table = False
# Titres
if line.startswith('# '):
emojis, text = extract_leading_emojis(line[2:].strip())
display_text = f"{emojis} {text}".strip() if emojis else text
elements.append(Paragraph(display_text, h1_style))
elif line.startswith('## '):
emojis, text = extract_leading_emojis(line[3:].strip())
display_text = f"{emojis} {text}".strip() if emojis else text
elements.append(Paragraph(display_text, h2_style))
elif line.startswith('### '):
emojis, text = extract_leading_emojis(line[4:].strip())
display_text = f"{emojis} {text}".strip() if emojis else text
elements.append(Paragraph(display_text, h3_style))
elif line.startswith('#### '):
text = line[5:].strip()
elements.append(Paragraph(text, h4_style))
# Listes à puces
elif line.strip().startswith('- ') or line.strip().startswith('* '):
text = line.strip()[2:]
# Convertir le markdown basique
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'`(.+?)`', r'\1', text)
elements.append(Paragraph(f"• {text}", bullet_style))
# Listes numérotées
elif re.match(r'^\d+\.\s', line.strip()):
match = re.match(r'^(\d+)\.\s(.+)', line.strip())
if match:
num, text = match.groups()
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'`(.+?)`', r'\1', text)
elements.append(Paragraph(f"{num}. {text}", bullet_style))
# Blockquotes (citations)
elif line.strip().startswith('> '):
text = line.strip()[2:]
# Convertir le markdown basique
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'`(.+?)`', r'\1', text)
elements.append(Paragraph(text, quote_style))
# Ligne horizontale
elif line.strip() in ['---', '***', '___']:
elements.append(Spacer(1, 10))
# Paragraphe normal
elif line.strip():
text = line.strip()
# Convertir le markdown basique
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'`(.+?)`', r'\1', text)
elements.append(Paragraph(text, body_style))
else:
# Ligne vide
elements.append(Spacer(1, 6))
# Traiter la dernière table si présente
if in_table and table_rows and len(table_rows) > 1:
table = Table(table_rows)
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#e0e0e0')),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#cccccc')),
]))
elements.append(table)
# Générer le PDF
doc.build(elements)
buffer.seek(0)
return buffer.getvalue()