324 lines
10 KiB
Python
324 lines
10 KiB
Python
"""
|
|
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,
|
|
)
|
|
|
|
# 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(f"<b>{text}</b>", body_style))
|
|
|
|
# Listes à puces
|
|
elif line.strip().startswith('- ') or line.strip().startswith('* '):
|
|
text = line.strip()[2:]
|
|
# Convertir le markdown basique
|
|
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
|
text = re.sub(r'`(.+?)`', r'<font face="Courier">\1</font>', 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'<b>\1</b>', text)
|
|
text = re.sub(r'`(.+?)`', r'<font face="Courier">\1</font>', text)
|
|
elements.append(Paragraph(f"{num}. {text}", bullet_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'<b>\1</b>', text)
|
|
text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
|
|
text = re.sub(r'`(.+?)`', r'<font face="Courier" color="#c7254e">\1</font>', 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()
|